diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index 5c981a2ba..b67246b80 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -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 diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index f9a462b7f..b0b2ceeb4 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -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 diff --git a/app/controllers/sessions/login_menus_controller.rb b/app/controllers/sessions/login_menus_controller.rb index f65ab3c5c..8db72726b 100644 --- a/app/controllers/sessions/login_menus_controller.rb +++ b/app/controllers/sessions/login_menus_controller.rb @@ -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 diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 2039f527c..51426e43c 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 5db073e97..139e60dcd 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 11b793b17..400a4ba94 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/jobs/magic_link/cleanup_job.rb b/app/jobs/magic_link/cleanup_job.rb deleted file mode 100644 index eea054fd0..000000000 --- a/app/jobs/magic_link/cleanup_job.rb +++ /dev/null @@ -1,5 +0,0 @@ -class MagicLink::CleanupJob < ApplicationJob - def perform - MagicLink.cleanup - end -end diff --git a/app/models/account.rb b/app/models/account.rb index 3e9b0fe19..f6fbd98b2 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb new file mode 100644 index 000000000..c536f7c6c --- /dev/null +++ b/app/models/account/join_code.rb @@ -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 diff --git a/app/models/account/joinable.rb b/app/models/account/joinable.rb deleted file mode 100644 index 3f313ce3a..000000000 --- a/app/models/account/joinable.rb +++ /dev/null @@ -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 diff --git a/app/models/identity.rb b/app/models/identity.rb index 74aa3830b..c2aec4061 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -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 diff --git a/app/models/identity_provider.rb b/app/models/identity_provider.rb new file mode 100644 index 000000000..d42325f48 --- /dev/null +++ b/app/models/identity_provider.rb @@ -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 diff --git a/app/models/identity_provider/saas.rb b/app/models/identity_provider/saas.rb new file mode 100644 index 000000000..9e956bca9 --- /dev/null +++ b/app/models/identity_provider/saas.rb @@ -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 diff --git a/app/models/magic_link.rb b/app/models/magic_link.rb index bb06313f7..f596a2bd3 100644 --- a/app/models/magic_link.rb +++ b/app/models/magic_link.rb @@ -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 diff --git a/app/models/membership.rb b/app/models/membership.rb index 9233de99c..ae7f94c44 100644 --- a/app/models/membership.rb +++ b/app/models/membership.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 14b2cff93..e569ac2ef 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/user/invitable.rb b/app/models/user/invitable.rb new file mode 100644 index 000000000..aaa52af10 --- /dev/null +++ b/app/models/user/invitable.rb @@ -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 diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb new file mode 100644 index 000000000..e93191e10 --- /dev/null +++ b/app/views/account/join_codes/edit.html.erb @@ -0,0 +1,46 @@ +<% @page_title = "Edit Join Code" %> + +<% content_for :header do %> + <%= render "filters/menu" %> +
+ <%= link_to account_join_code_path, class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> + Join Code + <% end %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <%= form_with model: @join_code, url: account_join_code_path, method: :patch, data: { controller: "form" }, html: { class: "flex flex-column gap" } do |form| %> +
+ <%= form.label :usage_limit do %> + Usage limit +
+ +

+ How many times can this code be used to join the account? +

+ <% end %> + + <%= form.number_field :usage_limit, + required: true, + autofocus: true, + min: @join_code.usage_count, + class: "input", + data: { action: "keydown.esc@document->form#cancel" } %> +

+ Current usage: <%= @join_code.usage_count %>. You can only increase the limit to allow more uses. +

+
+ + <%= form.button type: :submit, class: "btn btn--link center txt-medium" do %> + Update join code + <% end %> + + <%= link_to "Go back", account_join_code_path, data: { form_target: "cancel" }, hidden: true %> + <% end %> +
diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb new file mode 100644 index 000000000..b33d283be --- /dev/null +++ b/app/views/account/join_codes/show.html.erb @@ -0,0 +1,87 @@ +<% @page_title = "Join Code" %> + +<% content_for :header do %> + <%= render "filters/menu" %> +

<%= @page_title %>

+<% end %> + +
+
+
+

+ <%= @join_code.code %> +

+ <% if @join_code.active? %> + Active + <% else %> + Used up + <% end %> +
+ +
+
+ Usage +

+ <%= @join_code.usage_count %> + of + <%= @join_code.usage_limit %> + uses + <% if @join_code.active? %> + ( + <%= @join_code.usage_limit - @join_code.usage_count %> + remaining) + <% end %> +

+
+
+ +
+ +
+ Join URL + + <% url = join_url(join_code: @join_code.code, script_name: "/#{@join_code.tenant}") %> +
+ + <%= button_to_copy_to_clipboard(url) do %> + <%= icon_tag "copy-paste" %> + Copy join link + <% end %> +
+ +

