@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +1,3 @@
|
||||
class Session < ApplicationRecord
|
||||
belongs_to :user
|
||||
class Session < UntenantedRecord
|
||||
belongs_to :identity
|
||||
end
|
||||
|
||||
+12
-14
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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">← <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">← <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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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™</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 didn’t 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 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,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">← <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">← <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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
@@ -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 don’t 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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
<div class="flex flex-column align-center gap txt-medium--responsive txt-medium">
|
||||
<% url = session_transfer_url(user.transfer_id) %>
|
||||
<% url = session_transfer_url(user.identity.transfer_id, script_name: nil) %>
|
||||
|
||||
<label class="flex flex-column gap full-width">
|
||||
<div class="flex align-center gap justify-center">
|
||||
|
||||
@@ -1,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>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user