Merge pull request #1277 from basecamp/flavorjones/identity-and-membership
Introduce an "Identity" model to ease login
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
.icon--lifebuoy { --svg: url("lifebuoy.svg "); }
|
||||
.icon--lock { --svg: url("lock.svg "); }
|
||||
.icon--logout { --svg: url("logout.svg "); }
|
||||
.icon--logo-color { --svg: url("logo-color.svg "); mask-size: contain; }
|
||||
.icon--menu { --svg: url("menu.svg "); }
|
||||
.icon--menu-dots-horizontal { --svg: url("menu-dots-horizontal.svg "); }
|
||||
.icon--menu-dots-vertical { --svg: url("menu-dots-vertical.svg "); }
|
||||
|
||||
@@ -38,6 +38,7 @@ module Authentication
|
||||
|
||||
def require_tenant
|
||||
unless ApplicationRecord.current_tenant.present?
|
||||
set_current_identity_token
|
||||
render "sessions/login_menu"
|
||||
end
|
||||
end
|
||||
@@ -77,14 +78,27 @@ 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)
|
||||
token_value = cookies.signed[:identity_token]
|
||||
token_identity = Identity.find_signed(token_value["id"]) if token_value.present?
|
||||
identity = user.set_identity(token_identity)
|
||||
cookies.signed.permanent[:identity_token] = { value: { "id" => identity.signed_id, "updated_at" => identity.updated_at }, httponly: true, same_site: :lax }
|
||||
end
|
||||
|
||||
def set_current_identity_token
|
||||
Current.identity_token = Identity::Mock.new(**cookies.signed[:identity_token])
|
||||
end
|
||||
|
||||
def set_current_session(session)
|
||||
logger.struct " Authorized User##{session.user.id}", authentication: { user: { id: session.user.id } }
|
||||
Current.session = session
|
||||
set_current_identity_token
|
||||
cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax, path: Account.sole.slug }
|
||||
end
|
||||
|
||||
|
||||
@@ -15,10 +15,11 @@ module FiltersHelper
|
||||
user_filtering.selected_collection_titles.collect { tag.strong it }.to_sentence.html_safe
|
||||
end
|
||||
|
||||
def filter_place_menu_item(path, label, icon)
|
||||
def filter_place_menu_item(path, label, icon, new_window: false)
|
||||
link_to_params = new_window ? { target: "_blank" } : {}
|
||||
tag.li class: "popup__item", id: "filter-place-#{label.parameterize}", data: { filter_target: "item", navigable_list_target: "item" } do
|
||||
concat icon_tag(icon, class: "popup__icon")
|
||||
concat(link_to(path, class: "popup__btn btn") do
|
||||
concat(link_to(path, link_to_params.merge(class: "popup__btn btn")) do
|
||||
concat tag.span(label, class: "overflow-ellipsis")
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :session
|
||||
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
|
||||
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer, :identity_token
|
||||
|
||||
delegate :user, to: :session, allow_nil: true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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)
|
||||
|
||||
has_many :memberships, dependent: :destroy
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class Membership < UntenantedRecord
|
||||
belongs_to :identity, touch: true
|
||||
|
||||
# I want this to be `belongs_to :user`, but ActiveRecord::Tenanted doesn't yet support
|
||||
# associations from untenanted to untenanted models.
|
||||
#
|
||||
# See https://github.com/basecamp/activerecord-tenanted/issues/201
|
||||
#
|
||||
# In the meantime, when creating a Membership, specify both `user_id` and `user_tenant` attributes.
|
||||
def user
|
||||
User.with_tenant(user_tenant) { User.find_by(id: user_id) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,4 @@
|
||||
class UntenantedRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
connects_to database: { writing: :untenanted }
|
||||
end
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Highlights,
|
||||
Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable, Watcher
|
||||
Identifiable, Mentionable, Named, Notifiable, Role, Searcher, Staff, Transferable, Watcher
|
||||
include Timelined # Depends on Accessor
|
||||
|
||||
has_one_attached :avatar
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module User::Identifiable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_one :membership, ->(user) { where(user_tenant: user.tenant) }
|
||||
has_one :identity, through: :membership
|
||||
end
|
||||
|
||||
def set_identity(token_identity)
|
||||
if token_identity.present?
|
||||
if identity.nil?
|
||||
token_identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name)
|
||||
elsif identity != token_identity
|
||||
Identity.transaction do
|
||||
identity.memberships.update_all(identity_id: token_identity.id)
|
||||
identity.destroy
|
||||
end
|
||||
end
|
||||
token_identity
|
||||
elsif identity.present?
|
||||
identity
|
||||
else
|
||||
Identity.create!.tap do |identity|
|
||||
identity.memberships.create!(user_id: id, user_tenant: tenant, email_address: email_address, account_name: Account.sole.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,5 +3,5 @@
|
||||
action: "click->dialog#open:stop keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click",
|
||||
controller: "hotkey" } do %>
|
||||
<%= inline_svg "fizzy-logo" %>
|
||||
<strong class="txt-medium overflow-ellipsis margin-none">Fizzy</strong>
|
||||
<strong class="txt-medium overflow-ellipsis margin-none"><%= Account.sole.name %></strong>
|
||||
<% end %>
|
||||
|
||||
@@ -3,4 +3,9 @@
|
||||
<%= filter_place_menu_item user_path(Current.user), "My Profile", "person" %>
|
||||
<%= filter_place_menu_item notifications_path, "Notifications", "bell" %>
|
||||
<%= 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 %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
<% cache ApplicationRecord.tenants do %>
|
||||
<h2>We're migrating Fizzy authentication</h2>
|
||||
<p>
|
||||
While we migrate Fizzy away from Launchpad and 37id, please login at one of the following URLs:
|
||||
<% @page_title = "Choose Account" %>
|
||||
|
||||
<ul style="list-style-type: none;">
|
||||
<% ApplicationRecord.with_each_tenant do |tenant| %>
|
||||
<li><%= link_to Account.sole.name, root_url(script_name: Account.sole.slug) %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
Mike should have sent you your temporary password by now. If you haven't received it, please ping him!
|
||||
</p>
|
||||
<% end %>
|
||||
<div class="panel shadow center margin-block-double" style="--panel-size: 55ch;">
|
||||
<h1 class="txt-xx-large margin-block-end-double">Fizzy</h1>
|
||||
|
||||
<% cache [ Current.user, Current.identity_token ] do %>
|
||||
<% identity = Identity.find_signed(Current.identity_token.id) %>
|
||||
<% memberships = identity&.memberships %>
|
||||
<% if memberships.present? %>
|
||||
<h2 class="txt-large margin-block-end">Your Fizzy Accounts</h2>
|
||||
<ul class="flex flex-column gap txt-large" style="list-style-type: none; padding: 0;">
|
||||
<% memberships.each do |membership| %>
|
||||
<li>
|
||||
<%= link_to membership.account_name, root_url(script_name: "/#{membership.user_tenant}"), class: "btn btn--reversed center" %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="txt-large txt-align-center txt-subtle">You don't have any existing Fizzy accounts.</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,3 @@
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% content_for :footer do %>
|
||||
<div class="txt-align-center center margin-block-double txt-subtle">Fizzy™ version 0</div>
|
||||
<% end %>
|
||||
|
||||
+15
-7
@@ -16,6 +16,10 @@ development:
|
||||
<<: *default
|
||||
database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3
|
||||
tenanted: true
|
||||
untenanted:
|
||||
<<: *default
|
||||
database: storage/untenanted/development.sqlite3
|
||||
migrations_paths: db/untenanted_migrate
|
||||
cable:
|
||||
<<: *default
|
||||
database: storage/development_cable.sqlite3
|
||||
@@ -33,9 +37,14 @@ development:
|
||||
# re-generated from your development database when you run "rake".
|
||||
# Do not set this db to the same as development or production.
|
||||
test:
|
||||
<<: *default
|
||||
database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3
|
||||
tenanted: true
|
||||
primary:
|
||||
<<: *default
|
||||
database: storage/tenants/<%= Rails.env %>/%{tenant}/db/main.sqlite3
|
||||
tenanted: true
|
||||
untenanted:
|
||||
<<: *default
|
||||
database: storage/untenanted/test.sqlite3
|
||||
migrations_paths: db/untenanted_migrate
|
||||
|
||||
production: &production
|
||||
primary:
|
||||
@@ -44,11 +53,10 @@ production: &production
|
||||
tenanted: true
|
||||
extensions:
|
||||
- <%= SqliteVec.loadable_path %>
|
||||
primary_original:
|
||||
untenanted:
|
||||
<<: *default
|
||||
database: storage/production.sqlite3
|
||||
replica: true
|
||||
readonly: true
|
||||
database: storage/untenanted/production.sqlite3
|
||||
migrations_paths: db/untenanted_migrate
|
||||
cable:
|
||||
<<: *default
|
||||
database: storage/production_cable.sqlite3
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class CreateIdentities < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :identities do |t|
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class CreateMemberships < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :memberships do |t|
|
||||
t.string :email_address, null: false
|
||||
t.references :identity, null: false, foreign_key: true
|
||||
t.references :user, null: false, foreign_key: false, index: false
|
||||
t.string :user_tenant, null: false
|
||||
t.string :account_name, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :memberships, :email_address
|
||||
add_index :memberships, [ :user_tenant, :user_id ]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# This file is auto-generated from the current state of the database. Instead
|
||||
# of editing this file, please use the migrations feature of Active Record to
|
||||
# incrementally modify your database, and then regenerate this schema definition.
|
||||
#
|
||||
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||
# be faster and is potentially less error prone than running all of your
|
||||
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||
# migrations use external dependencies or application code.
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_09_24_190729) do
|
||||
create_table "identities", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
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.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"
|
||||
end
|
||||
|
||||
add_foreign_key "memberships", "identities"
|
||||
end
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require_relative "../../config/environment"
|
||||
|
||||
ApplicationRecord.with_each_tenant do |tenant|
|
||||
puts "# #{tenant}"
|
||||
User.find_each do |user|
|
||||
next if user.system? || !user.active?
|
||||
|
||||
if user.membership.present?
|
||||
puts "Found identity #{user.identity.id} for user #{user.id} (#{user.email_address})"
|
||||
else
|
||||
memberships = Membership.where(email_address: user.email_address)
|
||||
if memberships.empty?
|
||||
# Create a new Identity
|
||||
Identity.transaction do
|
||||
identity = Identity.create!
|
||||
user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Account.sole.name)
|
||||
puts "Created identity #{identity.id} for user #{user.id} (#{user.email_address})"
|
||||
end
|
||||
else
|
||||
# Merge this User's Membership into the existing Identity
|
||||
identity = memberships.first.identity
|
||||
user.membership = identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: Account.sole.name)
|
||||
puts "Merged membership for user #{user.id} (#{user.email_address}) into identity #{identity.id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,7 @@ class ControllerAuthenticationTest < ActionDispatch::IntegrationTest
|
||||
get cards_path
|
||||
|
||||
assert_response :success
|
||||
assert_dom "h2", text: "We're migrating Fizzy authentication"
|
||||
assert_dom "p", text: "You don't have any existing Fizzy accounts."
|
||||
end
|
||||
|
||||
test "access with an account slug but no session redirects to new session" do
|
||||
|
||||
@@ -17,7 +17,11 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "create with valid credentials" do
|
||||
post session_path, params: { email_address: "david@37signals.com", password: "secret123456" }
|
||||
assert_difference -> { Identity.count }, 1 do
|
||||
assert_difference -> { Membership.count }, 1 do
|
||||
post session_path, params: { email_address: "david@37signals.com", password: "secret123456" }
|
||||
end
|
||||
end
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert cookies[:session_token].present?
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentityMembershipTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@tenants = [ ActiveRecord::FixtureSet.identify("identity-tenant-1"),
|
||||
ActiveRecord::FixtureSet.identify("identity-tenant-2") ]
|
||||
@tenant_paths = @tenants.map { "/#{_1}" }
|
||||
@tenant_urls = @tenant_paths.map { root_url(script_name: _1) }
|
||||
@user_params = { email_address: "user@example.com", password: "password1234" }
|
||||
@users = @tenants.map do |tenant|
|
||||
ApplicationRecord.create_tenant(tenant) do
|
||||
Account.create! name: "Account for #{tenant}"
|
||||
User.create! @user_params.merge(name: "Harold Hancox")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "multiple signins on the same browser" do
|
||||
post session_path(script_name: @tenant_paths[0], params: @user_params)
|
||||
assert_redirected_to root_path(script_name: @tenant_paths[0])
|
||||
|
||||
post session_path(script_name: @tenant_paths[1], params: @user_params)
|
||||
assert_redirected_to root_path(script_name: @tenant_paths[1])
|
||||
|
||||
# Render links for other Fizzies in the jump menu
|
||||
get my_menu_path(script_name: @tenant_paths[0])
|
||||
assert_select "#my_menu ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}"
|
||||
|
||||
get my_menu_path(script_name: @tenant_paths[1])
|
||||
assert_select "#my_menu ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}"
|
||||
|
||||
# Render links for all the identity's Fizzies
|
||||
get root_path(script_name: nil)
|
||||
assert_select "ul li a[href='#{@tenant_urls[0]}']", "Account for #{@tenants[0]}"
|
||||
assert_select "ul li a[href='#{@tenant_urls[1]}']", "Account for #{@tenants[1]}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class IdentityTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class MembershipTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
require "test_helper"
|
||||
|
||||
class User::IdentifiableTest < ActiveSupport::TestCase
|
||||
test "set_identity when token is an identity and user has a matching identity" do
|
||||
user = users(:david)
|
||||
token_identity = Identity.create!
|
||||
token_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
|
||||
assert_equal(token_identity, user.identity)
|
||||
|
||||
result = user.set_identity(token_identity)
|
||||
user.reload
|
||||
|
||||
assert_equal(token_identity, result)
|
||||
assert_equal(token_identity, user.identity)
|
||||
end
|
||||
|
||||
test "set_identity when token is an identity and user has no identity" do
|
||||
user = users(:david)
|
||||
token_identity = Identity.create!
|
||||
assert_nil(user.membership)
|
||||
assert_nil(user.identity)
|
||||
|
||||
result = user.set_identity(token_identity)
|
||||
user.reload
|
||||
|
||||
assert_equal(token_identity, result)
|
||||
assert_equal(token_identity, user.identity)
|
||||
assert_equal(user.email_address, user.membership.email_address)
|
||||
assert_equal(Account.sole.name, user.membership.account_name)
|
||||
end
|
||||
|
||||
test "set_identity when token is an identity and user has a different identity" do
|
||||
user = users(:david)
|
||||
user2 = users(:jz)
|
||||
token_identity = Identity.create!
|
||||
user_identity = Identity.create!
|
||||
user_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
|
||||
user_identity.memberships.create!(user_id: user2.id, user_tenant: user2.tenant, email_address: user2.email_address, account_name: "qwer")
|
||||
assert_equal(user_identity, user.identity)
|
||||
assert_equal(user_identity, user2.identity)
|
||||
|
||||
result = user.set_identity(token_identity)
|
||||
user.reload
|
||||
user2.reload
|
||||
|
||||
assert_equal(token_identity, result)
|
||||
assert_equal(token_identity, user.identity)
|
||||
assert_equal(token_identity, user2.identity)
|
||||
|
||||
assert_raises(ActiveRecord::RecordNotFound) do
|
||||
user_identity.reload
|
||||
end
|
||||
end
|
||||
|
||||
test "set_identity when token is nil and user has an identity" do
|
||||
user = users(:david)
|
||||
user_identity = Identity.create!
|
||||
user_identity.memberships.create!(user_id: user.id, user_tenant: user.tenant, email_address: user.email_address, account_name: "asdf")
|
||||
assert_equal(user_identity, user.identity)
|
||||
|
||||
result = user.set_identity(nil)
|
||||
user.reload
|
||||
|
||||
assert_equal(user_identity, result)
|
||||
assert_equal(user_identity, user.identity)
|
||||
end
|
||||
|
||||
test "set_identity when token is nil and user has no identity" do
|
||||
user = users(:david)
|
||||
assert_nil(user.identity)
|
||||
|
||||
result = user.set_identity(nil)
|
||||
user.reload
|
||||
|
||||
assert_not_nil(result)
|
||||
assert_equal(result, user.identity)
|
||||
assert_equal(1, result.memberships.count)
|
||||
assert_equal(user.email_address, user.membership.email_address)
|
||||
assert_equal(Account.sole.name, user.membership.account_name)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user