+ Share this URL with people you want to invite to your account. +

+ +
+ <%= qr_code_image(url) %> +
+
+ +
+ +
+ <%= link_to edit_account_join_code_path, class: "btn btn--secondary txt-small" do %> + <%= icon_tag "edit" %> + Edit limit + <% 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" %> + Regenerate + <% end %> +
+
+
diff --git a/app/views/account/settings/_invite.html.erb b/app/views/account/settings/_invite.html.erb deleted file mode 100644 index 4bc7562f0..000000000 --- a/app/views/account/settings/_invite.html.erb +++ /dev/null @@ -1,40 +0,0 @@ -
- <% url = join_url(Account.sole.join_code) %> - - - -
-
- <%= tag.button class: "btn", data: { action: "dialog#open" } do %> - <%= icon_tag "qr-code" %> - Show join link QR code - <% end %> - - - <%= image_tag account_join_code_path, class: "qr-code center", alt: "QR Code", loading: "lazy" %> - -
- -
-
-
- - <%= button_to_copy_to_clipboard(url) do %> - <%= icon_tag "copy-paste" %> - Copy join link - <% end %> - - <%= button_to account_join_code_path, method: :put, class: "btn btn--regenerate" do %> - <%= icon_tag "refresh" %> - Regenerate join link - <% end %> -
-
diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index e47304df4..6011b7e3a 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -5,8 +5,6 @@ navigable-list-actionable-items-value="true">

People on the account

- <%= render "account/settings/invite" %> -
diff --git a/app/views/my/menus/_places.html.erb b/app/views/my/menus/_places.html.erb index 428504dba..06b9fe02a 100644 --- a/app/views/my/menus/_places.html.erb +++ b/app/views/my/menus/_places.html.erb @@ -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 %> diff --git a/app/views/sessions/login_menu.html.erb b/app/views/sessions/login_menu.html.erb deleted file mode 100644 index 88d6e0be6..000000000 --- a/app/views/sessions/login_menu.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<% @page_title = "Choose Account" %> - -
-

BOXCAR

- -<% cache [ Current.user, Current.identity_token ] do %> - <% identity = Identity.find_signed(Current.identity_token.id) %> - <% memberships = identity&.memberships %> - <% if memberships.present? %> -

Your BOXCAR Accounts

- - <% else %> -

You don't have any existing BOXCAR accounts.

- <% end %> -<% end %> diff --git a/app/views/sessions/login_menus/show.html.erb b/app/views/sessions/login_menus/show.html.erb index 0e806491d..7486ff2f5 100644 --- a/app/views/sessions/login_menus/show.html.erb +++ b/app/views/sessions/login_menus/show.html.erb @@ -9,17 +9,21 @@ <% end %> -<% cache [ @identity, @memberships ] do %> -
- <% if @memberships.present? %> -

Choose a Fizzy account

+<% cache [ Current.identity_token, @tenants ] do %> +
+ <% if @tenants.present? %> +

+ Choose a Fizzy account +

- <% @memberships.each do |membership| %> + <% @tenants.each do |tenant| %> <% end %> @@ -32,7 +36,7 @@
<%= link_to login_url, class: "btn center txt-small" do %> <%= icon_tag "add" %> - Link a Fizzy account + Create a Fizzy account <% end %>
diff --git a/app/views/users/new.html.erb b/app/views/users/new.html.erb index f37665a64..302ffcd73 100644 --- a/app/views/users/new.html.erb +++ b/app/views/users/new.html.erb @@ -1,39 +1,48 @@ -<% @page_title = "Create your account" %> +<% @page_title = "Join #{Account.sole.name}" %> <% content_for :header do %> -
-
- <%= link_to login_url, class: "btn", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %> - Sign in instead - <% end %> -
+ <% end %> -
-

BOXCAR

+
+
+

+ <%= @page_title %> +

+

Enter your email address to continue

