Clean up and simplify magic links
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class MagicLink::CleanupJob < ApplicationJob
|
||||
def perform
|
||||
MagicLink.cleanup
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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">← <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">
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class Account::InvitationTokenTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
Reference in New Issue
Block a user