diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 5ffd86551..8d30d0cbd 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -21,6 +21,7 @@ module Authentication def allow_unauthenticated_access(**options) skip_before_action :require_authentication, **options + before_action :resume_identity, **options before_action :resume_session, **options end @@ -30,9 +31,14 @@ module Authentication before_action :redirect_tenanted_request, **options end - def require_identity(**options) + def require_identified_access(**options) before_action :require_identity, **options end + + def require_unidentified_access(**options) + before_action :resume_identity, **options + before_action :redirect_request_with_identification, **options + end end private @@ -110,6 +116,10 @@ module Authentication redirect_to root_url if ApplicationRecord.current_tenant end + def redirect_request_with_identification + redirect_to session_login_menu_path(script_name: nil) if Current.identity_token.present? + end + def start_new_session_for(user) user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| set_current_session session diff --git a/app/controllers/sessions/login_menus_controller.rb b/app/controllers/sessions/login_menus_controller.rb index 03c5a4083..ebd06191e 100644 --- a/app/controllers/sessions/login_menus_controller.rb +++ b/app/controllers/sessions/login_menus_controller.rb @@ -1,5 +1,6 @@ class Sessions::LoginMenusController < ApplicationController require_untenanted_access + require_identified_access def show @tenants = IdentityProvider.tenants_for(resume_identity) diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index 51426e43c..a955cda75 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -1,5 +1,6 @@ class Sessions::MagicLinksController < ApplicationController require_untenanted_access + require_unidentified_access rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." } def show diff --git a/app/controllers/sessions/starts_controller.rb b/app/controllers/sessions/starts_controller.rb index 8661634f3..f0db9d89a 100644 --- a/app/controllers/sessions/starts_controller.rb +++ b/app/controllers/sessions/starts_controller.rb @@ -1,5 +1,6 @@ class Sessions::StartsController < ApplicationController allow_unauthenticated_access + require_identified_access def new end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 139e60dcd..ef54fc34f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,5 +1,6 @@ class SessionsController < ApplicationController require_untenanted_access only: %i[ new create ] + require_unidentified_access only: %i[ new create ] rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } def new diff --git a/app/controllers/users/email_addresses_controller.rb b/app/controllers/users/email_addresses_controller.rb index 0595e3470..c9c370315 100644 --- a/app/controllers/users/email_addresses_controller.rb +++ b/app/controllers/users/email_addresses_controller.rb @@ -6,8 +6,12 @@ class Users::EmailAddressesController < ApplicationController end def create - token = @user.generate_email_address_change_token(to: new_email_address, expires_in: 30.minutes) - UserMailer.email_change_confirmation(user: @user, email_address: new_email_address, token: token).deliver_later + if User.exists?(email_address: new_email_address) + flash.now[:alert] = "Someone else already uses that email" + render :new, status: :unprocessable_entity + else + @user.send_email_address_change_confirmation(new_email_address) + end end private diff --git a/app/models/account/join_code.rb b/app/models/account/join_code.rb index c536f7c6c..2ed42e5c1 100644 --- a/app/models/account/join_code.rb +++ b/app/models/account/join_code.rb @@ -11,10 +11,13 @@ class Account::JoinCode < ApplicationRecord class << self def redeem(code) - find_by(code: code)&.tap do |join_code| - if join_code.active? - join_code.increment!(:usage_count) - end + join_code = find_by(code: code) + + if join_code&.active? + join_code.increment!(:usage_count) + true + else + false end end @@ -28,7 +31,9 @@ class Account::JoinCode < ApplicationRecord end def reset - update!(code: generate_code, usage_count: 0) + generate_code + self.usage_count = 0 + save! end private diff --git a/app/models/identity_provider.rb b/app/models/identity_provider.rb index c76872487..2fb22bfd2 100644 --- a/app/models/identity_provider.rb +++ b/app/models/identity_provider.rb @@ -1,4 +1,6 @@ module IdentityProvider + class Error < StandardError; end + Token = Data.define(:id, :updated_at) do delegate :dig, to: :to_h @@ -11,7 +13,7 @@ module IdentityProvider extend self - mattr_accessor :backend, default: IdentityProvider::Simple + mattr_accessor :backend, default: IdentityProvider::LocalBackend delegate :link, :unlink, :change_email_address, :send_magic_link, :consume_magic_link, :tenants_for, :token_for, :resolve_token, :verify_token, to: :backend end diff --git a/app/models/identity_provider/simple.rb b/app/models/identity_provider/local_backend.rb similarity index 97% rename from app/models/identity_provider/simple.rb rename to app/models/identity_provider/local_backend.rb index 122a0b431..99b709110 100644 --- a/app/models/identity_provider/simple.rb +++ b/app/models/identity_provider/local_backend.rb @@ -1,4 +1,4 @@ -module IdentityProvider::Simple +module IdentityProvider::LocalBackend extend self def link(email_address:, to:) diff --git a/app/models/user.rb b/app/models/user.rb index cc4c3965c..d18450332 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,15 +20,20 @@ class User < ApplicationRecord normalizes :email_address, with: ->(value) { value.strip.downcase } def deactivate + old_email_address = email_address + sessions.delete_all accesses.destroy_all - old_email = email_address - update! active: false, email_address: deactived_email_address - unlink_identity + + update! active: false, email_address: deactivated_email_address + IdentityProvider.unlink(email_address: old_email_address, from: tenant) + rescue => e + update! active: true, email_address: old_email_address + raise e end private - def deactived_email_address + def deactivated_email_address email_address.sub(/@/, "-deactivated-#{SecureRandom.uuid}@") end end diff --git a/app/models/user/email_address_changeable.rb b/app/models/user/email_address_changeable.rb index da2137b39..784239079 100644 --- a/app/models/user/email_address_changeable.rb +++ b/app/models/user/email_address_changeable.rb @@ -1,8 +1,14 @@ module User::EmailAddressChangeable EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address" + EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes extend ActiveSupport::Concern + def send_email_address_change_confirmation(new_email_address) + token = generate_email_address_change_token(to: new_email_address, expires_in: EMAIL_CHANGE_TOKEN_EXPIRATION) + UserMailer.email_change_confirmation(user: self, email_address: new_email_address, token: token).deliver_later + end + def generate_email_address_change_token(from: email_address, to:, **options) options = options.reverse_merge( for: EMAIL_CHANGE_TOKEN_PURPOSE, @@ -23,7 +29,20 @@ module User::EmailAddressChangeable elsif email_address != parsed_token.params.fetch("old_email_address") raise ArgumentError, "The token was generated for a different email address" else - update!(email_address: parsed_token.params.fetch("new_email_address")) + change_email_address(parsed_token.params.fetch("new_email_address")) end end + + private + def change_email_address(new_email_address) + old_email_address = email_address + update!(email_address: new_email_address) + + begin + IdentityProvider.change_email_address(from: old_email_address, to: new_email_address, tenant: tenant) + rescue => e + update!(email_address: old_email_address) + raise e + end + end end diff --git a/app/models/user/identifiable.rb b/app/models/user/identifiable.rb index 58cac51ea..abe7b412a 100644 --- a/app/models/user/identifiable.rb +++ b/app/models/user/identifiable.rb @@ -3,7 +3,6 @@ module User::Identifiable included do after_create_commit :link_identity, unless: :system? - after_update_commit :update_email_address_on_identity, if: -> { saved_change_to_email_address? && !system? } after_destroy_commit :unlink_identity, unless: :system? end @@ -19,9 +18,4 @@ module User::Identifiable def unlink_identity IdentityProvider.unlink(email_address: email_address, from: tenant) end - - def update_email_address_on_identity - old_email, new_email = saved_change_to_email_address - IdentityProvider.change_email_address(from: old_email, to: new_email, tenant: tenant) - end end diff --git a/app/views/sessions/starts/new.html.erb b/app/views/sessions/starts/new.html.erb index 904eb5432..1438d5271 100644 --- a/app/views/sessions/starts/new.html.erb +++ b/app/views/sessions/starts/new.html.erb @@ -1,5 +1,5 @@ <% @hide_footer_frames = true %> -<% @page_title = "Signing into #{Account.sole.name}" %> +<% @page_title = "Signing in..." %> <% content_for :header do %> <% end %> -
-