+
- <%= 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| %>
+
-
- -
- <% end %>
+ +<% content_for :footer do %> +
+ BOXCAR™ Beta. <%= mail_to "support@37signals.com", "Need help?", class: "txt-link" %> +
+<% end %> diff --git a/config/recurring.yml b/config/recurring.yml index 8ed13f58d..89b43c567 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index be32d7119..d8469db1d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20251011080030_add_setup_status_to_accounts.rb b/db/migrate/20251011080030_add_setup_status_to_accounts.rb deleted file mode 100644 index db5f5c537..000000000 --- a/db/migrate/20251011080030_add_setup_status_to_accounts.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddSetupStatusToAccounts < ActiveRecord::Migration[8.1] - def change - add_column :accounts, :setup_status, :string - end -end diff --git a/db/migrate/20251015081645_create_account_join_codes.rb b/db/migrate/20251015081645_create_account_join_codes.rb new file mode 100644 index 000000000..79b137475 --- /dev/null +++ b/db/migrate/20251015081645_create_account_join_codes.rb @@ -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 diff --git a/db/seeds.rb b/db/seeds.rb index 76bcbd253..3a7dbf00e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -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 diff --git a/db/untenanted_migrate/20251015170934_move_email_to_identity.rb b/db/untenanted_migrate/20251015170934_move_email_to_identity.rb new file mode 100644 index 000000000..711fd5268 --- /dev/null +++ b/db/untenanted_migrate/20251015170934_move_email_to_identity.rb @@ -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 diff --git a/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb b/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb new file mode 100644 index 000000000..67574ea12 --- /dev/null +++ b/db/untenanted_migrate/20251015173452_associate_magic_links_to_identities.rb @@ -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 diff --git a/gems/fizzy-saas/app/controllers/concerns/internal_api.rb b/gems/fizzy-saas/app/controllers/concerns/internal_api.rb new file mode 100644 index 000000000..4c88a6d46 --- /dev/null +++ b/gems/fizzy-saas/app/controllers/concerns/internal_api.rb @@ -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 diff --git a/gems/fizzy-saas/app/controllers/identities_controller.rb b/gems/fizzy-saas/app/controllers/identities_controller.rb new file mode 100644 index 000000000..0592bed79 --- /dev/null +++ b/gems/fizzy-saas/app/controllers/identities_controller.rb @@ -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 diff --git a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb index bf322354a..ecdf5b38d 100644 --- a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb @@ -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 diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb index 8421270e2..ce82f23bb 100644 --- a/gems/fizzy-saas/app/controllers/signups_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups_controller.rb @@ -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 diff --git a/app/mailers/magic_link_mailer.rb b/gems/fizzy-saas/app/mailers/magic_link_mailer.rb similarity index 70% rename from app/mailers/magic_link_mailer.rb rename to gems/fizzy-saas/app/mailers/magic_link_mailer.rb index 11f0f5705..9b04d2ff5 100644 --- a/app/mailers/magic_link_mailer.rb +++ b/gems/fizzy-saas/app/mailers/magic_link_mailer.rb @@ -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 diff --git a/gems/fizzy-saas/app/models/internal_api_client.rb b/gems/fizzy-saas/app/models/internal_api_client.rb new file mode 100644 index 000000000..093fa75db --- /dev/null +++ b/gems/fizzy-saas/app/models/internal_api_client.rb @@ -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 diff --git a/gems/fizzy-saas/app/models/signup.rb b/gems/fizzy-saas/app/models/signup.rb index e9d9dfbeb..b52da43a0 100644 --- a/gems/fizzy-saas/app/models/signup.rb +++ b/gems/fizzy-saas/app/models/signup.rb @@ -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 diff --git a/gems/fizzy-saas/config/routes.rb b/gems/fizzy-saas/config/routes.rb index 172eb6e6f..f6519a5ae 100644 --- a/gems/fizzy-saas/config/routes.rb +++ b/gems/fizzy-saas/config/routes.rb @@ -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 diff --git a/gems/fizzy-saas/lib/fizzy/saas.rb b/gems/fizzy-saas/lib/fizzy/saas.rb index bfdd34dfa..dd0d35492 100644 --- a/gems/fizzy-saas/lib/fizzy/saas.rb +++ b/gems/fizzy-saas/lib/fizzy/saas.rb @@ -3,6 +3,5 @@ require "fizzy/saas/engine" module Fizzy module Saas - # Your code goes here... end end diff --git a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb index 5b24277dc..19c542ff0 100644 --- a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb +++ b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb @@ -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: "", diff --git a/gems/fizzy-saas/test/models/signup_test.rb b/gems/fizzy-saas/test/models/signup_test.rb index 48c6f499b..e4552be8a 100644 --- a/gems/fizzy-saas/test/models/signup_test.rb +++ b/gems/fizzy-saas/test/models/signup_test.rb @@ -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" diff --git a/test/fixtures/account/invitation_tokens.yml b/test/fixtures/account/invitation_tokens.yml new file mode 100644 index 000000000..bdd7db922 --- /dev/null +++ b/test/fixtures/account/invitation_tokens.yml @@ -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 diff --git a/test/models/account/join_codes_test.rb b/test/models/account/join_codes_test.rb new file mode 100644 index 000000000..7afcab749 --- /dev/null +++ b/test/models/account/join_codes_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::InvitationTokenTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end