Merge pull request #1304 from basecamp/magic-links

Magic links
This commit is contained in:
Stanko Krtalić
2025-10-31 16:41:12 +01:00
committed by GitHub
146 changed files with 2595 additions and 936 deletions
+12
View File
@@ -1,4 +1,16 @@
@layer components {
.boxcar-header-logo {
color: var(--color-ink);
inline-size: auto;
margin-block-start: 0.1em;
img {
block-size: auto;
inline-size: 1.15em;
margin-inline-end: 0.8ch;
}
}
.input:is(.fizzy-menu-trigger) {
--input-background: var(--color-canvas);
--input-border-color: transparent;
+20
View File
@@ -28,6 +28,26 @@
&[readonly] {
--focus-ring-size: 0;
}
&[autocomplete='one-time-code'] {
--input-spacing: 0.5em;
font-family: var(--font-mono);
font-size: var(--text-large);
font-weight: 900;
inline-size: 18ch;
letter-spacing: 1ch;
min-inline-size: 18ch;
text-align: center;
}
&[type='number'] {
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
.input--file {
+1
View File
@@ -19,6 +19,7 @@
.txt-subtle { color: var(--color-ink-medium); }
.txt-alert { color: var(--color-marker); }
.txt-undecorated { text-decoration: none; }
.txt-underline { text-decoration: underline; }
.txt-tight-lines { line-height: 1.2; }
.txt-nowrap { white-space: nowrap; }
.txt-break { word-break: break-word; }
+3 -2
View File
@@ -4,13 +4,14 @@ module ApplicationCable
def connect
super
ApplicationRecord.with_tenant(current_tenant) { set_current_user || reject_unauthorized_connection }
set_current_user || reject_unauthorized_connection
end
private
def set_current_user
if session = find_session_by_cookie
self.current_user = session.user
membership = session.identity.memberships.find_by!(tenant: current_tenant)
self.current_user = membership.user if membership.user.active?
end
end
@@ -1,10 +1,31 @@
class Account::JoinCodesController < ApplicationController
before_action :set_join_code
def show
render svg: RQRCode::QRCode.new(join_url(Account.sole.join_code)).as_svg(viewbox: true, fill: :white, color: :black)
end
def edit
end
def update
Account.sole.reset_join_code
redirect_to users_path
if @join_code.update(join_code_params)
redirect_to account_join_code_path
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@join_code.reset
redirect_to account_join_code_path
end
private
def set_join_code
@join_code = Account::JoinCode.sole
end
def join_code_params
params.expect account_join_code: [ :usage_limit ]
end
end
@@ -3,4 +3,18 @@ class Account::SettingsController < ApplicationController
@account = Account.sole
@users = User.active.alphabetically
end
def update
if Current.user.can_administer?
Account.sole.update!(account_params)
redirect_to account_settings_path
else
head :forbidden
end
end
private
def account_params
params.expect account: %i[ name ]
end
end
+2 -1
View File
@@ -1,6 +1,7 @@
class ApplicationController < ActionController::Base
include LoadBalancerRouting, WriterAffinity
include Authentication, Authorization
include Authentication
include Authorization
include CurrentRequest, CurrentTimezone, SetPlatform
include TurboFlash, ViewTransitions
+6 -22
View File
@@ -26,7 +26,6 @@ module Authentication
def require_untenanted_access(**options)
skip_before_action :require_tenant, **options
skip_before_action :require_authentication, **options
before_action :redirect_tenanted_request, **options
end
end
@@ -38,8 +37,7 @@ module Authentication
def require_tenant
unless ApplicationRecord.current_tenant.present?
set_current_identity_token
render "sessions/login_menu"
redirect_to session_menu_url(script_name: nil)
end
end
@@ -57,7 +55,7 @@ module Authentication
Session.find_signed(cookies.signed[:session_token])
end
def request_authentication(untenanted: false)
def request_authentication
if ApplicationRecord.current_tenant.present?
session[:return_to_after_authenticating] = request.url
end
@@ -77,30 +75,16 @@ module Authentication
redirect_to root_url if ApplicationRecord.current_tenant
end
def start_new_session_for(user)
link_identity(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 link_identity(user)
token_value = cookies.signed[:identity_token]
token_identity = Identity.find_signed(token_value["id"]) if token_value.present?
identity = user.set_identity(token_identity)
cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax }
end
def set_current_identity_token
link_identity(Current.user) if cookies.signed[:identity_token].nil? && Current.user.present?
Current.identity_token = Identity::Mock.new(**cookies.signed[:identity_token])
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
set_current_identity_token
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
@@ -1,7 +0,0 @@
module Authentication::SessionLookup
def find_session_by_cookie
if token = cookies.signed[:session_token]
Session.find_by(token: token)
end
end
end
+31
View File
@@ -1,4 +1,21 @@
module Authorization
extend ActiveSupport::Concern
included do
before_action :ensure_can_access_account, if: -> { ApplicationRecord.current_tenant && Current.session }
end
class_methods do
def allow_unauthorized_access(**options)
skip_before_action :ensure_can_access_account, **options
end
def require_access_without_a_user(**options)
skip_before_action :ensure_can_access_account, **options
before_action :redirect_existing_user, **options
end
end
private
def ensure_admin
head :forbidden unless Current.user.admin?
@@ -7,4 +24,18 @@ 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
end
+32
View File
@@ -0,0 +1,32 @@
class JoinCodesController < ApplicationController
require_untenanted_access
allow_unauthenticated_access
before_action :set_join_code
def new
@account_name = ApplicationRecord.with_tenant(tenant) { Account.sole.name }
end
def create
Identity.transaction do
identity = Identity.find_or_create_by(email_address: params.expect(:email_address))
identity.memberships.create!(tenant: tenant, join_code: code)
identity.send_magic_link
end
redirect_to session_magic_link_path
end
private
def set_join_code
@join_code ||= ApplicationRecord.with_tenant(tenant) { Account::JoinCode.active.find_by(code: code) }
end
def tenant
params.expect(:tenant)
end
def code
params.expect(:code)
end
end
@@ -0,0 +1,28 @@
class Memberships::EmailAddresses::ConfirmationsController < ApplicationController
require_untenanted_access
allow_unauthenticated_access
before_action :set_membership
rate_limit to: 5, within: 1.hour, only: :create
def show
end
def create
membership = Membership.change_email_address_using_token(token)
terminate_session
start_new_session_for membership.reload.identity
redirect_to edit_user_url(script_name: "/#{@membership.tenant}", id: @membership.user)
end
private
def set_membership
@membership = Membership.find(params[:membership_id])
end
def token
params.expect :email_address_token
end
end
@@ -0,0 +1,22 @@
class Memberships::EmailAddressesController < ApplicationController
require_untenanted_access
before_action :set_membership
rate_limit to: 5, within: 1.hour, only: :create
def new
end
def create
@membership.send_email_address_change_confirmation(new_email_address)
end
private
def set_membership
@membership = Current.identity.memberships.find(params[:membership_id])
end
def new_email_address
params.expect :email_address
end
end
@@ -0,0 +1,17 @@
class Memberships::UnlinkController < ApplicationController
require_untenanted_access
before_action :set_membership
def show
end
def create
@membership.destroy
redirect_to session_menu_path
end
private
def set_membership
@membership = Current.identity.memberships.find_signed!(params[:membership_id], purpose: :unlinking)
end
end
+1 -1
View File
@@ -2,6 +2,6 @@ class My::MenusController < ApplicationController
include FilterScoped
def show
fresh_when etag: [ @user_filtering, Current.identity_token ]
fresh_when etag: [ @user_filtering, Current.session ]
end
end
@@ -1,5 +1,6 @@
class Notifications::UnsubscribesController < ApplicationController
allow_unauthenticated_access
allow_unauthorized_access
skip_before_action :verify_authenticity_token
before_action :set_user
@@ -2,6 +2,7 @@ class Public::CardsController < ApplicationController
include CachedPublicly, PublicCardScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::Collections::Columns::ClosedsController < ApplicationController
include CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::Collections::Columns::NotNowsController < ApplicationController
include CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::Collections::Columns::StreamsController < ApplicationController
include CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::Collections::ColumnsController < ApplicationController
include ActionView::RecordIdentifier, CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -2,6 +2,7 @@ class Public::CollectionsController < ApplicationController
include CachedPublicly, PublicCollectionScoped
allow_unauthenticated_access only: :show
allow_unauthorized_access only: :show
layout "public"
@@ -0,0 +1,24 @@
class Sessions::MagicLinksController < ApplicationController
require_untenanted_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"
def show
end
def create
if identity = MagicLink.consume(code)
start_new_session_for identity
redirect_to after_authentication_url
else
redirect_to session_magic_link_path, alert: "Try another code."
end
end
private
def code
params.expect(: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,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
+12 -7
View File
@@ -1,21 +1,26 @@
class SessionsController < ApplicationController
require_unauthenticated_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"
def new
end
def create
if user = User.active.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
Identity.find_by_email_address(email_address)&.send_magic_link
redirect_to session_magic_link_path
end
def destroy
terminate_session
redirect_to_logout_url
end
private
def email_address
params.expect(:email_address)
end
end
+17 -9
View File
@@ -1,21 +1,23 @@
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]
def new
@user = User.new
end
def create
user = User.create!(user_params)
start_new_session_for user
@join_code.redeem do
User.create!(user_params.merge(membership: Current.membership))
end
redirect_to root_path
end
@@ -38,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.sole.join_code == 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
@@ -59,6 +67,6 @@ class UsersController < ApplicationController
end
def user_params
params.expect(user: [ :name, :email_address, :password, :avatar ])
params.expect(user: [ :name, :avatar ])
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
module LoginHelper
def login_url
new_session_path
new_session_path(script_name: nil)
end
def logout_url
@@ -2,7 +2,42 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.addEventListener("turbo:submit-end", () => this.element.remove(), { once: true } )
this.element.addEventListener("turbo:submit-end", this.#handleSubmitEnd.bind(this), { once: true })
this.submit()
}
submit() {
this.#markAsBusy()
this.#disableSubmit()
this.element.requestSubmit()
}
#handleSubmitEnd(event) {
if (event.detail.success) {
this.element.remove()
} else {
this.#clearBusy()
this.#enableSubmit()
}
}
#markAsBusy() {
this.element.setAttribute("aria-busy", "true")
}
#clearBusy() {
this.element.setAttribute("aria-busy", "false")
}
#disableSubmit() {
this.#submitElements().forEach(element => element.disabled = true)
}
#enableSubmit() {
this.#submitElements().forEach(element => element.disabled = false)
}
#submitElements() {
return this.element.querySelectorAll("input[type=submit],button")
}
}
@@ -20,7 +20,7 @@ export default class extends Controller {
const input = this.hasInputTarget ? this.inputTarget : null
if (input) {
const value = (input.value || "").trim()
const value = (input.value || "").trim()
if (value.length === 0) {
event.preventDefault()
}
+5 -1
View File
@@ -7,6 +7,10 @@ class ApplicationMailer < ActionMailer::Base
private
def default_url_options
super.merge(script_name: Account.sole.slug)
if ApplicationRecord.current_tenant
super.merge(script_name: Account.sole.slug)
else
super
end
end
end
+7
View File
@@ -0,0 +1,7 @@
class IdentityMailer < ApplicationMailer
def email_change_confirmation(email_address:, token:, membership:)
@token = token
@membership = membership
mail to: email_address, subject: "Confirm your new email address"
end
end
+13
View File
@@ -0,0 +1,13 @@
class MagicLinkMailer < ApplicationMailer
def sign_in_instructions(magic_link)
@magic_link = magic_link
@identity = @magic_link.identity
mail to: @identity.email_address, subject: "Sign in to Fizzy"
end
private
def default_url_options
Rails.application.config.action_mailer.default_url_options
end
end
+1 -1
View File
@@ -10,7 +10,7 @@ class Notification::BundleMailer < ApplicationMailer
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
mail \
to: bundle.user.email_address,
to: bundle.user.identity.email_address,
subject: "Latest Activity in BOXCAR"
end
end
+8
View File
@@ -0,0 +1,8 @@
class UserMailer < ApplicationMailer
def email_change_confirmation(user:, email_address:, token:)
@user = user
@token = token
mail to: email_address, subject: "Confirm your new email address"
end
end
+14 -4
View File
@@ -1,13 +1,18 @@
class Account < ApplicationRecord
include Entropic, Joinable
include Entropic
has_many_attached :uploads
after_create :create_join_code
validates :name, presence: true
class << self
def create_with_admin_user(account:, owner:)
User.system
User.create!(**owner.reverse_merge(role: "admin", password: SecureRandom.hex(16)))
create!(**account)
create!(**account).tap do
User.system
User.create!(**owner.reverse_merge(role: "admin"))
end
end
end
@@ -25,4 +30,9 @@ class Account < ApplicationRecord
Collection.create!(name: "Cards", creator: user, all_access: true)
end
private
def create_join_code
Account::JoinCode.create!
end
end
+36
View File
@@ -0,0 +1,36 @@
class Account::JoinCode < ApplicationRecord
CODE_LENGTH = 12
scope :active, -> { where("usage_count < usage_limit") }
before_validation :generate_code, on: :create, if: -> { code.blank? }
validates :code, presence: true, uniqueness: true
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 }
def redeem
transaction do
increment!(:usage_count)
yield if block_given?
end
end
def active?
usage_count < usage_limit
end
def reset
generate_code
self.usage_count = 0
save!
end
private
def generate_code
self.code = loop do
candidate = SecureRandom.base58(CODE_LENGTH).scan(/.{4}/).join("-")
break candidate unless self.class.exists?(code: candidate)
end
end
end
-16
View File
@@ -1,16 +0,0 @@
module Account::Joinable
extend ActiveSupport::Concern
included do
before_create { self.join_code = generate_join_code }
end
def reset_join_code
update! join_code: generate_join_code
end
private
def generate_join_code
SecureRandom.alphanumeric(12).scan(/.{4}/).join("-")
end
end
+11 -2
View File
@@ -1,6 +1,15 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session, :identity_token
attribute :session, :membership
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
delegate :user, to: :session, allow_nil: true
delegate :identity, to: :session, allow_nil: true
delegate :user, to: :membership, allow_nil: true
def session=(value)
super(value)
unless value.nil?
self.membership = identity.memberships.find_by(tenant: ApplicationRecord.current_tenant)
end
end
end
+16 -3
View File
@@ -1,7 +1,20 @@
class Identity < UntenantedRecord
# This is used to instantiate an Identity-like object from the `identity_token` without hitting
# the untenanted database. It is intended to be used with caching/etagging methods.
Mock = Struct.new(:id, :updated_at)
include Transferable
has_many :memberships, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
normalizes :email_address, with: ->(value) { value.strip.downcase }
validates :email_address, presence: true
def send_magic_link
magic_links.create!.tap do |magic_link|
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
def staff?
email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com")
end
end
+15
View File
@@ -0,0 +1,15 @@
module Identity::Transferable
extend ActiveSupport::Concern
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
class_methods do
def find_by_transfer_id(id)
find_signed(id, purpose: :transfer)
end
end
def transfer_id
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
end
end
+41
View File
@@ -0,0 +1,41 @@
class MagicLink < UntenantedRecord
CODE_LENGTH = 6
EXPIRATION_TIME = 15.minutes
belongs_to :identity
scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }
before_validation :generate_code, on: :create
before_validation :set_expiration, on: :create
validates :code, uniqueness: true, presence: true
class << self
def consume(code)
active.find_by(code: Code.sanitize(code))&.consume
end
def cleanup
stale.delete_all
end
end
def consume
destroy
identity
end
private
def generate_code
self.code ||= loop do
candidate = Code.generate(CODE_LENGTH)
break candidate unless self.class.exists?(code: candidate)
end
end
def set_expiration
self.expires_at ||= EXPIRATION_TIME.from_now
end
end
+31
View File
@@ -0,0 +1,31 @@
module MagicLink::Code
CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".chars.freeze
CODE_SUBSTITUTIONS = { "O" => "0", "I" => "1", "L" => "1" }.freeze
class << self
def generate(length)
length.times.map { CODE_ALPHABET.sample }.join
end
def sanitize(code)
return nil if code.blank?
normalize_code(code)
.then { apply_substitutions(_1) }
.then { remove_invalid_characters(_1) }
end
private
def normalize_code(code)
code.to_s.upcase
end
def apply_substitutions(code)
CODE_SUBSTITUTIONS.reduce(code) { |result, (from, to)| result.gsub(from, to) }
end
def remove_invalid_characters(code)
code.gsub(/[^#{CODE_ALPHABET.join}]/, "")
end
end
end
+23 -7
View File
@@ -1,13 +1,29 @@
class Membership < UntenantedRecord
include EmailAddressChangeable
belongs_to :identity, touch: true
# I want this to be `belongs_to :user`, but ActiveRecord::Tenanted doesn't yet support
# associations from untenanted to untenanted models.
#
# See https://github.com/basecamp/activerecord-tenanted/issues/201
#
# In the meantime, when creating a Membership, specify both `user_id` and `user_tenant` attributes.
class << self
def change_email_address(from:, to:, tenant:)
identity = Identity.find_by(email_address: from)
membership = find_by(tenant: tenant, identity: identity)
if membership
new_identity = Identity.find_or_create_by!(email_address: to)
membership.update!(identity: new_identity)
end
end
end
def account_name
ApplicationRecord.with_tenant(tenant) { Account.sole.name }
rescue ActiveRecord::Tenanted::TenantDoesNotExistError
nil
end
def user
User.with_tenant(user_tenant) { User.find_by(id: user_id) }
ApplicationRecord.with_tenant(tenant) { User.find_by(membership_id: id) }
rescue ActiveRecord::Tenanted::TenantDoesNotExistError
nil
end
end
@@ -0,0 +1,57 @@
module Membership::EmailAddressChangeable
EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address"
EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes
extend ActiveSupport::Concern
class_methods do
def change_email_address_using_token(token)
parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)
membership = parsed_token&.find
if parsed_token.nil?
raise ArgumentError, "The token is invalid"
elsif membership.nil?
raise ArgumentError, "The membership no longer exists"
elsif membership.identity.email_address != parsed_token.params.fetch("old_email_address")
raise ArgumentError, "The token was generated for a different email address"
else
new_email_address = parsed_token.params.fetch("new_email_address")
membership.change_email_address(new_email_address)
end
membership
end
end
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
)
IdentityMailer.email_change_confirmation(
email_address: new_email_address,
token: token,
membership: self
).deliver_later
end
def change_email_address(new_email_address)
transaction do
new_identity = Identity.find_or_create_by!(email_address: new_email_address)
update!(identity: new_identity)
end
end
private
def generate_email_address_change_token(from: identity.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
end
+2 -2
View File
@@ -1,3 +1,3 @@
class Session < ApplicationRecord
belongs_to :user
class Session < UntenantedRecord
belongs_to :identity
end
+12 -14
View File
@@ -1,13 +1,15 @@
class User < ApplicationRecord
include Accessor, Assignee, Attachable, Configurable, Identifiable,
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
has_secure_password validations: false
belongs_to :membership, optional: true
has_one :identity, through: :membership, disable_joins: true
has_many :comments, inverse_of: :creator, dependent: :destroy
@@ -18,14 +20,10 @@ class User < ApplicationRecord
normalizes :email_address, with: ->(value) { value.strip.downcase }
def deactivate
sessions.delete_all
accesses.destroy_all
update! active: false, email_address: deactived_email_address
end
delegate :staff?, to: :identity, allow_nil: true
private
def deactived_email_address
email_address.sub(/@/, "-deactivated-#{SecureRandom.uuid}@")
end
def deactivate
accesses.destroy_all
update! active: false
end
end
-28
View File
@@ -1,28 +0,0 @@
module User::Identifiable
extend ActiveSupport::Concern
included do
has_one :membership, ->(user) { where(user_tenant: user.tenant) }
has_one :identity, through: :membership
end
def set_identity(token_identity)
if token_identity.present?
if identity.nil?
token_identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name)
elsif identity != token_identity
Identity.transaction do
identity.memberships.update_all(identity_id: token_identity.id)
identity.destroy
end
end
token_identity
elsif identity.present?
identity
else
Identity.create!.tap do |identity|
identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name)
end
end
end
end
-7
View File
@@ -1,7 +0,0 @@
module User::Staff
extend ActiveSupport::Concern
def staff?
email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com")
end
end
@@ -0,0 +1,36 @@
<% @page_title = "Change usage limit" %>
<% content_for :header do %>
<%= render "my/menus/menu" %>
<div class="header__actions header__actions--start">
<%= link_to account_join_code_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">&larr; <strong>Invite link</strong></span>
<% end %>
</div>
<% end %>
<article class="panel center shadow flex flex-column gap-half" style="view-transition-name: <%= dom_id(@join_code) %>">
<header class="margin-block-end-half">
<h2 class="txt-large margin-none font-weight-black"><%= @page_title %></h2>
<p class="txt-medium margin-none">How many times can this link be used to join the account?</p>
</header>
<%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<%= form.number_field :usage_limit,
required: true,
autofocus: true,
min: @join_code.usage_count,
class: "input center txt-large fit-content font-weight-black txt-align-center",
style: "max-inline-size: 8ch",
data: { action: "keydown.esc@document->form#cancel focus->form#select" } %>
<p class="margin-none txt-subtle">
This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times.
</p>
<%= form.button type: :submit, class: "btn btn--link center txt-medium", data: { form_target: "submit" } do %>
<span>Save changes</span>
<% end %>
<%= link_to "Go back", account_join_code_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
@@ -0,0 +1,68 @@
<% @page_title = "Add people" %>
<% content_for :header do %>
<%= render "my/menus/menu" %>
<div class="header__actions header__actions--start">
<%= link_to account_settings_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">&larr; <strong>Account Settings</strong></span>
<% end %>
</div>
<% end %>
<div class="panel shadow center flex flex-column gap" style="view-transition-name: <%= dom_id(@join_code) %>">
<header>
<h2 class="txt-large margin-none font-weight-black"><%= @page_title %></h2>
<p class="txt-medium margin-none">Share the link below to invite people to this account</p>
</header>
<% 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",
data: { turbo_confirm: "Are you sure you want to generate a new link? The previous code will stop working." } do %>
<%= icon_tag "refresh" %>
<span class="for-screen-reader">Generate a new code</span>
<% end %>
</div>
<div class="center flex align-center gap">
<%= tag.button class: "btn btn--link", data: {
controller: "copy-to-clipboard", action: "copy-to-clipboard#copy",
copy_to_clipboard_success_class: "btn--success", copy_to_clipboard_content_value: url } do %>
<%= icon_tag "copy-paste" %>
<span class="txt-nowrap">Copy invite link</span>
<% end %>
<div data-controller="dialog" data-dialog-modal-value="true" class="flex-inline">
<%= tag.button class: "btn", data: { action: "dialog#open" } do %>
<%= icon_tag "qr-code" %>
<span>Get QR code</span>
<% end %>
<dialog class="dialog panel shadow" data-dialog-target="dialog">
<p class="margin-none-block-start"><strong>Scan this code with the camera on your mobile device</strong></p>
<%= qr_code_image(url) %>
<form method="dialog" class="margin-block-start flex justify-center">
<button class="btn">
<span>Done</span>
</button>
</form>
</dialog>
</div>
</div>
<footer class="txt-small">
<hr class="separator--horizontal full-width margin-block" style="--border-color: var(--color-ink-lighter)">
<p class="margin-none <%= "txt-negative" if !@join_code.active? %>">
This code has been used <%= @join_code.usage_count %>/<%= @join_code.usage_limit %> times
(<%= @join_code.active? ? @join_code.usage_limit - @join_code.usage_count : "none" %> remaining)
<%= link_to edit_account_join_code_path, class: @join_code.active? ? "txt-link" : "txt-negative txt-underline" do %>
<span>Change limit</span>
<% end %>
</p>
</footer>
</div>
@@ -1,40 +0,0 @@
<div class="flex flex-column align-center fill-shade pad border-radius gap">
<% url = join_url(Account.sole.join_code) %>
<label class="flex flex-column gap full-width txt-align-center">
<strong id="invite_label" class="invite-label">Share to invite more people</strong>
<span class="flex align-center gap margin-inline">
<input type="text" class="input fill-white" value="<%= url %>" aria-labelledby="invite_label" readonly>
</span>
</label>
<div class="flex align-center gap">
<div data-controller="dialog" data-dialog-modal-value="true" class="flex-inline">
<%= tag.button class: "btn", data: { action: "dialog#open" } do %>
<%= icon_tag "qr-code" %>
<span class="for-screen-reader">Show join link QR code</span>
<% end %>
<dialog class="dialog panel shadow" data-dialog-target="dialog">
<%= image_tag account_join_code_path, class: "qr-code center", alt: "QR Code", loading: "lazy" %>
<form method="dialog" class="margin-block-start flex justify-center">
<button class="btn">
<%= icon_tag "remove" %>
<span class="for-screen-reader">Close</span>
</button>
</form>
</dialog>
</div>
<%= button_to_copy_to_clipboard(url) do %>
<%= icon_tag "copy-paste" %>
<span class="for-screen-reader">Copy join link</span>
<% end %>
<%= button_to account_join_code_path, method: :put, class: "btn btn--regenerate" do %>
<%= icon_tag "refresh" %>
<span class="for-screen-reader">Regenerate join link</span>
<% end %>
</div>
</div>
@@ -0,0 +1,8 @@
<h2 class="divider txt-large">Account</h2>
<%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %>
<%= form.text_field :name, required: true, class: "input input--transparent full-width", placeholder: "Account name",data: { action: "input->form#disableSubmitWhenInvalid" } %>
<%= form.button class: "btn btn--circle btn--link", data: { form_target: "submit" }, disabled: form.object do %>
<%= icon_tag "arrow-right" %>
<span class="for-screen-reader">Save changes</span>
<% end %>
<% end %>
+11 -7
View File
@@ -1,11 +1,15 @@
<div class="settings__panel settings__panel--users panel shadow center"
data-controller="filter navigable-list"
data-action="keydown->navigable-list#navigate filter:changed->navigable-list#reset"
navigable-list-focus-on-selection-value="true"
navigable-list-actionable-items-value="true">
<%= tag.div class: "margin-block-start flex flex-column gap-half", data: {
controller: "filter navigable-list",
action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset",
navigable_list_focus_on_selection_value: true,
navigable_list_actionable_items_value: true
} do %>
<h2 class="divider txt-large">People on the account</h2>
<%= render "account/settings/invite" %>
<%= link_to account_join_code_path, class: "btn btn--link center txt-small" do %>
<%= icon_tag "add" %>
<span>Add people</span>
<% end %>
<div class="settings__user-filter">
<input placeholder="Filter…" class="input input--transparent full-width txt-small" type="search" autocorrect="off" autocomplete="off" data-1p-ignore="true" data-filter-target="input" data-action="input->filter#filter">
@@ -14,4 +18,4 @@
<%= render partial: "account/settings/user", collection: users %>
</ul>
</div>
</div>
<% end %>
+7 -1
View File
@@ -5,6 +5,12 @@
<% end %>
<section class="settings">
<%= render "account/settings/users", users: @users %>
<div class="settings__panel settings__panel--users panel shadow center">
<% if Current.user.can_administer? %>
<%= render "account/settings/name", account: @account %>
<% end%>
<%= render "account/settings/users", users: @users %>
</div>
<%= render "account/settings/entropy_configuration", account: @account %>
</section>
+27
View File
@@ -0,0 +1,27 @@
<% @page_title = "Join #{@account_name}" %>
<div class="panel panel--centered flex flex-column gap-half">
<header>
<h1 class="txt-x-large font-weight-black margin-none">
<%= @page_title %>
</h1>
<p class="margin-none-block-start">Enter your email address to continue</p>
</header>
<%= form_with url: join_path(code: params[:code], tenant: params[:tenant]), class: "flex flex-column gap txt-medium", data: { controller: "form" } 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" data-form-target="submit">
<span>Join</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+15 -3
View File
@@ -65,7 +65,7 @@
border-radius: 3rem;
color: #ffffff;
font-weight: 500;
padding: 0.2em 0.4em;
padding: 0.5em 1em;
text-decoration: none;
white-space: nowrap;
}
@@ -81,7 +81,7 @@
margin-bottom: 0;
margin-top: 0;
}
.footer {
border-top: 1px solid #ccc;
margin-top: 3em;
@@ -107,10 +107,18 @@
text-transform: uppercase;
}
.margin-block-end-double {
margin-bottom: 2em;
}
.margin-block-start-double {
margin-top: 2em;
}
.subtitle {
font-size: 1.2em;
font-weight: normal;
margin-bottom: 2em;
margin-bottom: 1em;
margin-top: 0.1em;
}
@@ -119,6 +127,10 @@
font-weight: 900;
margin-bottom: 0;
}
.txt-large {
font-size: 1.2em;
}
</style>
</head>
+7 -6
View File
@@ -5,7 +5,12 @@
<body class="public" data-controller="local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
<header class="header" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= yield :header %>
<nav>
<%= link_to "https://box-car.com", class: "boxcar-header-logo center flex-inline align-center txt-normal" do %>
<%= image_tag "logo.png" %>
<strong class="txt-medium overflow-ellipsis margin-none">BOXCAR</strong>
<% end %>
</nav>
</header>
<main id="main">
@@ -13,10 +18,6 @@
</main>
<footer id="footer">
<div class="justify-center center margin-block-double flex align-end gap">
<%= image_tag "logo.png", size: 28 %>
<strong><a href="https://box-car.com" class="txt-ink">Made with Boxcar&trade;</a></strong>
</div>
</footer>
<%= yield :footer %>
</body>
</html>
@@ -0,0 +1,9 @@
<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", email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token), class: "btn" %>
<p class="margin-block-start-double">If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p>
<p class="footer">Need help? <%= mail_to "support@37signals.com", "Email us"%>.
</p>
@@ -0,0 +1,8 @@
Confirm your email address change
<%= "=" * 80 %>
Hit the link below to use this email address in Fizzy:
<%= email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token) %>
If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.
@@ -0,0 +1,12 @@
<h1 class="title">Sign in to Fizzy</h1>
<p class="subtitle">Hit the button below to sign in to Fizzy on this device</p>
<%= link_to "Sign in to Fizzy", session_magic_link_url(code: @magic_link.code), class: "btn" %>
<p class="margin-block-start-double">If you're signing in on another device, enter this special code:
<br><strong class="txt-large"><%= @magic_link.code %></strong>
</p>
<p class="footer">Need help? <%= mail_to "support@37signals.com", "Email us"%>.
</p>
@@ -0,0 +1,8 @@
Sign in to Fizzy
<%= "=" * 80 %>
Open this link in your browser to login on this device:
<%= session_magic_link_url(code: @magic_link.code) %>
If you're signing in on another device, enter this code:
<%= @magic_link.code %>
@@ -1,7 +1,7 @@
<tr>
<td>
<h1 class="title">Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %></h1>
<p class="subtitle">You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %>.</p>
<p class="subtitle margin-block-end-double">You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %>.</p>
<%= render partial: "notification/bundle_mailer/notification", collection: @notifications, as: :notification %>
<p class="footer">BOXCAR emails you about new notifications every few hours. <br>
<%= link_to "Change how often you get these", notifications_settings_url %>
@@ -0,0 +1,18 @@
<% @page_title = "Confirm email change" %>
<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">
<%= @page_title %>
</h1>
<p class="margin-none">Just a sec while we confirm your new email address.</p>
</header>
<%= form_with url: email_address_confirmation_path(membership_id: @membership.id), method: :post, data: { controller: "form auto-submit" } do |form| %>
<%= form.hidden_field :email_address_token, value: params[:email_address_token] %>
<button type="submit" class="btn btn--link center" data-form-target="submit">
<span>Done</span>
</button>
<% end %>
</div>
@@ -0,0 +1,17 @@
<% @page_title = "Confirm your new email address" %>
<% content_for :header do %>
<div class="header__title">
<%= link_to edit_user_path(@membership.user, script_name: "/#{@tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">&larr; <strong>My profile</strong></span>
<% end %>
</div>
<% end %>
<div class="panel shadow center flex flex-column gap-half" style="--panel-size: 45ch;">
<h1 class="txt-x-large font-weight-black margin-none">Check your email</h1>
<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(@membership.user, script_name: "/#{@tenant}"), class: "btn btn--link center" %>
</div>
@@ -0,0 +1,31 @@
<% @page_title = "Change your email" %>
<% content_for :header do %>
<div class="header__title">
<%= link_to edit_user_path(@membership.user, script_name: "/#{@membership.tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">&larr; <strong>My profile</strong></span>
<% end %>
</div>
<% end %>
<div class="panel shadow center flex flex-column gap" style="--panel-size: 45ch;">
<header>
<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>
</header>
<%= form_with url: email_addresses_path(membership_id: @membership.id), method: :post, class: "flex flex-column gap", data: { controller: "form", turbo: false } do |form| %>
<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 %>
</label>
</div>
<button type="submit" class="btn btn--reversed center" data-form-target="submit">
<span>Next</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
@@ -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: "form auto-submit" } do |form| %>
<button type="submit" id="log_in" class="btn btn--link center" data-form-target="submit">
<span>Leave</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+2 -4
View File
@@ -1,5 +1,3 @@
<%= collapsible_nav_section "Accounts" do %>
<% Current.user.identity.memberships.each do |membership| %>
<%= filter_place_menu_item root_url(script_name: "/#{membership.user_tenant}"), "#{membership.account_name}", "marker", new_window: true, current: membership.user_tenant == Current.user.tenant %>
<% 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 %>
+6
View File
@@ -1,3 +1,9 @@
<%= collapsible_nav_section "People" do %>
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item">
<%= icon_tag "add", class: "popup__icon" %>
<%= link_to account_join_code_path, class: "popup__btn btn" do %>
<span class="overflow-ellipsis">Add people</span>
<% end %>
</li>
<%= render partial: "my/menus/users/user", collection: user_filtering.users, as: :user %>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<div class="txt-align-center center margin-block-double txt-subtle txt-small">
BOXCAR beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
</div>
-20
View File
@@ -1,20 +0,0 @@
<% @page_title = "Choose Account" %>
<div class="panel shadow center margin-block-double" style="--panel-size: 55ch;">
<h1 class="txt-xx-large margin-block-end-double">BOXCAR</h1>
<% identity = Identity.find_signed(Current.identity_token.id) %>
<% memberships = identity&.memberships %>
<% if memberships.present? %>
<h2 class="txt-large margin-block-end">Your BOXCAR Accounts</h2>
<ul class="flex flex-column gap txt-large" style="list-style-type: none; padding: 0;">
<% memberships.each do |membership| %>
<li>
<%= link_to membership.account_name, root_url(script_name: "/#{membership.user_tenant}"), class: "btn btn--reversed center" %>
</li>
<% end %>
</ul>
<% else %>
<p class="txt-large txt-align-center txt-subtle">You don't have any existing BOXCAR accounts.</p>
<% end %>
</div>
@@ -0,0 +1,21 @@
<% @page_title = "Check your email" %>
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
<header>
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<p class="margin-none-block-start txt-medium">We just emailed you a link to sign in.</p>
</header>
<p class="margin-none-block-start margin-block-end-half txt-small txt-subtle">If your email is on a different device, enter the code included in the email below.</p>
<%= form_with url: session_magic_link_path, method: :post, html: { data: { controller: token_list("form", "auto-submit" => params[:code].present?)} } do |form| %>
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-normal",
autofocus: false, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true,
autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code],
data: { form_target: "input", action: "paste->form#submit, keydown.enter->form#submit" } %>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% 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 %>
+39
View File
@@ -0,0 +1,39 @@
<% @page_title = "Choose an account" %>
<% 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 @memberships.present? %>
<h1 class="txt-x-large font-weight-black margin-none">
Your BOXCAR accounts
</h1>
<menu class="popup__list pad border-radius border">
<% @memberships.each do |membership| %>
<li class="popup__item txt-medium">
<%= icon_tag "marker", class: "popup__icon" %>
<%= 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 %>
</menu>
<% else %>
<h1 class="txt-x-large font-weight-black margin-none">Hmm...</h1>
<p class="margin-none-block-start">You dont have any BOXCAR accounts.</p>
<% end %>
<% if defined?(saas) %>
<div class="margin-block-start">
<%= 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>
<% end %>
</div>
<% end %>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+10 -14
View File
@@ -1,26 +1,22 @@
<% @page_title = "Sign in" %>
<div class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>" style="--panel-size: 55ch;">
<h1 class="txt-xx-large margin-block-end-double">BOXCAR</h1>
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<%= form_with url: session_path, class: "flex flex-column gap txt-large" do |form| %>
<%= form_with url: session_path, 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: "Enter your email address" %>
<%= icon_tag "email", class: "txt-large" %>
<%= form.email_field :email_address, required: true, class: "input full-width", autofocus: true, autocomplete: "username", placeholder: "Email address" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.password_field :password, required: true, class: "input full-width", autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center">
<button type="submit" id="log_in" class="btn btn--link center">
<span>Continue</span>
<%= icon_tag "arrow-right" %>
<span class="for-screen-reader">Sign in</span>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+19
View File
@@ -0,0 +1,19 @@
<% @hide_footer_frames = true %>
<% @page_title = "Signing in..." %>
<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">Just a sec while we sign you in with <%= Account.sole.name %>.</p>
</header>
<%= form_with url: session_start_path, method: :post, data: { controller: "form auto-submit" } do |form| %>
<%= form.button "Sign in", type: "submit", class: "btn btn-primary", data: { form_target: "submit", turbo_submits_with: "Signing in..." } %>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+1 -1
View File
@@ -1,5 +1,5 @@
<div class="flex flex-column align-center gap txt-medium--responsive txt-medium">
<% url = session_transfer_url(user.transfer_id) %>
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
<label class="flex flex-column gap full-width">
<div class="flex align-center gap justify-center">
+10 -14
View File
@@ -1,6 +1,10 @@
<% @page_title = "Edit your profile" %>
<div class="panel shadow center">
<% content_for :header do %>
<%= render "my/menus/menu" %>
<% end %>
<div class="panel shadow center" style="--panel-size: 45ch;">
<div class="flex flex-column gap txt-medium">
<%= form_with model: @user, method: :patch, class: "flex flex-column gap", data: { controller: "form upload-preview" } do |form| %>
<div class="align-center center avatar__form gap">
@@ -24,23 +28,15 @@
<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, action: "keydown.esc@document->form#cancel" } %>
<%= icon_tag "person", class: "txt-large" %>
</label>
</div>
<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: "username", placeholder: "Email address", required: true %>
<%= icon_tag "email", class: "txt-large" %>
</label>
<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, value: @user.identity.email_address %>
<%= link_to "Change email", new_email_address_url(script_name: nil, membership_id: Current.membership.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
</div>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.password_field :password, class: "input full-width", autocomplete: "new-password", placeholder: "Password", required: false, maxlength: 72 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center">
<%= icon_tag "check" %>
<button type="submit" id="log_in" class="btn btn--reversed center" data-form-target="submit">
<span>Save changes</span>
</button>
+16 -27
View File
@@ -1,39 +1,28 @@
<% @page_title = "Create your account" %>
<% @page_title = "Join #{Account.sole.name}" %>
<% content_for :header do %>
<div class="header__actions header__actions--start"></div>
<div class="header__actions header__actions--end">
<%= link_to login_url, class: "btn", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="for-screen-reader">Sign in instead</span>
<% end %>
</div>
<% end %>
<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 name to continue</p>
</header>
<div class="panel shadow center">
<h1 class="margin-none-block-start margin-block-end-double">BOXCAR</h1>
<%= form_with model: @user, url: join_path(params[:join_code]), class: "flex flex-column gap" do |form| %>
<%= form_with scope: "user", url: users_path(membership: params[:membership]), class: "flex flex-column gap txt-medium", data: { controller: "form" } 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 } %>
<%= icon_tag "person", class: "txt-large" %>
</label>
</div>
<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: "username", placeholder: "Email address", required: true %>
<%= icon_tag "email", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.password_field :password, class: "input full-width", autocomplete: "new-password", placeholder: "Password", required: true, maxlength: 72 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center">
<button type="submit" id="log_in" class="btn btn--link center" data-form-target="submit">
<span>Join</span>
<%= icon_tag "arrow-right" %>
<span>Create account</span>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+11 -8
View File
@@ -1,6 +1,10 @@
<% @page_title = @user.name %>
<div class="panel shadow center txt-align-center margin-block-end-double">
<% content_for :header do %>
<%= render "my/menus/menu" %>
<% end %>
<div class="panel shadow center txt-align-center margin-block-end-double" style="--panel-size: 45ch;">
<div class="flex flex-column gap position-relative">
<% if Current.user == @user %>
<%= link_to edit_user_path(@user), class: "user-edit-link btn" do %>
@@ -9,8 +13,8 @@
<% end %>
<% end %>
<div class="avatar txt-xx-large center fill-white">
<%= image_tag user_avatar_path(@user), alt: "Profile avatar for #{@user.name}", size: 128 %>
<div class="avatar txt-xx-large center fill-white btn--circle hide-focus-ring">
<%= image_tag user_avatar_path(@user), alt: "Profile avatar for #{@user.name}" %>
</div>
<div class="flex flex-column gap-half margin-block-end">
@@ -32,15 +36,14 @@
</div>
<% if Current.user == @user %>
<div class="panel shadow center margin-block-double">
<div class="panel shadow center margin-block-double" style="--panel-size: 45ch;">
<%= render "users/transfer", user: @user %>
</div>
<div class="panel borderless center margin-block-double">
<%= button_to session_path, method: :delete, class: "btn center txt-medium", data: { turbo: false } do %>
<%= icon_tag "logout" %>
<div class="center margin-block-start-double">
<%= 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>
</div>
<% end %>
+1 -1
View File
@@ -45,7 +45,7 @@ Rails.application.configure do
config.action_mailer.delivery_method = :test
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "%{tenant}.example.com" }
config.action_mailer.default_url_options = { host: "example.com" }
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
+15 -5
View File
@@ -15,10 +15,6 @@ module AccountSlug
request.engine_script_name = request.script_name = $1
request.path_info = $'.empty? ? "/" : $'
# Limit session cookies to the slug path.
# TODO TEST ME
request.env["rack.session.options"][:path] = $1
# Return the account id for tenanting.
AccountSlug.decode($2)
end
@@ -26,10 +22,24 @@ module AccountSlug
def self.decode(slug) slug.to_i end
def self.encode(id) FORMAT % id end
def self.creation_request?(request)
if defined?(Fizzy::Saas) && request.post?
path = Fizzy::Saas::Engine.routes.url_helpers.signup_completion_path
request.path_info =~ /\A\/#{PATTERN}#{Regexp.escape(path)}\Z/
else
false
end
end
end
Rails.application.config.after_initialize do
Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do
AccountSlug.extract(request)
if AccountSlug.creation_request?(request)
AccountSlug.extract(request)
nil
else
AccountSlug.extract(request)
end
end
end
+3
View File
@@ -25,6 +25,9 @@ production: &production
cleanup_webhook_deliveries:
class: Webhook::CleanupDeliveriesJob
schedule: every 4 hours at minute 51
cleanup_magic_links:
command: "MagicLink.cleanup"
schedule: every 4 hours
sqlite_backups:
class: SQLiteBackupsJob
schedule: every day at 05:02
+15 -3
View File
@@ -1,6 +1,7 @@
Rails.application.routes.draw do
namespace :account do
resource :join_code
post :enter, to: "entries#create"
resource :join_code, only: %i[ show edit update destroy ]
resource :settings
resource :entropy_configuration
end
@@ -123,12 +124,15 @@ 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 :menu, only: %i[ show create ]
end
end
@@ -146,6 +150,14 @@ 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
resources :email_addresses, only: %i[ new create ], param: :token do
resource :confirmation, only: %i[ show create ], module: :email_addresses
end
end
namespace :my do
resources :pins
resource :timezone
@@ -0,0 +1,31 @@
class CreateAccountJoinCodes < ActiveRecord::Migration[8.1]
def change
create_table :account_join_codes do |t|
t.string :code, null: false, index: { unique: true }
t.integer :usage_count, default: 0, null: false
t.integer :usage_limit, default: 10, null: false
t.timestamps
end
reversible do |dir|
dir.up do
execute <<-SQL
INSERT INTO account_join_codes (code, usage_count, usage_limit, created_at, updated_at)
SELECT join_code, 0, 10, datetime('now'), datetime('now')
FROM accounts
WHERE join_code IS NOT NULL;
SQL
end
dir.down do
execute <<-SQL
UPDATE accounts
SET join_code = (SELECT code FROM account_join_codes LIMIT 1);
SQL
end
end
remove_column :accounts, :join_code, :string
end
end
@@ -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
+11 -11
View File
@@ -24,10 +24,18 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_161222) do
t.index ["user_id"], name: "index_accesses_on_user_id"
end
create_table "account_join_codes", force: :cascade do |t|
t.string "code", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "usage_count", default: 0, null: false
t.integer "usage_limit", default: 10, null: false
t.index ["code"], name: "index_account_join_codes_on_code", unique: true
end
create_table "accounts", force: :cascade do |t|
t.datetime "created_at", null: false
t.integer "external_account_id"
t.string "join_code"
t.string "name", null: false
t.datetime "updated_at", null: false
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
@@ -342,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
@@ -391,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
@@ -465,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"
+234 -200
View File
@@ -40,7 +40,7 @@ columns:
default_function:
collation:
comment:
- &5 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &7 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: created_at
cast_type: *1
@@ -50,7 +50,7 @@ columns:
default_function:
collation:
comment:
- &6 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &8 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment: true
name: id
cast_type: *3
@@ -63,13 +63,13 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: involvement
cast_type: &7 !ruby/object:ActiveModel::Type::String
cast_type: &5 !ruby/object:ActiveModel::Type::String
true: t
false: f
precision:
scale:
limit:
sql_type_metadata: &8 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &6 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: varchar
type: :string
limit:
@@ -100,8 +100,42 @@ columns:
default_function:
collation:
comment:
account_join_codes:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: code
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- *7
- *8
- *9
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: usage_count
cast_type: *3
sql_type_metadata: *4
'null': false
default: 0
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: usage_limit
cast_type: *3
sql_type_metadata: *4
'null': false
default: 10
default_function:
collation:
comment:
accounts:
- *5
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: external_account_id
@@ -112,22 +146,12 @@ columns:
default_function:
collation:
comment:
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: join_code
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
collation:
comment:
- *8
- &10 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: name
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
@@ -155,8 +179,8 @@ columns:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- *10
- &13 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -181,8 +205,8 @@ columns:
- &14 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: record_type
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
@@ -200,16 +224,16 @@ columns:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- *10
- *13
- *14
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: slug
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -229,8 +253,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: checksum
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -239,30 +263,30 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: content_type
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
collation:
comment:
- *5
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: filename
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- *6
- *8
- &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: key
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
@@ -281,8 +305,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: service_name
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
@@ -290,26 +314,26 @@ columns:
comment:
active_storage_variant_records:
- *17
- *6
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: variation_digest
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
ar_internal_metadata:
- *5
- *7
- *18
- *9
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: value
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -361,13 +385,13 @@ columns:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- *9
card_activity_spikes:
- *22
- *5
- *6
- *7
- *8
- *9
card_engagements:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -380,13 +404,13 @@ columns:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: status
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default: doing
default_function:
@@ -395,13 +419,13 @@ columns:
- *9
card_goldnesses:
- *22
- *5
- *6
- *7
- *8
- *9
card_not_nows:
- *22
- *5
- *6
- *7
- *8
- *9
- &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -425,7 +449,7 @@ columns:
default_function:
collation:
comment:
- *5
- *7
- &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: creator_id
@@ -455,7 +479,7 @@ columns:
default_function:
collation:
comment:
- *6
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: last_active_at
@@ -476,11 +500,11 @@ columns:
default_function:
collation:
comment:
- &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: title
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -501,19 +525,19 @@ columns:
- *19
closures:
- *22
- *5
- *6
- *7
- *8
- *9
- *24
collection_publications:
- *23
- *5
- *6
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: key
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -524,11 +548,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
cast_type: &32 !ruby/object:ActiveModel::Type::Boolean
cast_type: &31 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
sql_type_metadata: &33 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -539,9 +563,9 @@ columns:
default_function:
collation:
comment:
- *5
- *7
- *25
- *6
- *8
- *10
- *9
collections_filters:
@@ -552,15 +576,15 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: color
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- *10
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -575,9 +599,9 @@ columns:
- *9
comments:
- *22
- *5
- *7
- *25
- *6
- *8
- *9
creators_filters:
- *25
@@ -606,29 +630,29 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: container_type
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- *9
events:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: action
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- *23
- *5
- *7
- *25
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -643,14 +667,14 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: eventable_type
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- *6
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: particulars
@@ -671,7 +695,7 @@ columns:
comment:
- *9
filters:
- *5
- *7
- *25
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -683,12 +707,12 @@ columns:
default_function:
collation:
comment:
- *6
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: params_digest
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
@@ -697,7 +721,7 @@ columns:
- *9
filters_tags:
- *19
- &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *3
@@ -708,8 +732,8 @@ columns:
collation:
comment:
mentions:
- *5
- *6
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: mentionee_id
@@ -743,8 +767,8 @@ columns:
- &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: source_type
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
@@ -752,7 +776,7 @@ columns:
comment:
- *9
notification_bundles:
- *5
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: ends_at
@@ -763,7 +787,7 @@ columns:
default_function:
collation:
comment:
- *6
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: starts_at
@@ -787,7 +811,7 @@ columns:
- *9
- *28
notifications:
- *5
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: creator_id
@@ -798,7 +822,7 @@ columns:
default_function:
collation:
comment:
- *6
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: read_at
@@ -815,49 +839,49 @@ columns:
- *28
pins:
- *22
- *5
- *6
- *7
- *8
- *9
- *28
push_subscriptions:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: auth_key
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
collation:
comment:
- *5
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: endpoint
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
collation:
comment:
- *6
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: p256dh_key
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
collation:
comment:
- *9
- &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_agent
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -895,8 +919,8 @@ columns:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: reacter_id
@@ -912,16 +936,16 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: version
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
search_queries:
- *5
- *6
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: terms
@@ -945,32 +969,16 @@ columns:
- *9
- *28
search_results:
- *5
- *6
- *7
- *8
- *9
sessions:
- *5
- *6
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: ip_address
cast_type: *7
sql_type_metadata: *8
'null': true
default:
default_function:
collation:
comment:
- *9
- *31
- *28
steps:
- *22
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: completed
cast_type: *32
sql_type_metadata: *33
cast_type: *31
sql_type_metadata: *32
'null': false
default: false
default_function:
@@ -986,19 +994,19 @@ columns:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- *9
taggings:
- *22
- *5
- *6
- *34
- *7
- *8
- *33
- *9
tags:
- *5
- *6
- *35
- *7
- *8
- *34
- *9
user_settings:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -1011,13 +1019,13 @@ columns:
default_function:
collation:
comment:
- *5
- *6
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: timezone_name
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -1026,34 +1034,44 @@ columns:
- *9
- *28
users:
- &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &36 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
cast_type: *32
sql_type_metadata: *33
cast_type: *31
sql_type_metadata: *32
'null': false
default: true
default_function:
collation:
comment:
- *5
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: email_address
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
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:
- *6
- *10
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: password_digest
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -1062,8 +1080,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: role
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default: member
default_function:
@@ -1072,15 +1090,15 @@ columns:
- *9
watches:
- *22
- *5
- *6
- *7
- *8
- *9
- *28
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
cast_type: *32
sql_type_metadata: *33
cast_type: *31
sql_type_metadata: *32
'null': false
default: true
default_function:
@@ -1097,7 +1115,7 @@ columns:
default_function:
collation:
comment:
- *5
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: first_failure_at
@@ -1108,9 +1126,9 @@ columns:
default_function:
collation:
comment:
- *6
- *8
- *9
- &36 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: webhook_id
cast_type: *3
@@ -1121,7 +1139,7 @@ columns:
collation:
comment:
webhook_deliveries:
- *5
- *7
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: event_id
@@ -1132,7 +1150,7 @@ columns:
default_function:
collation:
comment:
- *6
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: request
@@ -1156,25 +1174,25 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: state
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
collation:
comment:
- *9
- *36
- *35
webhooks:
- *37
- *36
- *23
- *5
- *6
- *7
- *8
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: name
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': true
default:
default_function:
@@ -1183,8 +1201,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: signing_secret
cast_type: *7
sql_type_metadata: *8
cast_type: *5
sql_type_metadata: *6
'null': false
default:
default_function:
@@ -1213,6 +1231,7 @@ columns:
comment:
primary_keys:
accesses: id
account_join_codes: id
accounts: id
action_text_rich_texts: id
active_storage_attachments: id
@@ -1248,7 +1267,6 @@ primary_keys:
schema_migrations: version
search_queries: id
search_results: id
sessions: id
steps: id
taggings: id
tags: id
@@ -1260,6 +1278,7 @@ primary_keys:
webhooks: id
data_sources:
accesses: true
account_join_codes: true
accounts: true
action_text_rich_texts: true
active_storage_attachments: true
@@ -1295,7 +1314,6 @@ data_sources:
schema_migrations: true
search_queries: true
search_results: true
sessions: true
steps: true
taggings: true
tags: true
@@ -1372,6 +1390,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
account_join_codes:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: account_join_codes
name: index_account_join_codes_on_code
unique: true
columns:
- code
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
accounts:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: accounts
@@ -2560,23 +2595,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
@@ -2713,6 +2731,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
+12 -7
View File
@@ -1,5 +1,4 @@
raise "Seeding is just for development" unless Rails.env.development?
require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers
@@ -12,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
@@ -22,8 +24,7 @@ def create_tenant(signal_account_name)
},
owner: {
name: "David Heinemeier Hansson",
email_address: "david@37signals.com",
password: "secret123456"
membership: membership
}
)
account.setup_basic_template
@@ -36,15 +37,19 @@ def find_or_create_user(full_name, email_address)
if user = User.find_by(email_address: email_address)
user
else
User.create! \
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,
password: "secret123456"
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: [])
@@ -0,0 +1,11 @@
class CreateIdentityMagicLinks < ActiveRecord::Migration[8.1]
def change
create_table :magic_links do |t|
t.string :code, index: { unique: true }, null: false
t.belongs_to :membership, null: false, foreign_key: true
t.datetime :expires_at, index: true, null: false
t.timestamps
end
end
end
@@ -0,0 +1,48 @@
class MoveEmailToIdentity < ActiveRecord::Migration[8.1]
def change
add_column :identities, :email_address, :string
# Create identities for each unique email
execute <<-SQL
INSERT INTO identities (email_address, created_at, updated_at)
SELECT DISTINCT email_address, datetime('now'), datetime('now')
FROM memberships
WHERE email_address IS NOT NULL
AND email_address NOT IN (SELECT email_address FROM identities WHERE email_address IS NOT NULL);
SQL
# And link memberships to them
execute <<-SQL
UPDATE memberships
SET identity_id = (
SELECT id
FROM identities
WHERE identities.email_address = memberships.email_address
)
WHERE email_address IS NOT NULL;
SQL
# Delete memberships associated with identities without an email address
execute <<-SQL
DELETE FROM memberships
WHERE identity_id IN (
SELECT id
FROM identities
WHERE email_address IS NULL
);
SQL
# Delete identities without an email address
execute <<-SQL
DELETE FROM identities
WHERE email_address IS NULL;
SQL
change_column_null :memberships, :identity_id, false
add_index :identities, :email_address, unique: true
remove_column :memberships, :email_address
remove_column :memberships, :user_id, :bigint
remove_column :memberships, :account_name, :string
rename_column :memberships, :user_tenant, :tenant
end
end
@@ -0,0 +1,18 @@
class AssociateMagicLinksToIdentities < ActiveRecord::Migration[8.1]
def change
add_reference :magic_links, :identity, foreign_key: true
# Re-associate magic links from memberships to their identity
execute <<-SQL
UPDATE magic_links
SET identity_id = (
SELECT identity_id
FROM memberships
WHERE memberships.id = magic_links.membership_id
)
WHERE membership_id IS NOT NULL;
SQL
remove_column :magic_links, :membership_id
end
end
@@ -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 AddJoinCodeToMemberships < ActiveRecord::Migration[8.2]
def change
add_column :memberships, :join_code, :string
end
end
+28 -7
View File
@@ -10,24 +10,45 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_09_24_190729) 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"
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_identities_on_email_address", unique: true
end
create_table "magic_links", force: :cascade do |t|
t.string "code", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.integer "identity_id"
t.datetime "updated_at", null: false
t.index ["code"], name: "index_magic_links_on_code", unique: true
t.index ["expires_at"], name: "index_magic_links_on_expires_at"
t.index ["identity_id"], name: "index_magic_links_on_identity_id"
end
create_table "memberships", force: :cascade do |t|
t.string "account_name", null: false
t.datetime "created_at", null: false
t.string "email_address", null: false
t.integer "identity_id", null: false
t.string "join_code"
t.string "tenant", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.string "user_tenant", null: false
t.index ["email_address"], name: "index_memberships_on_email_address"
t.index ["identity_id"], name: "index_memberships_on_identity_id"
t.index ["user_tenant", "user_id"], name: "index_memberships_on_user_tenant_and_user_id"
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
@@ -0,0 +1,11 @@
module Restricted
extend ActiveSupport::Concern
included do
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
@@ -0,0 +1,26 @@
class Signups::CompletionsController < ApplicationController
include Restricted
require_untenanted_access
layout "public"
def new
@signup = Signup.new(signup_params)
end
def create
@signup = Signup.new(signup_params)
if @signup.complete
redirect_to root_url(script_name: "/#{@signup.tenant}")
else
render :new, status: :unprocessable_entity
end
end
private
def signup_params
params.expect(signup: %i[ full_name account_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
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,
account_name: @signup.account_name
}
)
else
render :new, status: :unprocessable_entity
end
end
private
def signup_params
params.expect(signup: %i[ full_name ]).with_defaults(identity: Current.identity)
end
end
@@ -1,15 +1,16 @@
class SignupsController < ApplicationController
include Restricted
require_untenanted_access
require_unauthenticated_access
layout "public"
rate_limit only: :create, name: "short-term", to: 5, within: 3.minutes,
with: -> { redirect_to saas.new_signup_path, alert: "Try again later." }
rate_limit only: :create, name: "long-term", to: 10, within: 30.minutes,
with: -> { redirect_to saas.new_signup_path, alert: "Try again later." }
http_basic_authenticate_with \
name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
def new
@signup = Signup.new
end
@@ -17,8 +18,9 @@ class SignupsController < ApplicationController
def create
@signup = Signup.new(signup_params)
if @signup.process
redirect_to root_url(script_name: @signup.account.slug)
if @signup.create_identity
session[:return_to_after_authenticating] = saas.new_signup_membership_path
redirect_to session_magic_link_path
else
render :new, status: :unprocessable_entity
end
@@ -26,6 +28,6 @@ class SignupsController < ApplicationController
private
def signup_params
params.require(:signup).permit(*Signup::PERMITTED_KEYS)
params.expect(signup: %i[ email_address ])
end
end

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