Clean up and simplify magic links

This commit is contained in:
Stanko K.R.
2025-10-15 08:44:00 +02:00
parent 575578245f
commit c8843360fe
45 changed files with 768 additions and 285 deletions
@@ -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
+11 -21
View File
@@ -60,8 +60,8 @@ module Authentication
end
def resume_identity
if identity = find_identity_by_cookie
set_current_identity(identity)
if identity_token = find_identity_by_cookie
set_current_identity(identity_token)
end
end
@@ -72,7 +72,7 @@ module Authentication
end
def find_identity_by_cookie
Identity.find_signed(cookies.signed[:identity_token]&.dig("id"))
IdentityProvider.verify_token(cookies.signed[:identity_token])
end
def find_session_by_cookie
@@ -91,6 +91,10 @@ module Authentication
session.delete(:return_to_after_authenticating) || root_url
end
def after_identification_url
session.delete(:return_to_after_identification) || session_login_menu_path
end
def redirect_authenticated_user
redirect_to root_url if authenticated?
end
@@ -100,29 +104,15 @@ module Authentication
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|
set_current_session session
end
end
def link_identity(user_or_membership)
token_value = cookies.signed[:identity_token]
identity = Identity.find_signed(token_value["id"]) if token_value.present?
if user_or_membership.is_a?(User)
identity = user_or_membership.set_identity(identity)
elsif user_or_membership.is_a?(Membership) && identity
user_or_membership.update!(identity: identity)
end
set_current_identity(identity)
end
def set_current_identity(identity)
Current.identity_token = if identity
cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax }
Identity::Mock.new(**cookies.signed[:identity_token])
def set_current_identity(identity_token)
Current.identity_token = if identity_token
cookies.signed.permanent[:identity_token] = { value: identity_token.to_h, httponly: true, same_site: :lax }
identity_token
else
nil
end
@@ -1,25 +1,22 @@
class Sessions::LoginMenusController < ApplicationController
require_untenanted_access only: :show
allow_unauthenticated_access only: :create
before_action :require_identity
before_action :set_identity
skip_before_action :verify_authenticity_token, only: :create
def show
@memberships = @identity.memberships
@tenants = IdentityProvider.tenants_for(resume_identity)
end
def create
membership = @identity.memberships.find(membership_id)
start_new_session_for(membership.user)
redirect_to after_authentication_url
email_address = IdentityProvider.resolve_token(resume_identity)
user = User.find_by(email_address: email_address)
if user
start_new_session_for(user)
redirect_to after_authentication_url
else
IdentityProvider.unlink(email_address: email_address, from: ApplicationRecord.current_tenant)
redirect_to session_login_menu_path, alert: "You can't access this account"
end
end
private
def set_identity
@identity = Current.identity_token.identity
end
def membership_id
params.expect :membership_id
end
end
@@ -6,24 +6,13 @@ class Sessions::MagicLinksController < ApplicationController
end
def create
membership = MagicLink.consume(code)
identity_token = IdentityProvider.consume_magic_link(code)
if membership.blank?
if identity_token.blank?
redirect_to session_magic_link_path, alert: "Try another code."
else
resume_identity
if Current.identity_token
link_identity(membership)
else
set_current_identity(membership.identity)
end
if membership.account.setup_pending?
redirect_to saas.new_signup_completion_url(script_name: "/#{membership.user_tenant}")
else
redirect_to session_login_menu_path
end
set_current_identity(identity_token)
redirect_to after_identification_url
end
end
+4 -2
View File
@@ -6,8 +6,10 @@ class SessionsController < ApplicationController
end
def create
if membership = Membership.find_by(email_address: email_address)
membership.send_magic_link
magic_link_code = IdentityProvider.send_magic_link(email_address)
if magic_link_code && Rails.env.development?
flash[:notice] = "Magic Link Code: #{magic_link_code}"
end
redirect_to session_magic_link_path
+12 -6
View File
@@ -10,13 +10,15 @@ class UsersController < ApplicationController
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
redirect_to root_path
if Account::JoinCode.redeem(params[:join_code])
User.invite(**invite_params)
redirect_to session_magic_link_path(script_name: nil)
else
head :forbidden
end
end
def edit
@@ -39,7 +41,7 @@ class UsersController < ApplicationController
private
def ensure_join_code_is_valid
head :forbidden unless Account.sole.join_code == params[:join_code]
head :forbidden unless Account::JoinCode.active?(params[:join_code])
end
def set_user
@@ -59,6 +61,10 @@ class UsersController < ApplicationController
end
def user_params
params.expect(user: [ :name, :email_address, :password, :avatar ])
params.expect(user: [ :name, :email_address, :avatar ])
end
def invite_params
params.expect(user: [ :name, :email_address ])
end
end
-5
View File
@@ -1,5 +0,0 @@
class MagicLink::CleanupJob < ApplicationJob
def perform
MagicLink.cleanup
end
end
+7 -2
View File
@@ -1,9 +1,9 @@
class Account < ApplicationRecord
include Entropic, Joinable
include Entropic
has_many_attached :uploads
enum :setup_status, %w[ pending complete ].index_by(&:itself), prefix: :setup, default: :pending
after_create :create_join_code
class << self
def create_with_admin_user(account:, owner:)
@@ -27,4 +27,9 @@ class Account < ApplicationRecord
Collection.create!(name: "Cards", creator: user, all_access: true)
end
private
def create_join_code
Account::JoinCode.create!
end
end
+41
View File
@@ -0,0 +1,41 @@
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 }
class << self
def redeem(code)
find_by(code: code)&.tap do |join_code|
if join_code.active?
join_code.increment!(:usage_count)
end
end
end
def active?(code)
active.exists?(code: code)
end
end
def active?
usage_count < usage_limit
end
def reset
update!(code: generate_code, usage_count: 0)
end
private
def generate_code
self.code = loop do
candidate = SecureRandom.base58(CODE_LENGTH).scan(/.{4}/).join("-")
break candidate unless self.class.exists?(code: candidate)
end
end
end
-16
View File
@@ -1,16 +0,0 @@
module Account::Joinable
extend ActiveSupport::Concern
included do
before_create { self.join_code = generate_join_code }
end
def reset_join_code
update! join_code: generate_join_code
end
private
def generate_join_code
SecureRandom.alphanumeric(12).scan(/.{4}/).join("-")
end
end
+27 -6
View File
@@ -1,11 +1,32 @@
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) do
def identity
Identity.find_signed(id)
has_many :memberships, dependent: :destroy
has_many :magic_links, dependent: :delete_all
normalizes :email_address, with: ->(value) { value.strip.downcase }
class << self
def link(email_address:, to:)
find_or_create_by!(email_address: email_address).tap { |identity| identity.link_to(to) }
end
def unlink(email_address:, from:)
find_by(email_address: email_address)&.unlink_from(from)
end
end
has_many :memberships, dependent: :destroy
def send_magic_link
magic_links.create!.tap do |magic_link|
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
def link_to(tenant)
memberships.find_or_create_by!(tenant: tenant) do |membership|
membership.account_name = ApplicationRecord.with_tenant(membership.tenant) { Account.sole.name }
end
end
def unlink_from(tenant)
memberships.find_by(tenant: tenant)&.destroy
end
end
+19
View File
@@ -0,0 +1,19 @@
module IdentityProvider
Tenant = Data.define(:id, :name)
extend self
def self.backend
if Bootstrap.oss_config?
raise NotImplementedError, "No identity provider configured in OSS version"
else
IdentityProvider::Saas
end
end
delegate :link, :unlink, :send_magic_link, :consume_magic_link, :tenants_for, :token_for, :resolve_token, :verify_token, to: :backend
def tenants_for(identity_token)
backend.tenants_for(identity_token)
end
end
+86
View File
@@ -0,0 +1,86 @@
module IdentityProvider::Saas
# 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)
class Error < StandardError; end
extend self
extend Fizzy::Saas::Engine.routes.url_helpers
def default_url_options
Rails.application.config.action_mailer.default_url_options
end
def url_options
default_url_options
end
def link(email_address:, to:)
response = InternalApiClient.new(link_identity_url(script_name: nil)).post({ email_address: email_address, to: to })
unless response.success?
raise Error, "Failed to link identity: #{response.error || response.code}"
end
end
def unlink(email_address:, from:)
response = InternalApiClient.new(unlink_identity_url(script_name: nil)).post({ email_address: email_address, from: from })
unless response.success?
raise Error, "Failed to unlink identity: #{response.error || response.code}"
end
end
def change_email_address(from:, to:, tenant:)
response = InternalApiClient.new(change_identity_email_address_url(script_name: nil)).post({ from: from, to: to, tenant: tenant })
unless response.success?
raise Error, "Failed to change email address: #{response.error || response.code}"
end
end
def send_magic_link(email_address)
response = InternalApiClient.new(send_magic_link_url(script_name: nil)).post({ email_address: email_address })
if response.success?
response.parsed_body["code"]
else
raise Error, "Failed to send magic link: #{response.error || response.code}"
end
end
def consume_magic_link(code)
identity = MagicLink.consume(code)
wrap_identity(identity)
end
def token_for(email_address)
identity = Identity.find_by(email_address: email_address)
wrap_identity(identity)
end
def resolve_token(token)
identity = Identity.find_signed(token&.dig("id"))
identity&.email_address
end
def verify_token(token)
identity = Identity.find_signed(token&.dig("id"))
wrap_identity(identity)
end
def tenants_for(token)
Identity.find_signed(token&.dig("id")).memberships.pluck(:tenant, :account_name).map do |id, name|
IdentityProvider::Tenant.new(id: id, name: name)
end
end
private
def wrap_identity(identity)
if identity
Mock.new(identity.signed_id, identity.updated_at)
else
nil
end
end
end
+4 -4
View File
@@ -2,7 +2,7 @@ class MagicLink < UntenantedRecord
CODE_LENGTH = 6
EXPIRATION_TIME = 15.minutes
belongs_to :membership
belongs_to :identity
scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }
@@ -24,18 +24,18 @@ class MagicLink < UntenantedRecord
def consume
destroy
membership
identity
end
private
def generate_code
self.code = loop do
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
self.expires_at ||= EXPIRATION_TIME.from_now
end
end
+14 -14
View File
@@ -1,23 +1,23 @@
class Membership < UntenantedRecord
belongs_to :identity, touch: true
has_many :magic_links, dependent: :delete_all
# 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 user
User.with_tenant(user_tenant) { User.find_by(id: user_id) }
User.with_tenant(tenant) { User.find_by(email_address: identity.email_address) }
end
def account
Account.with_tenant(user_tenant) { Account.sole }
end
def send_magic_link
magic_link = magic_links.create!
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
Account.with_tenant(tenant) { Account.sole }
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class User < ApplicationRecord
include Accessor, Assignee, Attachable, Configurable, Identifiable,
Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable,
Invitable, Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable,
Watcher
include Timelined # Depends on Accessor
+15
View File
@@ -0,0 +1,15 @@
module User::Invitable
extend ActiveSupport::Concern
class_methods do
def invite(**attributes)
create!(attributes).tap do |user|
IdentityProvider.link(email_address: user.email_address, to: ApplicationRecord.current_tenant)
IdentityProvider.send_magic_link(user.email_address)
rescue
user.destroy!
raise
end
end
end
end
@@ -0,0 +1,46 @@
<% @page_title = "Edit Join Code" %>
<% content_for :header do %>
<%= render "filters/menu" %>
<div class="header__actions header__actions--start">
<%= link_to account_join_code_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="overflow-ellipsis">&larr; <strong>Join Code</strong></span>
<% end %>
</div>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<article
class="panel center txt-align-start"
style="view-transition-name: <%= dom_id(@join_code) %>"
>
<%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %>
<div class="flex flex-column gap-half">
<%= form.label :usage_limit do %>
<strong>Usage limit</strong>
<br>
<p class="margin-none txt-x-small txt-subtle">
How many times can this code be used to join the account?
</p>
<% end %>
<%= form.number_field :usage_limit,
required: true,
autofocus: true,
min: @join_code.usage_count,
class: "input",
data: { action: "keydown.esc@document->form#cancel" } %>
<p class="margin-none txt-x-small txt-subtle">
Current usage: <%= @join_code.usage_count %>. You can only increase the limit to allow more uses.
</p>
</div>
<%= form.button type: :submit, class: "btn btn--link center txt-medium" do %>
<span>Update join code</span>
<% end %>
<%= link_to "Go back", account_join_code_path, data: { form_target: "cancel" }, hidden: true %>
<% end %>
</article>
@@ -0,0 +1,87 @@
<% @page_title = "Join Code" %>
<% content_for :header do %>
<%= render "filters/menu" %>
<h1 class="header__title"><%= @page_title %></h1>
<% end %>
<article
class="panel center txt-align-start"
style="view-transition-name: <%= dom_id(@join_code) %>"
>
<div class="flex flex-column gap">
<div class="flex align-center gap-half">
<h2 class="txt-large margin-none flex-item-grow">
<code><%= @join_code.code %></code>
</h2>
<% if @join_code.active? %>
<span class="badge badge--positive">Active</span>
<% else %>
<span class="badge badge--subtle">Used up</span>
<% end %>
</div>
<div class="flex flex-column gap-half">
<div>
<strong class="txt-small">Usage</strong>
<p class="margin-none txt-subtle">
<%= @join_code.usage_count %>
of
<%= @join_code.usage_limit %>
uses
<% if @join_code.active? %>
(
<%= @join_code.usage_limit - @join_code.usage_count %>
remaining)
<% end %>
</p>
</div>
</div>
<hr class="separator">
<div class="flex flex-column gap-half">
<strong class="txt-small">Join URL</strong>
<% url = join_url(join_code: @join_code.code, script_name: "/#{@join_code.tenant}") %>
<div class="flex align-center gap-half">
<input
type="text"
class="input flex-item-grow"
value="<%= url %>"
readonly
>
<%= button_to_copy_to_clipboard(url) do %>
<%= icon_tag "copy-paste" %>
<span class="for-screen-reader">Copy join link</span>
<% end %>
</div>
<p class="margin-none txt-x-small txt-subtle">
Share this URL with people you want to invite to your account.
</p>
<div
class="txt-align-center margin-block-start"
style="max-width: 200px; margin-inline: auto;"
>
<%= qr_code_image(url) %>
</div>
</div>
<hr class="separator">
<div class="flex gap-half">
<%= link_to edit_account_join_code_path, class: "btn btn--secondary txt-small" do %>
<%= icon_tag "edit" %>
<span>Edit limit</span>
<% end %>
<%= button_to account_join_code_path, method: :delete, class: "btn btn--negative txt-small",
data: { turbo_confirm: "Are you sure you want to regenerate this join code? The old code will no longer work." } do %>
<%= icon_tag "refresh" %>
<span>Regenerate</span>
<% end %>
</div>
</div>
</article>
@@ -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>
@@ -5,8 +5,6 @@
navigable-list-actionable-items-value="true">
<h2 class="divider txt-large">People on the account</h2>
<%= render "account/settings/invite" %>
<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">
+2 -2
View File
@@ -6,8 +6,8 @@
<%= filter_place_menu_item notifications_settings_path, "Notification Settings", "settings" %>
<% cache [ Current.user, Current.identity_token ] do %>
<% Current.user.identity.memberships.where.not(user_tenant: Current.user.tenant).each do |membership| %>
<%= filter_place_menu_item root_url(script_name: "/#{membership.user_tenant}"), "#{membership.account_name}", "logo-color", new_window: true %>
<% IdentityProvider.tenants_for(Current.identity_token).each do |tenant| %>
<%= filter_place_menu_item root_url(script_name: "/#{tenant.id}"), tenant.name, "logo-color", new_window: true %>
<% end %>
<% end %>
-21
View File
@@ -1,21 +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>
<% cache [ Current.user, Current.identity_token ] do %>
<% 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}") %>: <%= membership.email_address %>
</li>
<% end %>
</ul>
<% else %>
<p class="txt-large txt-align-center txt-subtle">You don't have any existing BOXCAR accounts.</p>
<% end %>
<% end %>
+13 -9
View File
@@ -9,17 +9,21 @@
</nav>
<% end %>
<% cache [ @identity, @memberships ] do %>
<div class="panel shadow center margin-block-double flex flex-column gap-half" style="--panel-size: 42ch; view-transition-name: sign-in-panel; --popup-icon-size: 24px; --popup-item-padding-inline: 0.5rem;">
<% if @memberships.present? %>
<h1 class="txt-x-large font-weight-black margin-none">Choose a Fizzy account</h1>
<% cache [ Current.identity_token, @tenants ] do %>
<div
class="panel shadow center margin-block-double flex flex-column gap-half"
style="--panel-size: 42ch; view-transition-name: sign-in-panel; --popup-icon-size: 24px; --popup-item-padding-inline: 0.5rem;"
>
<% if @tenants.present? %>
<h1 class="txt-x-large font-weight-black margin-none">
Choose a Fizzy account
</h1>
<menu class="popup__list pad border-radius border">
<% @memberships.each do |membership| %>
<% @tenants.each do |tenant| %>
<li class="popup__item txt-medium">
<%= icon_tag "logo-color", class: "popup__icon" %>
<%= button_to session_login_menu_url(membership_id: membership, script_name: "/#{membership.user_tenant}"), method: :post, class: "btn overflow-ellipsis fill-transparent popup__btn", form_class: "max-width flex-item-grow" do %>
<strong><%= membership.account_name %></strong>
<div class="txt-subtle font-weight-normal"><%= membership.email_address %></div>
<%= button_to session_login_menu_url(membership_id: 0, script_name: "/#{tenant.id}"), method: :post, class: "btn overflow-ellipsis fill-transparent popup__btn", form_class: "max-width flex-item-grow" do %>
<strong><%= tenant.name %></strong>
<% end %>
</li>
<% end %>
@@ -32,7 +36,7 @@
<div class="margin-block-start">
<%= link_to login_url, class: "btn center txt-small" do %>
<%= icon_tag "add" %>
<span>Link a Fizzy account</span>
<span>Create a Fizzy account</span>
<% end %>
</div>
</div>
+29 -20
View File
@@ -1,39 +1,48 @@
<% @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>
<nav>
<div class="fizzy-header-logo center flex-inline align-center txt-normal">
<%= inline_svg "fizzy-logo" %>
<strong class="txt-medium overflow-ellipsis margin-none">BOXCAR</strong>
</div>
</nav>
<% end %>
<div class="panel shadow center">
<h1 class="margin-none-block-start margin-block-end-double">BOXCAR</h1>
<div
class="panel shadow center margin-block-double flex flex-column gap-half"
style="--panel-size: 42ch; view-transition-name: sign-in-panel"
>
<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 model: @user, url: join_path(params[:join_code]), class: "flex flex-column gap" do |form| %>
<%= form_with scope: "user", url: join_path(join_code: params[:join_code]), class: "flex flex-column gap txt-medium" do |form| %>
<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" %>
<%= 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, 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">
<span>Join</span>
<%= icon_tag "arrow-right" %>
<span>Create account</span>
</button>
<% end %>
</div>
<% content_for :footer do %>
<div class="txt-align-center center margin-block-double txt-subtle">
BOXCAR&trade; Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
</div>
<% end %>
+1 -1
View File
@@ -26,7 +26,7 @@ production: &production
class: Webhook::CleanupDeliveriesJob
schedule: every 4 hours at minute 51
cleanup_magic_links:
class: "MagicLink::CleanupJob"
command: "MagicLink.cleanup"
schedule: every 4 hours
sqlite_backups:
class: SQLiteBackupsJob
+1 -1
View File
@@ -1,6 +1,6 @@
Rails.application.routes.draw do
namespace :account do
resource :join_code
resource :join_code, only: %i[ show edit update destroy ]
resource :settings
resource :entropy_configuration
end
@@ -1,5 +0,0 @@
class AddSetupStatusToAccounts < ActiveRecord::Migration[8.1]
def change
add_column :accounts, :setup_status, :string
end
end
@@ -0,0 +1,25 @@
class CreateAccountJoinCodes < ActiveRecord::Migration[8.1]
def change
create_table :account_join_codes do |t|
t.string :code, null: false, index: { unique: true }
t.integer :usage_count, default: 0, null: false
t.integer :usage_limit, default: 10, null: false
t.timestamps
end
reversible do |dir|
dir.up do
execute <<-SQL
INSERT INTO account_join_codes (code, usage_count, usage_limit, created_at, updated_at)
SELECT join_code, 0, 10, datetime('now'), datetime('now')
FROM accounts
WHERE join_code IS NOT NULL;
SQL
end
dir.down do
end
end
end
end
+5 -6
View File
@@ -18,8 +18,7 @@ def create_tenant(signal_account_name)
account = Account.create_with_admin_user(
account: {
external_account_id: tenant_id,
name: signal_account_name,
setup_status: :complete
name: signal_account_name
},
owner: {
name: "David Heinemeier Hansson",
@@ -32,8 +31,8 @@ def create_tenant(signal_account_name)
ApplicationRecord.current_tenant = tenant_id
identity = Membership.find_by(email_address: User.first.email_address)&.identity
User.first.set_identity(identity)
identity = Identity.find_or_create_by!(email_address: User.first.email_address)
identity.memberships.find_or_create_by!(tenant: tenant_id, account_name: Account.sole.name)
end
def find_or_create_user(full_name, email_address)
@@ -45,8 +44,8 @@ def find_or_create_user(full_name, email_address)
email_address: email_address,
password: "secret123456"
identity = Membership.find_by(email_address: email_address)&.identity || Identity.create!
user.set_identity(identity)
identity = Identity.find_or_create_by!(email_address: email_address)
identity.memberships.find_or_create_by!(tenant: ApplicationRecord.current_tenant, account_name: Account.sole.name)
user
end
@@ -0,0 +1,47 @@
class MoveEmailToIdentity < ActiveRecord::Migration[8.1]
def change
add_column :identities, :email_address, :string
# Create identities for each unique email
execute <<-SQL
INSERT INTO identities (email_address, created_at, updated_at)
SELECT DISTINCT email_address, datetime('now'), datetime('now')
FROM memberships
WHERE email_address IS NOT NULL
AND email_address NOT IN (SELECT email_address FROM identities WHERE email_address IS NOT NULL);
SQL
# And link memberships to them
execute <<-SQL
UPDATE memberships
SET identity_id = (
SELECT id
FROM identities
WHERE identities.email_address = memberships.email_address
)
WHERE email_address IS NOT NULL;
SQL
# Delete memberships associated with identities without an email address
execute <<-SQL
DELETE FROM memberships
WHERE identity_id IN (
SELECT id
FROM identities
WHERE email_address IS NULL
);
SQL
# Delete identities without an email address
execute <<-SQL
DELETE FROM identities
WHERE email_address IS NULL;
SQL
change_column_null :memberships, :identity_id, false
add_index :identities, :email_address, unique: true
remove_column :memberships, :email_address
remove_column :memberships, :user_id, :bigint
rename_column :memberships, :user_tenant, :tenant
end
end
@@ -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,28 @@
module InternalApi
extend ActiveSupport::Concern
included do
require_untenanted_access
skip_before_action :verify_authenticity_token
before_action :verify_request_authentication
before_action :verify_request_signature
end
private
def verify_request_authentication
authenticated = authenticate_with_http_token do |token, options|
ActiveSupport::SecurityUtils.secure_compare(token, InternalApiClient.token)
end
head :unauthorized unless authenticated
end
def verify_request_signature
signature = request.headers[InternalApiClient::SIGNATURE_HEADER].to_s
computed_signature = InternalApiClient.signature_for(request.raw_post)
unless ActiveSupport::SecurityUtils.secure_compare(signature, computed_signature)
head :unauthorized
end
end
end
@@ -0,0 +1,23 @@
class IdentitiesController < ApplicationController
include InternalApi
def link
Identity.link(email_address: params[:email_address], to: params[:to])
head :ok
end
def unlink
Identity.unlink(email_address: params[:email_address], from: params[:from])
head :ok
end
def change_email_address
Membership.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant])
head :ok
end
def send_magic_link
magic_link = Identity.find_by(email_address: params[:email_address])&.send_magic_link
render json: { code: magic_link&.code }
end
end
@@ -1,9 +1,10 @@
class Signups::CompletionsController < ApplicationController
allow_unauthenticated_access
require_untenanted_access
before_action :require_identity
before_action :ensure_setup_pending
before_action :ensure_identity_of_an_admin
http_basic_authenticate_with \
name: Rails.env.test? ? "testname" : Rails.application.credentials.account_signup_http_basic_auth.name,
password: Rails.env.test? ? "testpassword" : Rails.application.credentials.account_signup_http_basic_auth.password
def new
@signup = Signup.new
@@ -13,34 +14,21 @@ class Signups::CompletionsController < ApplicationController
@signup = Signup.new(signup_params)
if @signup.complete
start_new_session_for(@signup.user)
redirect_to root_path
redirect_to session_login_menu_path(go_to: @signup.tenant)
else
render :new, status: :unprocessable_entity
end
end
private
def ensure_setup_pending
unless Account.sole.setup_pending?
redirect_to root_path
end
end
def ensure_identity_of_an_admin
unless user&.admin?
redirect_to root_path
end
end
def signup_params
params.expect(signup: %i[ full_name company_name ]).with_defaults(
tenant: ApplicationRecord.current_tenant,
user: user
identity: identity,
email_address: identity.email_address
)
end
def user
@user ||= User.all.admin.with_identity(Current.identity_token.identity).first
def identity
@identity ||= Identity.find_signed(Current.identity_token.id)
end
end
@@ -17,7 +17,8 @@ class SignupsController < ApplicationController
def create
@signup = Signup.new(signup_params)
if @signup.create_account
if @signup.create_identity
session[:return_to_after_identification] = saas.new_signup_completion_path
redirect_to session_magic_link_path
else
render :new, status: :unprocessable_entity
@@ -3,9 +3,9 @@ class MagicLinkMailer < ApplicationMailer
def sign_in_instructions(magic_link)
@magic_link = magic_link
@membership = @magic_link.membership
@identity = @magic_link.identity
mail to: @membership.email_address, subject: "Sign in to Fizzy"
mail to: @identity.email_address, subject: "Sign in to Fizzy"
end
private
@@ -0,0 +1,99 @@
class InternalApiClient
SECRET_KEY = "internal_api_client_signing_secret"
TOKEN_KEY = "internal_api_client_token"
USER_AGENT = "fizzy/1.0.0 InternalApiClient"
SIGNATURE_HEADER = "X-Internal-Signature"
DEFAULT_TIMEOUT = 5.seconds
class Error < StandardError; end
class TimeoutError < Error; end
class ConnectionError < Error; end
Response = Struct.new(:code, :body, :error) do
def parsed_body
@parsed_body ||= JSON.parse(body) if body.present?
end
def success?
error.nil? && code.between?(200, 299)
end
end
attr_reader :url, :response
class << self
def token
Rails.application.key_generator.generate_key(TOKEN_KEY, 32).unpack1("H*")
end
def signature_for(body)
OpenSSL::HMAC.hexdigest("SHA256", signing_secret, body)
end
private
def signing_secret
Rails.application.key_generator.generate_key(SECRET_KEY, 32).unpack1("H*")
end
end
def initialize(url, timeout: DEFAULT_TIMEOUT)
@url = url
@timeout = timeout
end
def post(body = nil, params: {})
uri = build_uri(@url, params)
payload = prepare_payload(body)
request = Net::HTTP::Post.new(uri, headers(payload))
request.body = payload
perform_request(uri, request)
end
private
def build_uri(url, params)
uri = URI(url)
if params.any?
existing_params = URI.decode_www_form(uri.query || "").to_h
uri.query = URI.encode_www_form(existing_params.merge(params))
end
uri
end
def prepare_payload(body)
case body
when nil
""
when String
body
else
body.to_json
end
end
def headers(payload)
{
"User-Agent" => USER_AGENT,
"Content-Type" => "application/json",
"Authorization" => "Bearer #{self.class.token}",
SIGNATURE_HEADER => self.class.signature_for(payload)
}
end
def perform_request(uri, request)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == "https")
http.open_timeout = @timeout
http.read_timeout = @timeout
response = http.request(request)
Response.new(code: response.code.to_i, body: response.body)
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ETIMEDOUT
Response.new(error: :connection_timeout)
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ECONNRESET, SocketError
Response.new(error: :destination_unreachable)
rescue OpenSSL::SSL::SSLError
Response.new(error: :failed_tls)
end
end
+23 -36
View File
@@ -3,17 +3,15 @@ class Signup
include ActiveModel::Attributes
include ActiveModel::Validations
PERMITTED_KEYS = %i[ full_name email_address company_name ]
attr_accessor :company_name, :full_name, :email_address, :identity
attr_reader :queenbee_account, :account, :user, :tenant
attr_accessor :company_name, :full_name, :email_address, :user, :tenant
attr_reader :queenbee_account, :account
with_options on: :account_creation do
with_options on: :identity_creation do
validates_presence_of :email_address
end
with_options on: :completion do
validates_presence_of :company_name, :full_name
validates_presence_of :company_name, :full_name, :identity
end
@@ -30,17 +28,22 @@ class Signup
super
end
def create_account
return false unless valid?(:account_creation)
def create_identity
return false unless valid?(:identity_creation)
if membership = Membership.find_by(email_address: email_address)
membership.send_magic_link
else
create_queenbee_account
create_tenant
user.membership.send_magic_link
end
@identity = Identity.find_or_create_by!(email_address: email_address)
@identity.send_magic_link
end
def complete
return false unless valid?(:completion)
create_queenbee_account
create_tenant
identity.link_to(tenant)
true
rescue => error
destroy_tenant
destroy_queenbee_account
@@ -52,21 +55,6 @@ class Signup
false
end
def complete
return false unless valid?(:completion)
ApplicationRecord.with_tenant(tenant) do
@account = Account.sole
ApplicationRecord.transaction do
user.update!(name: full_name)
account.update!(name: company_name, setup_status: :complete)
user.membership.update!(account_name: company_name)
end
end
# TODO: Update company and user name in QB
end
private
def create_queenbee_account
@queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
@@ -78,17 +66,16 @@ class Signup
end
def create_tenant
self.tenant = queenbee_account.id.to_s
@tenant = queenbee_account.id.to_s
ApplicationRecord.create_tenant(tenant) do
@account = Account.create_with_admin_user(
account: {
external_account_id: tenant,
name: "New Account",
setup_status: :pending
name: company_name
},
owner: {
name: email_address,
name: full_name,
email_address: email_address
}
)
@@ -103,8 +90,8 @@ class Signup
end
@account = nil
self.user = nil
self.tenant = nil
@user = nil
@tenant = nil
end
def queenbee_account_attributes
+6
View File
@@ -6,5 +6,11 @@ Fizzy::Saas::Engine.routes.draw do
end
end
end
Queenbee.routes(self)
post "identities/link", to: "identities#link", as: :link_identity
post "identities/unlink", to: "identities#unlink", as: :unlink_identity
post "identities/change_email_address", to: "identities#change_email_address", as: :change_identity_email_address
post "identities/send_magic_link", to: "identities#send_magic_link", as: :send_magic_link
end
-1
View File
@@ -3,6 +3,5 @@ require "fizzy/saas/engine"
module Fizzy
module Saas
# Your code goes here...
end
end
@@ -2,7 +2,6 @@ require "test_helper"
class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest
setup do
Account.sole.update!(setup_status: :pending)
set_identity_as :kevin
end
@@ -22,12 +21,9 @@ class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_path, "Successful completion should redirect to root"
assert cookies[:session_token].present?, "Successful completion should create a session"
assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete"
assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated"
assert_equal "37signals", Account.sole.reload.name, "Account name should be updated"
Account.sole.update!(setup_status: :pending)
post saas.signup_completion_path, params: {
signup: {
full_name: "",
@@ -47,7 +47,6 @@ class SignupTest < ActiveSupport::TestCase
end
test "#complete" do
Account.sole.update!(setup_status: :pending)
signup = Signup.new(
tenant: ApplicationRecord.current_tenant,
user: users(:kevin),
@@ -57,7 +56,6 @@ class SignupTest < ActiveSupport::TestCase
assert signup.complete, signup.errors.full_messages.to_sentence(words_connector: ". ")
assert_equal "complete", Account.sole.reload.setup_status, "Account setup status should be complete"
assert_equal "Kevin Systrom", users(:kevin).reload.name, "User name should be updated"
assert_equal "37signals", Account.sole.reload.name, "Account name should be updated"
assert_equal "37signals", users(:kevin).membership.reload.account_name, "Membership account name should be updated"
+13
View File
@@ -0,0 +1,13 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
token: MyString
usage_count: 1
usage_limit: 1
creator: one
two:
token: MyString
usage_count: 1
usage_limit: 1
creator: two
+7
View File
@@ -0,0 +1,7 @@
require "test_helper"
class Account::InvitationTokenTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end