<%= @page_title %>

+
+
+

+ <%= @page_title %> +

+

Just a sec while we sign you in with <%= Account.sole.name %>.

+
<%= form_with url: session_start_path, method: :post, data: { controller: "auto-submit" } do |form| %> - <%= form.button "Continue", type: "submit", class: "btn btn-primary" %> + <%= form.button "Sign in", type: "submit", class: "btn btn-primary", data: { turbo_submits_with: "Signing in..." } %> <% end %>
diff --git a/db/schema.rb b/db/schema.rb index 86470ce07..e4c3b3491 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -24,6 +24,15 @@ ActiveRecord::Schema[8.2].define(version: 2025_10_29_161222) do t.index ["user_id"], name: "index_accesses_on_user_id" end + create_table "account_join_codes", force: :cascade do |t| + t.string "code", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "usage_count", default: 0, null: false + t.integer "usage_limit", default: 10, null: false + t.index ["code"], name: "index_account_join_codes_on_code", unique: true + end + create_table "accounts", force: :cascade do |t| t.datetime "created_at", null: false t.integer "external_account_id" diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 4ca90d0bd..ff60d4302 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -20,7 +20,7 @@ columns: default_function: collation: comment: - - &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: collection_id cast_type: &3 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3Adapter::SQLite3Integer @@ -40,7 +40,7 @@ columns: default_function: collation: comment: - - &5 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &7 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: created_at cast_type: *1 @@ -50,7 +50,7 @@ columns: default_function: collation: comment: - - &6 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &8 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: true name: id cast_type: *3 @@ -63,13 +63,13 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: involvement - cast_type: &7 !ruby/object:ActiveModel::Type::String + cast_type: &5 !ruby/object:ActiveModel::Type::String true: t false: f precision: scale: limit: - sql_type_metadata: &8 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &6 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: varchar type: :string limit: @@ -90,7 +90,7 @@ columns: default_function: collation: comment: - - &28 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: user_id cast_type: *3 @@ -100,8 +100,42 @@ columns: default_function: collation: comment: + account_join_codes: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: code + cast_type: *5 + sql_type_metadata: *6 + 'null': false + default: + default_function: + collation: + comment: + - *7 + - *8 + - *9 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: usage_count + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: 0 + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: usage_limit + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: 10 + default_function: + collation: + comment: accounts: - - *5 + - *7 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: external_account_id @@ -112,22 +146,12 @@ columns: default_function: collation: comment: - - *6 - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: join_code - cast_type: *7 - sql_type_metadata: *8 - 'null': true - default: - default_function: - collation: - comment: + - *8 - &10 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: name - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: @@ -155,8 +179,8 @@ columns: default_function: collation: comment: - - *5 - - *6 + - *7 + - *8 - *10 - &13 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -181,8 +205,8 @@ columns: - &14 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: record_type - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: @@ -200,16 +224,16 @@ columns: default_function: collation: comment: - - *5 - - *6 + - *7 + - *8 - *10 - *13 - *14 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: slug - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: @@ -229,8 +253,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: checksum - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: @@ -239,30 +263,30 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: content_type - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: - - *5 + - *7 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: filename - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: collation: comment: - - *6 - - &18 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - *8 + - &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: key - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: @@ -281,8 +305,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: service_name - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: @@ -290,33 +314,68 @@ columns: comment: active_storage_variant_records: - *17 - - *6 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: variation_digest - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: collation: comment: - ar_internal_metadata: - - *5 + ai_quotas: + - *7 + - *8 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: limit + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: reset_at + cast_type: *1 + sql_type_metadata: *2 + 'null': false + default: + default_function: + collation: + comment: + - *9 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: used + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: 0 + default_function: + collation: + comment: - *18 + ar_internal_metadata: + - *7 + - *19 - *9 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: value - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: assignees_filters: - - &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: assignee_id cast_type: *3 @@ -326,7 +385,7 @@ columns: default_function: collation: comment: - - &19 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &20 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: filter_id cast_type: *3 @@ -337,7 +396,7 @@ columns: collation: comment: assigners_filters: - - &21 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: assigner_id cast_type: *3 @@ -347,11 +406,11 @@ columns: default_function: collation: comment: - - *19 - assignments: - *20 + assignments: - *21 - - &22 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - *22 + - &23 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: card_id cast_type: *3 @@ -361,13 +420,13 @@ columns: default_function: collation: comment: - - *5 - - *6 + - *7 + - *8 - *9 card_activity_spikes: - - *22 - - *5 - - *6 + - *23 + - *7 + - *8 - *9 card_engagements: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -380,13 +439,13 @@ columns: default_function: collation: comment: - - *5 - - *6 + - *7 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: status - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: doing default_function: @@ -394,16 +453,16 @@ columns: comment: - *9 card_goldnesses: - - *22 - - *5 - - *6 + - *23 + - *7 + - *8 - *9 card_not_nows: - - *22 - - *5 - - *6 + - *23 + - *7 + - *8 - *9 - - &24 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: user_id cast_type: *3 @@ -414,7 +473,7 @@ columns: collation: comment: cards: - - *23 + - *24 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: column_id @@ -425,8 +484,8 @@ columns: default_function: collation: comment: - - *5 - - &25 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - *7 + - &26 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: creator_id cast_type: *3 @@ -439,12 +498,12 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: due_on - cast_type: !ruby/object:ActiveRecord::Type::Date + cast_type: &38 !ruby/object:ActiveRecord::Type::Date precision: scale: limit: timezone: - sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &39 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: date type: :date limit: @@ -455,7 +514,7 @@ columns: default_function: collation: comment: - - *6 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: last_active_at @@ -476,11 +535,11 @@ columns: default_function: collation: comment: - - &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: title - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: @@ -498,22 +557,22 @@ columns: default_function: collation: comment: - - *19 + - *20 closures: - - *22 - - *5 - - *6 - - *9 - - *24 - collection_publications: - *23 - - *5 - - *6 + - *7 + - *8 + - *9 + - *25 + collection_publications: + - *24 + - *7 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: key - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: @@ -524,11 +583,11 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: all_access - cast_type: &32 !ruby/object:ActiveModel::Type::Boolean + cast_type: &33 !ruby/object:ActiveModel::Type::Boolean precision: scale: limit: - sql_type_metadata: &33 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &34 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: boolean type: :boolean limit: @@ -539,49 +598,158 @@ columns: default_function: collation: comment: - - *5 - - *25 - - *6 + - *7 + - *26 + - *8 - *10 - *9 collections_filters: - - *23 - - *19 + - *24 + - *20 columns: - - *23 + - *24 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: color - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: collation: comment: - - *5 - - *6 + - *7 + - *8 - *10 + - *9 + comments: + - *23 + - *7 + - *26 + - *8 + - *9 + conversation_messages: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: - name: position + name: client_message_id + cast_type: *5 + sql_type_metadata: *6 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: conversation_id cast_type: *3 sql_type_metadata: *4 'null': false - default: 0 + default: + default_function: + collation: + comment: + - &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: cost_in_microcents + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - *7 + - *8 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: input_cost_in_microcents + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: input_tokens + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: model_id + cast_type: *5 + sql_type_metadata: *6 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: output_cost_in_microcents + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: output_tokens + cast_type: *11 + sql_type_metadata: *12 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: role + cast_type: *5 + sql_type_metadata: *6 + 'null': false + default: default_function: collation: comment: - *9 - comments: - - *22 - - *5 - - *25 - - *6 + conversations: + - *7 + - *8 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: state + cast_type: *5 + sql_type_metadata: *6 + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: string + cast_type: *5 + sql_type_metadata: *6 + 'null': true + default: + default_function: + collation: + comment: - *9 + - *18 creators_filters: - - *25 - - *19 + - *26 + - *20 entropy_configurations: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -606,30 +774,30 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: container_type - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: collation: comment: - - *5 - - *6 + - *7 + - *8 - *9 events: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: action - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: collation: comment: - - *23 - - *5 - - *25 + - *24 + - *7 + - *26 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: eventable_id @@ -643,22 +811,22 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: eventable_type - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: collation: comment: - - *6 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: particulars - cast_type: &26 !ruby/object:ActiveRecord::Type::Json + cast_type: &27 !ruby/object:ActiveRecord::Type::Json precision: scale: limit: - sql_type_metadata: &27 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &28 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: json type: :json limit: @@ -671,24 +839,24 @@ columns: comment: - *9 filters: - - *5 - - *25 + - *7 + - *26 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: fields - cast_type: *26 - sql_type_metadata: *27 + cast_type: *27 + sql_type_metadata: *28 'null': false default: "{}" default_function: collation: comment: - - *6 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: params_digest - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: @@ -696,8 +864,8 @@ columns: comment: - *9 filters_tags: - - *19 - - &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - *20 + - &36 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: tag_id cast_type: *3 @@ -708,8 +876,8 @@ columns: collation: comment: mentions: - - *5 - - *6 + - *7 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: mentionee_id @@ -743,8 +911,8 @@ columns: - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: source_type - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: @@ -752,7 +920,7 @@ columns: comment: - *9 notification_bundles: - - *5 + - *7 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: ends_at @@ -763,7 +931,7 @@ columns: default_function: collation: comment: - - *6 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: starts_at @@ -785,9 +953,9 @@ columns: collation: comment: - *9 - - *28 + - *18 notifications: - - *5 + - *7 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: creator_id @@ -798,7 +966,7 @@ columns: default_function: collation: comment: - - *6 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: read_at @@ -812,58 +980,74 @@ columns: - *29 - *30 - *9 - - *28 - pins: - - *22 - - *5 - - *6 + - *18 + period_highlights: + - &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: content + cast_type: *15 + sql_type_metadata: *16 + 'null': false + default: + default_function: + collation: + comment: + - *31 + - *7 + - *8 + - *19 - *9 - - *28 + pins: + - *23 + - *7 + - *8 + - *9 + - *18 push_subscriptions: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: auth_key - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: - - *5 + - *7 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: endpoint - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: - - *6 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: p256dh_key - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: - *9 - - &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: user_agent - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: - - *28 + - *18 reactions: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -895,8 +1079,8 @@ columns: default_function: collation: comment: - - *5 - - *6 + - *7 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: reacter_id @@ -912,16 +1096,53 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: version - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 + 'null': false + default: + default_function: + collation: + comment: + search_embeddings_vector_chunks00: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: rowid + cast_type: !ruby/object:ActiveModel::Type::Value + precision: + scale: + limit: + sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: '' + type: + limit: + precision: + scale: + 'null': true + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: vectors + cast_type: !ruby/object:ActiveModel::Type::Binary + precision: + scale: + limit: + sql_type_metadata: !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type: BLOB + type: :binary + limit: + precision: + scale: 'null': false default: default_function: collation: comment: search_queries: - - *5 - - *6 + - *7 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: terms @@ -943,62 +1164,53 @@ columns: collation: comment: - *9 - - *28 + - *18 search_results: - - *5 - - *6 + - *7 + - *8 - *9 sessions: - - *5 - - *6 + - *7 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: ip_address - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: - *9 - - *31 - - *28 + - *32 + - *18 steps: - - *22 + - *23 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: completed - cast_type: *32 - sql_type_metadata: *33 + cast_type: *33 + sql_type_metadata: *34 'null': false default: false default_function: collation: comment: - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: content - cast_type: *15 - sql_type_metadata: *16 - 'null': false - default: - default_function: - collation: - comment: - - *5 - - *6 + - *35 + - *7 + - *8 - *9 taggings: - - *22 - - *5 - - *6 - - *34 + - *23 + - *7 + - *8 + - *36 - *9 tags: - - *5 - - *6 - - *35 + - *7 + - *8 + - *37 - *9 user_settings: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column @@ -1011,49 +1223,74 @@ columns: default_function: collation: comment: - - *5 - - *6 + - *7 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: timezone_name - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: - *9 - - *28 + - *18 + user_weekly_highlights: + - *7 + - *8 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: period_highlights_id + cast_type: *3 + sql_type_metadata: *4 + 'null': false + default: + default_function: + collation: + comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: starts_at + cast_type: *38 + sql_type_metadata: *39 + 'null': false + default: + default_function: + collation: + comment: + - *9 + - *18 users: - - &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &41 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: active - cast_type: *32 - sql_type_metadata: *33 + cast_type: *33 + sql_type_metadata: *34 'null': false default: true default_function: collation: comment: - - *5 + - *7 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: email_address - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: collation: comment: - - *6 + - *8 - *10 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: password_digest - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: @@ -1062,8 +1299,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: role - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: member default_function: @@ -1071,16 +1308,16 @@ columns: comment: - *9 watches: - - *22 - - *5 - - *6 + - *23 + - *7 + - *8 - *9 - - *28 + - *18 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: watching - cast_type: *32 - sql_type_metadata: *33 + cast_type: *33 + sql_type_metadata: *34 'null': false default: true default_function: @@ -1097,7 +1334,7 @@ columns: default_function: collation: comment: - - *5 + - *7 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: first_failure_at @@ -1108,9 +1345,9 @@ columns: default_function: collation: comment: - - *6 + - *8 - *9 - - &36 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &40 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: webhook_id cast_type: *3 @@ -1121,7 +1358,7 @@ columns: collation: comment: webhook_deliveries: - - *5 + - *7 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: event_id @@ -1132,7 +1369,7 @@ columns: default_function: collation: comment: - - *6 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: request @@ -1156,25 +1393,25 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: state - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: collation: comment: - *9 - - *36 + - *40 webhooks: - - *37 - - *23 - - *5 - - *6 + - *41 + - *24 + - *7 + - *8 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: name - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': true default: default_function: @@ -1183,8 +1420,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: signing_secret - cast_type: *7 - sql_type_metadata: *8 + cast_type: *5 + sql_type_metadata: *6 'null': false default: default_function: @@ -1213,11 +1450,13 @@ columns: comment: primary_keys: accesses: id + account_join_codes: id accounts: id action_text_rich_texts: id active_storage_attachments: id active_storage_blobs: id active_storage_variant_records: id + ai_quotas: id ar_internal_metadata: key assignees_filters: assigners_filters: @@ -1234,6 +1473,8 @@ primary_keys: collections_filters: columns: id comments: id + conversation_messages: id + conversations: id creators_filters: entropy_configurations: id events: id @@ -1242,10 +1483,12 @@ primary_keys: mentions: id notification_bundles: id notifications: id + period_highlights: id pins: id push_subscriptions: id reactions: id schema_migrations: version + search_embeddings_vector_chunks00: rowid search_queries: id search_results: id sessions: id @@ -1253,6 +1496,7 @@ primary_keys: taggings: id tags: id user_settings: id + user_weekly_highlights: id users: id watches: id webhook_delinquency_trackers: id @@ -1260,11 +1504,13 @@ primary_keys: webhooks: id data_sources: accesses: true + account_join_codes: true accounts: true action_text_rich_texts: true active_storage_attachments: true active_storage_blobs: true active_storage_variant_records: true + ai_quotas: true ar_internal_metadata: true assignees_filters: true assigners_filters: true @@ -1281,6 +1527,8 @@ data_sources: collections_filters: true columns: true comments: true + conversation_messages: true + conversations: true creators_filters: true entropy_configurations: true events: true @@ -1289,10 +1537,12 @@ data_sources: mentions: true notification_bundles: true notifications: true + period_highlights: true pins: true push_subscriptions: true reactions: true schema_migrations: true + search_embeddings_vector_chunks00: true search_queries: true search_results: true sessions: true @@ -1300,6 +1550,7 @@ data_sources: taggings: true tags: true user_settings: true + user_weekly_highlights: true users: true watches: true webhook_delinquency_trackers: true @@ -1372,6 +1623,23 @@ indexes: nulls_not_distinct: comment: valid: true + account_join_codes: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: account_join_codes + name: index_account_join_codes_on_code + unique: true + columns: + - code + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true accounts: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: accounts @@ -1495,6 +1763,39 @@ indexes: nulls_not_distinct: comment: valid: true + ai_quotas: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: ai_quotas + name: index_ai_quotas_on_reset_at + unique: false + columns: + - reset_at + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: ai_quotas + name: index_ai_quotas_on_user_id + unique: false + columns: + - user_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true ar_internal_metadata: [] assignees_filters: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition @@ -1929,13 +2230,13 @@ indexes: nulls_not_distinct: comment: valid: true + comments: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: columns - name: index_columns_on_collection_id_and_position + table: comments + name: index_comments_on_card_id unique: false columns: - - collection_id - - position + - card_id lengths: {} orders: {} opclasses: {} @@ -1946,13 +2247,30 @@ indexes: nulls_not_distinct: comment: valid: true - comments: + conversation_messages: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition - table: comments - name: index_comments_on_card_id + table: conversation_messages + name: index_conversation_messages_on_conversation_id unique: false columns: - - card_id + - conversation_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + conversations: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: conversations + name: index_conversations_on_user_id + unique: true + columns: + - user_id lengths: {} orders: {} opclasses: {} @@ -2340,6 +2658,23 @@ indexes: nulls_not_distinct: comment: valid: true + period_highlights: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: period_highlights + name: index_period_highlights_on_key_and_starts_at_and_duration + unique: true + columns: + - key + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true pins: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: pins @@ -2508,6 +2843,7 @@ indexes: comment: valid: true schema_migrations: [] + search_embeddings_vector_chunks00: [] search_queries: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: search_queries @@ -2696,6 +3032,56 @@ indexes: nulls_not_distinct: comment: valid: true + user_weekly_highlights: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: user_weekly_highlights + name: index_user_weekly_highlights_on_period_highlights_id + unique: false + columns: + - period_highlights_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: user_weekly_highlights + name: index_user_weekly_highlights_on_user_id + unique: false + columns: + - user_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: user_weekly_highlights + name: index_user_weekly_highlights_on_user_id_and_starts_at + unique: true + columns: + - user_id + - starts_at + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true users: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: users @@ -2845,4 +3231,4 @@ indexes: nulls_not_distinct: comment: valid: true -version: 20251029161222 +version: 20251016153034 diff --git a/db/seeds.rb b/db/seeds.rb index 6311e6969..bf933467c 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,5 +1,5 @@ raise "Seeding is just for development" unless Rails.env.development? -IdentityProvider.backend = IdentityProvider::Simple +IdentityProvider.backend = IdentityProvider::LocalBackend require "active_support/testing/time_helpers" include ActiveSupport::Testing::TimeHelpers diff --git a/db/untenanted_schema.rb b/db/untenanted_schema.rb index d2937897b..7137cf2c8 100644 --- a/db/untenanted_schema.rb +++ b/db/untenanted_schema.rb @@ -10,24 +10,35 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_09_24_190729) do +ActiveRecord::Schema[8.2].define(version: 2025_10_15_173452) do create_table "identities", force: :cascade do |t| t.datetime "created_at", null: false + t.string "email_address" t.datetime "updated_at", null: false + t.index ["email_address"], name: "index_identities_on_email_address", unique: true + end + + create_table "magic_links", force: :cascade do |t| + t.string "code", null: false + t.datetime "created_at", null: false + t.datetime "expires_at", null: false + t.integer "identity_id" + t.datetime "updated_at", null: false + t.index ["code"], name: "index_magic_links_on_code", unique: true + t.index ["expires_at"], name: "index_magic_links_on_expires_at" + t.index ["identity_id"], name: "index_magic_links_on_identity_id" end create_table "memberships", force: :cascade do |t| t.string "account_name", null: false t.datetime "created_at", null: false - t.string "email_address", null: false t.integer "identity_id", null: false + t.string "tenant", null: false t.datetime "updated_at", null: false - t.integer "user_id", null: false - t.string "user_tenant", null: false - t.index ["email_address"], name: "index_memberships_on_email_address" t.index ["identity_id"], name: "index_memberships_on_identity_id" - t.index ["user_tenant", "user_id"], name: "index_memberships_on_user_tenant_and_user_id" + t.index ["tenant"], name: "index_memberships_on_user_tenant_and_user_id" end + add_foreign_key "magic_links", "identities" add_foreign_key "memberships", "identities" end diff --git a/gems/fizzy-saas/app/controllers/identities_controller.rb b/gems/fizzy-saas/app/controllers/identities_controller.rb index 2520ccbce..3459eabbc 100644 --- a/gems/fizzy-saas/app/controllers/identities_controller.rb +++ b/gems/fizzy-saas/app/controllers/identities_controller.rb @@ -2,22 +2,22 @@ class IdentitiesController < ApplicationController include InternalApi def link - IdentityProvider::Simple.link(email_address: params[:email_address], to: params[:to]) + IdentityProvider::LocalBackend.link(email_address: params[:email_address], to: params[:to]) head :ok end def unlink - IdentityProvider::Simple.unlink(email_address: params[:email_address], from: params[:from]) + IdentityProvider::LocalBackend.unlink(email_address: params[:email_address], from: params[:from]) head :ok end def change_email_address - IdentityProvider::Simple.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant]) + IdentityProvider::LocalBackend.change_email_address(from: params[:from], to: params[:to], tenant: params[:tenant]) head :ok end def send_magic_link - code = IdentityProvider::Simple.send_magic_link(params[:email_address]) + code = IdentityProvider::LocalBackend.send_magic_link(params[:email_address]) render json: { code: 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 c7bf66a3e..c33433a79 100644 --- a/gems/fizzy-saas/app/controllers/signups/completions_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups/completions_controller.rb @@ -2,7 +2,7 @@ class Signups::CompletionsController < ApplicationController include Restricted require_untenanted_access - before_action :require_identity + require_identified_access def new @signup = Signup.new diff --git a/gems/fizzy-saas/app/controllers/signups_controller.rb b/gems/fizzy-saas/app/controllers/signups_controller.rb index f837345fb..acfa52dc8 100644 --- a/gems/fizzy-saas/app/controllers/signups_controller.rb +++ b/gems/fizzy-saas/app/controllers/signups_controller.rb @@ -2,6 +2,7 @@ class SignupsController < ApplicationController include Restricted require_untenanted_access + require_unidentified_access rate_limit only: :create, name: "short-term", to: 5, within: 3.minutes, with: -> { redirect_to saas.new_signup_path, alert: "Try again later." } diff --git a/gems/fizzy-saas/app/models/identity_provider/remote_backend.rb b/gems/fizzy-saas/app/models/identity_provider/remote_backend.rb new file mode 100644 index 000000000..839c2919c --- /dev/null +++ b/gems/fizzy-saas/app/models/identity_provider/remote_backend.rb @@ -0,0 +1,48 @@ +module IdentityProvider::RemoteBackend + extend self + include Fizzy::Saas::Engine.routes.url_helpers + + delegate :consume_magic_link, :token_for, :resolve_token, :verify_token, :tenants_for, to: IdentityProvider::LocalBackend + + def default_url_options + Rails.application.config.action_mailer.default_url_options + end + + def url_options + default_url_options.merge(script_name: nil) + end + + def link(email_address:, to:) + response = InternalApiClient.new(link_identity_url).post({ email_address: email_address, to: to }) + + unless response.success? + raise IdentityProvider::Error, "Failed to link identity: #{response.error || response.code}" + end + end + + def unlink(email_address:, from:) + response = InternalApiClient.new(unlink_identity_url).post({ email_address: email_address, from: from }) + + unless response.success? + raise IdentityProvider::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).post({ from: from, to: to, tenant: tenant }) + + unless response.success? + raise IdentityProvider::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).post({ email_address: email_address }) + + if response.success? + response.parsed_body["code"] + else + raise IdentityProvider::Error, "Failed to send magic link: #{response.error || response.code}" + end + end +end diff --git a/gems/fizzy-saas/app/models/identity_provider/saas.rb b/gems/fizzy-saas/app/models/identity_provider/saas.rb deleted file mode 100644 index d3b74fb4c..000000000 --- a/gems/fizzy-saas/app/models/identity_provider/saas.rb +++ /dev/null @@ -1,83 +0,0 @@ -module IdentityProvider::Saas - class Error < StandardError; end - - extend self - include 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.merge(script_name: nil) - end - - def link(email_address:, to:) - response = InternalApiClient.new(link_identity_url).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).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).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).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 - IdentityProvider::Token.new(identity.signed_id, identity.updated_at) - else - nil - end - end -end diff --git a/gems/fizzy-saas/app/models/internal_api_client.rb b/gems/fizzy-saas/app/models/internal_api_client.rb index 093fa75db..a07c74d79 100644 --- a/gems/fizzy-saas/app/models/internal_api_client.rb +++ b/gems/fizzy-saas/app/models/internal_api_client.rb @@ -3,7 +3,7 @@ class InternalApiClient TOKEN_KEY = "internal_api_client_token" USER_AGENT = "fizzy/1.0.0 InternalApiClient" SIGNATURE_HEADER = "X-Internal-Signature" - DEFAULT_TIMEOUT = 5.seconds + DEFAULT_TIMEOUT = 60.seconds class Error < StandardError; end class TimeoutError < Error; end diff --git a/gems/fizzy-saas/lib/fizzy/saas/engine.rb b/gems/fizzy-saas/lib/fizzy/saas/engine.rb index db71a4b0b..b9f3f71ac 100644 --- a/gems/fizzy-saas/lib/fizzy/saas/engine.rb +++ b/gems/fizzy-saas/lib/fizzy/saas/engine.rb @@ -5,7 +5,7 @@ module Fizzy Queenbee.host_app = Fizzy config.to_prepare do - IdentityProvider.backend = IdentityProvider::Saas + IdentityProvider.backend = IdentityProvider::RemoteBackend Queenbee::Subscription.short_names = Subscription::SHORT_NAMES Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token) 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 f750e389c..f21f3666a 100644 --- a/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb +++ b/gems/fizzy-saas/test/controllers/signups/completions_controller_test.rb @@ -33,7 +33,8 @@ class Signups::CompletionsControllerTest < ActionDispatch::IntegrationTest }, headers: http_basic_auth_headers end - assert_redirected_to session_login_menu_path(go_to: Membership.last.tenant), "Successful completion should redirect to login menu" + tenant = Membership.last.tenant + assert_redirected_to new_session_start_url(script_name: "/#{tenant}"), "Successful completion should redirect to start session in new tenant" post saas.signup_completion_path, params: { signup: { diff --git a/gems/fizzy-saas/test/models/identity_provider/saas_test.rb b/gems/fizzy-saas/test/models/identity_provider/remote_backend_test.rb similarity index 65% rename from gems/fizzy-saas/test/models/identity_provider/saas_test.rb rename to gems/fizzy-saas/test/models/identity_provider/remote_backend_test.rb index d4cd6f22f..6cb721af2 100644 --- a/gems/fizzy-saas/test/models/identity_provider/saas_test.rb +++ b/gems/fizzy-saas/test/models/identity_provider/remote_backend_test.rb @@ -1,32 +1,32 @@ require "test_helper" -class IdentityProvider::SaasTest < ActiveSupport::TestCase +class IdentityProvider::RemoteBackendTest < ActiveSupport::TestCase setup do WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/link}) .to_return do |request| body = JSON.parse(request.body) - IdentityProvider::Simple.link(email_address: body["email_address"], to: body["to"]) + IdentityProvider::LocalBackend.link(email_address: body["email_address"], to: body["to"]) { status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } } end WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/unlink}) .to_return do |request| body = JSON.parse(request.body) - IdentityProvider::Simple.unlink(email_address: body["email_address"], from: body["from"]) + IdentityProvider::LocalBackend.unlink(email_address: body["email_address"], from: body["from"]) { status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } } end WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/change_email_address}) .to_return do |request| body = JSON.parse(request.body) - IdentityProvider::Simple.change_email_address(from: body["from"], to: body["to"], tenant: body["tenant"]) + IdentityProvider::LocalBackend.change_email_address(from: body["from"], to: body["to"], tenant: body["tenant"]) { status: 200, body: {}.to_json, headers: { "Content-Type" => "application/json" } } end WebMock.stub_request(:post, %r{http://example\.com(:80)?/identities/send_magic_link}) .to_return do |request| body = JSON.parse(request.body) - code = IdentityProvider::Simple.send_magic_link(body["email_address"]) + code = IdentityProvider::LocalBackend.send_magic_link(body["email_address"]) { status: 200, body: { code: code }.to_json, headers: { "Content-Type" => "application/json" } } end end @@ -37,7 +37,7 @@ class IdentityProvider::SaasTest < ActiveSupport::TestCase assert_difference -> { Identity.count }, 1 do assert_difference -> { Membership.count }, 1 do - IdentityProvider::Saas.link(email_address: new_email, to: tenant) + IdentityProvider::RemoteBackend.link(email_address: new_email, to: tenant) end end @@ -50,7 +50,7 @@ class IdentityProvider::SaasTest < ActiveSupport::TestCase tenant = ApplicationRecord.current_tenant assert_difference -> { Membership.count }, -1 do - IdentityProvider::Saas.unlink(email_address: identity.email_address, from: tenant) + IdentityProvider::RemoteBackend.unlink(email_address: identity.email_address, from: tenant) end end @@ -59,7 +59,7 @@ class IdentityProvider::SaasTest < ActiveSupport::TestCase tenant = ApplicationRecord.current_tenant new_email = "newemail@example.com" - IdentityProvider::Saas.change_email_address(from: identity.email_address, to: new_email, tenant: tenant) + IdentityProvider::RemoteBackend.change_email_address(from: identity.email_address, to: new_email, tenant: tenant) new_identity = Identity.find_by(email_address: new_email) assert new_identity.memberships.exists?(tenant: tenant) @@ -69,11 +69,11 @@ class IdentityProvider::SaasTest < ActiveSupport::TestCase identity = identities(:kevin) assert_difference -> { MagicLink.count }, 1 do - code = IdentityProvider::Saas.send_magic_link(identity.email_address) + code = IdentityProvider::RemoteBackend.send_magic_link(identity.email_address) assert_equal MagicLink::CODE_LENGTH, code.length end - code = IdentityProvider::Saas.send_magic_link("nonexistent@example.com") + code = IdentityProvider::RemoteBackend.send_magic_link("nonexistent@example.com") assert_nil code end @@ -81,21 +81,21 @@ class IdentityProvider::SaasTest < ActiveSupport::TestCase identity = identities(:kevin) magic_link = identity.send_magic_link - token = IdentityProvider::Saas.consume_magic_link(magic_link.code) + token = IdentityProvider::RemoteBackend.consume_magic_link(magic_link.code) assert_equal identity.signed_id, token.id assert_not MagicLink.exists?(magic_link.id) - token = IdentityProvider::Saas.consume_magic_link("invalid") + token = IdentityProvider::RemoteBackend.consume_magic_link("invalid") assert_nil token end test "token_for" do identity = identities(:kevin) - token = IdentityProvider::Saas.token_for(identity.email_address) + token = IdentityProvider::RemoteBackend.token_for(identity.email_address) assert_equal identity.signed_id, token.id - token = IdentityProvider::Saas.token_for("nonexistent@example.com") + token = IdentityProvider::RemoteBackend.token_for("nonexistent@example.com") assert_nil token end @@ -103,10 +103,10 @@ class IdentityProvider::SaasTest < ActiveSupport::TestCase identity = identities(:kevin) token = { "id" => identity.signed_id } - email = IdentityProvider::Saas.resolve_token(token) + email = IdentityProvider::RemoteBackend.resolve_token(token) assert_equal identity.email_address, email - email = IdentityProvider::Saas.resolve_token({ "id" => "invalid" }) + email = IdentityProvider::RemoteBackend.resolve_token({ "id" => "invalid" }) assert_nil email end @@ -114,10 +114,10 @@ class IdentityProvider::SaasTest < ActiveSupport::TestCase identity = identities(:kevin) token = { "id" => identity.signed_id } - result = IdentityProvider::Saas.verify_token(token) + result = IdentityProvider::RemoteBackend.verify_token(token) assert_equal identity.signed_id, result.id - result = IdentityProvider::Saas.verify_token({ "id" => "invalid" }) + result = IdentityProvider::RemoteBackend.verify_token({ "id" => "invalid" }) assert_nil result end @@ -125,7 +125,7 @@ class IdentityProvider::SaasTest < ActiveSupport::TestCase identity = identities(:kevin) token = { "id" => identity.signed_id } - tenants = IdentityProvider::Saas.tenants_for(token) + tenants = IdentityProvider::RemoteBackend.tenants_for(token) assert tenants.all? { |t| t.is_a?(IdentityProvider::Tenant) } assert_includes tenants.map(&:id), identity.memberships.first.tenant end diff --git a/script/create-identities.rb b/script/create-identities.rb new file mode 100644 index 000000000..2411f1b0c --- /dev/null +++ b/script/create-identities.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" + +# Loop through all tenants and create identities for users +ApplicationRecord.with_each_tenant do |tenant| + puts "Processing tenant: #{tenant}" + + User.find_each do |user| + next if user.system? + + # Use IdentityProvider to link the user's email to this tenant + # This will find_or_create the identity and link it to the tenant + identity = IdentityProvider.link(email_address: user.email_address, to: tenant) + + puts " ✅ Linked identity for user #{user.id} (#{user.email_address}) to tenant '#{tenant}'" + end + + puts " Completed tenant: #{tenant}" + puts +end + +puts "All identities created successfully!" diff --git a/test/models/identity_provider/simple_test.rb b/test/models/identity_provider/local_backend_test.rb similarity index 69% rename from test/models/identity_provider/simple_test.rb rename to test/models/identity_provider/local_backend_test.rb index 4a246a207..e760cefd7 100644 --- a/test/models/identity_provider/simple_test.rb +++ b/test/models/identity_provider/local_backend_test.rb @@ -1,13 +1,13 @@ require "test_helper" -class IdentityProvider::SimpleTest < ActiveSupport::TestCase +class IdentityProvider::LocalBackendTest < ActiveSupport::TestCase test "link" do new_email = "newuser@example.com" tenant = ApplicationRecord.current_tenant assert_difference -> { Identity.count }, 1 do assert_difference -> { Membership.count }, 1 do - IdentityProvider::Simple.link(email_address: new_email, to: tenant) + IdentityProvider::LocalBackend.link(email_address: new_email, to: tenant) end end @@ -20,7 +20,7 @@ class IdentityProvider::SimpleTest < ActiveSupport::TestCase tenant = ApplicationRecord.current_tenant assert_difference -> { Membership.count }, -1 do - IdentityProvider::Simple.unlink(email_address: identity.email_address, from: tenant) + IdentityProvider::LocalBackend.unlink(email_address: identity.email_address, from: tenant) end assert_not identity.reload.memberships.exists?(tenant: tenant), "removes membership from tenant" @@ -31,7 +31,7 @@ class IdentityProvider::SimpleTest < ActiveSupport::TestCase tenant = ApplicationRecord.current_tenant new_email = "newemail@example.com" - IdentityProvider::Simple.change_email_address(from: identity.email_address, to: new_email, tenant: tenant) + IdentityProvider::LocalBackend.change_email_address(from: identity.email_address, to: new_email, tenant: tenant) assert_not identity.reload.memberships.exists?(tenant: tenant), "removes old identity membership" new_identity = Identity.find_by(email_address: new_email) @@ -42,11 +42,11 @@ class IdentityProvider::SimpleTest < ActiveSupport::TestCase identity = identities(:kevin) assert_difference -> { MagicLink.count }, 1 do - code = IdentityProvider::Simple.send_magic_link(identity.email_address) + code = IdentityProvider::LocalBackend.send_magic_link(identity.email_address) assert_equal MagicLink::CODE_LENGTH, code.length, "returns code of correct length" end - code = IdentityProvider::Simple.send_magic_link("nonexistent@example.com") + code = IdentityProvider::LocalBackend.send_magic_link("nonexistent@example.com") assert_nil code, "returns nil for non-existent email" end @@ -54,21 +54,21 @@ class IdentityProvider::SimpleTest < ActiveSupport::TestCase identity = identities(:kevin) magic_link = identity.send_magic_link - token = IdentityProvider::Simple.consume_magic_link(magic_link.code) + token = IdentityProvider::LocalBackend.consume_magic_link(magic_link.code) assert_equal identity.signed_id, token.id, "returns token for valid code" assert_not MagicLink.exists?(magic_link.id), "deletes magic link after consumption" - token = IdentityProvider::Simple.consume_magic_link("invalid") + token = IdentityProvider::LocalBackend.consume_magic_link("invalid") assert_nil token, "returns nil for invalid code" end test "token_for" do identity = identities(:kevin) - token = IdentityProvider::Simple.token_for(identity.email_address) + token = IdentityProvider::LocalBackend.token_for(identity.email_address) assert_equal identity.signed_id, token.id, "returns token for existing email" - token = IdentityProvider::Simple.token_for("nonexistent@example.com") + token = IdentityProvider::LocalBackend.token_for("nonexistent@example.com") assert_nil token, "returns nil for non-existent email" end @@ -76,10 +76,10 @@ class IdentityProvider::SimpleTest < ActiveSupport::TestCase identity = identities(:kevin) token = { "id" => identity.signed_id } - email = IdentityProvider::Simple.resolve_token(token) + email = IdentityProvider::LocalBackend.resolve_token(token) assert_equal identity.email_address, email, "returns email address from valid token" - email = IdentityProvider::Simple.resolve_token({ "id" => "invalid" }) + email = IdentityProvider::LocalBackend.resolve_token({ "id" => "invalid" }) assert_nil email, "returns nil for invalid token" end @@ -87,10 +87,10 @@ class IdentityProvider::SimpleTest < ActiveSupport::TestCase identity = identities(:kevin) token = { "id" => identity.signed_id } - result = IdentityProvider::Simple.verify_token(token) + result = IdentityProvider::LocalBackend.verify_token(token) assert_equal identity.signed_id, result.id, "returns token from valid token hash" - result = IdentityProvider::Simple.verify_token({ "id" => "invalid" }) + result = IdentityProvider::LocalBackend.verify_token({ "id" => "invalid" }) assert_nil result, "returns nil for invalid token" end @@ -98,7 +98,7 @@ class IdentityProvider::SimpleTest < ActiveSupport::TestCase identity = identities(:kevin) token = { "id" => identity.signed_id } - tenants = IdentityProvider::Simple.tenants_for(token) + tenants = IdentityProvider::LocalBackend.tenants_for(token) assert tenants.all? { |t| t.is_a?(IdentityProvider::Tenant) }, "returns Tenant objects" assert_includes tenants.map(&:id), identity.memberships.first.tenant, "includes identity's tenant" end diff --git a/test/models/user/email_address_changeable_test.rb b/test/models/user/email_address_changeable_test.rb index 3b372c336..744c30e57 100644 --- a/test/models/user/email_address_changeable_test.rb +++ b/test/models/user/email_address_changeable_test.rb @@ -1,14 +1,26 @@ require "test_helper" class User::EmailAddressChangeableTest < ActiveSupport::TestCase - test "generate_email_address_change_token" do + test "send_email_address_change_confirmation" do user = users(:david) new_email_address = "new@example.com" - token = user.generate_email_address_change_token(to: new_email_address) + assert_enqueued_jobs 1, only: ActionMailer::MailDeliveryJob do + user.send_email_address_change_confirmation(new_email_address) + end + + assert_not_equal new_email_address, user.reload.email_address + end + + test "generate_email_address_change_token" do + user = users(:david) + old_email = user.email_address + new_email = "david.new@37signals.com" + + token = user.generate_email_address_change_token(to: new_email, expires_in: 30.minutes) assert_kind_of String, token - assert_not_equal new_email_address, user.reload.email_address + assert token.present? end test "change_email_address_using_token" do @@ -16,7 +28,7 @@ class User::EmailAddressChangeableTest < ActiveSupport::TestCase old_email = user.email_address new_email = "david.new@37signals.com" - token = user.generate_email_address_change_token(from: old_email, to: new_email) + token = user.generate_email_address_change_token(to: new_email, expires_in: 30.minutes) assert_equal old_email, user.reload.email_address diff --git a/test/models/user/identifiable_test.rb b/test/models/user/identifiable_test.rb index b842632fd..28d18624b 100644 --- a/test/models/user/identifiable_test.rb +++ b/test/models/user/identifiable_test.rb @@ -12,20 +12,6 @@ class User::IdentifiableTest < ActiveSupport::TestCase assert_equal "newuser@example.com", user.identity.email_address end - test "update email address" do - user = users(:david) - old_email = user.email_address - new_email = "david.updated@example.com" - - assert_not Identity.find_by(email_address: new_email) - - user.update!(email_address: new_email) - - new_identity = Identity.find_by(email_address: new_email) - assert new_identity.present? - assert new_identity.memberships.exists?(tenant: user.tenant) - end - test "destroy" do user = User.create!(name: "Bob") diff --git a/test/system/smoke_test.rb b/test/system/smoke_test.rb index 51cb42c45..16e6ded73 100644 --- a/test/system/smoke_test.rb +++ b/test/system/smoke_test.rb @@ -43,7 +43,37 @@ class SmokeTest < ApplicationSystemTestCase private def sign_in_as(user) - visit session_transfer_url(user.transfer_id) + # Visit app first to establish domain for cookies + visit root_url + + identity = Identity.find_or_create_by!(email_address: user.email_address) + identity.link_to(user.tenant) + + session = user.sessions.create!(user_agent: "Test", ip_address: "127.0.0.1") + + secret_key_base = Rails.application.secret_key_base + key_generator = ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000) + secret = key_generator.generate_key("signed cookie") + verifier = ActiveSupport::MessageVerifier.new(secret, serializer: JSON) + + identity_token = IdentityProvider::Token.new(identity.signed_id, identity.updated_at) + signed_identity_token = verifier.generate(identity_token.to_h) + signed_session_token = verifier.generate(session.signed_id) + + # Set cookies in browser + Capybara.current_session.driver.browser.manage.add_cookie( + name: "identity_token", + value: signed_identity_token, + path: "/" + ) + + Capybara.current_session.driver.browser.manage.add_cookie( + name: "session_token", + value: signed_session_token, + path: "/#{user.tenant}" + ) + + visit root_url assert_selector "h1", text: "Activity" end diff --git a/test/test_helper.rb b/test/test_helper.rb index 63e6262fa..912154ef4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -66,4 +66,4 @@ unless Rails.application.config.x.oss_config load File.expand_path("../gems/fizzy-saas/test/test_helper.rb", __dir__) end -IdentityProvider.backend = IdentityProvider::Simple +IdentityProvider.backend = IdentityProvider::LocalBackend