Drop memberships

This commit is contained in:
Stanko K.R.
2025-11-13 13:38:10 +01:00
committed by Mike Dalessio
parent 5c6b91ef77
commit edf837fed3
26 changed files with 72 additions and 99 deletions
+3 -2
View File
@@ -9,8 +9,9 @@ module ApplicationCable
private
def set_current_user
if session = find_session_by_cookie
membership = session.identity.memberships.find_by!(tenant: request.env["fizzy.external_account_id"])
self.current_user = membership.user if membership.user.active?
account = Account.find(request.env["fizzy.external_account_id"])
user = session.identity.user.find_by!(account: account)
self.current_user = user if user.active?
end
end
+1 -1
View File
@@ -26,7 +26,7 @@ module Authorization
end
def ensure_can_access_account
redirect_to session_menu_url(script_name: nil) if Current.membership.blank?
redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active?
end
def redirect_existing_user
@@ -1,4 +1,4 @@
class Memberships::EmailAddresses::ConfirmationsController < ApplicationController
class Users::EmailAddresses::ConfirmationsController < ApplicationController
disallow_account_scope
allow_unauthenticated_access
@@ -1,4 +1,4 @@
class Memberships::EmailAddressesController < ApplicationController
class Users::EmailAddressesController < ApplicationController
disallow_account_scope
layout "public"
+1 -1
View File
@@ -1,6 +1,6 @@
module ApplicationHelper
def page_title_tag
account_name = if Current.account && Current.session&.identity&.memberships&.many?
account_name = if Current.account && Current.session&.identity&.users&.many?
Current.account&.name
end
tag.title [ @page_title, account_name, "Fizzy" ].compact.join(" | ")
+2 -3
View File
@@ -1,15 +1,14 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session, :membership, :account
attribute :session, :user, :account
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
delegate :identity, to: :session, allow_nil: true
delegate :user, to: :membership, allow_nil: true
def session=(value)
super(value)
if value.present? && Current.account.present?
self.membership = identity.memberships.find_by(tenant: Current.account.external_account_id)
self.user = identity.users.find_by(account: Current.account)
end
end
end
+8 -1
View File
@@ -1,10 +1,12 @@
class Identity < ApplicationRecord
include Joinable, Transferable
has_many :memberships, dependent: :destroy
has_many :users, dependent: :nullify
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
before_destroy :deactivate_users
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
def send_magic_link
@@ -16,4 +18,9 @@ class Identity < ApplicationRecord
def staff?
email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com")
end
private
def deactivate_users
users.find_each(&:deactivate)
end
end
+5 -4
View File
@@ -1,14 +1,15 @@
module Identity::Joinable
extend ActiveSupport::Concern
def join(account)
def join(account, **attributes)
attributes[:name] ||= email_address
transaction do
membership = memberships.create!(tenant: account.external_account_id)
account.users.create!(membership: membership, name: email_address)
account.users.create!(**attributes, identity: self)
end
end
def member_of?(account)
memberships.exists?(tenant: account.external_account_id.to_s)
account.users.exists?(identity: self)
end
end
-30
View File
@@ -1,30 +0,0 @@
class Membership < ApplicationRecord
include EmailAddressChangeable
belongs_to :identity, touch: true
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 account
Account.find_by_external_account_id(tenant)
end
def account_name
account&.name
end
def user
# TODO:PLANB: should this find should be scoped by account?
User.find_by(membership_id: id)
end
end
+1 -4
View File
@@ -6,9 +6,7 @@ class User < ApplicationRecord
has_one_attached :avatar
belongs_to :account, default: -> { Current.account }
belongs_to :membership, optional: true
has_one :identity, through: :membership, disable_joins: true
belongs_to :identity, optional: true
has_many :comments, inverse_of: :creator, dependent: :destroy
@@ -22,7 +20,6 @@ class User < ApplicationRecord
def deactivate
transaction do
accesses.destroy_all
membership.destroy!
update! active: false
end
end
@@ -1,4 +1,4 @@
module Membership::EmailAddressChangeable
module User::EmailAddressChangeable
EMAIL_CHANGE_TOKEN_PURPOSE = "change_email_address"
EMAIL_CHANGE_TOKEN_EXPIRATION = 30.minutes
@@ -1,6 +1,6 @@
<p class="subtitle">Confirm your email address change</p>
<%= link_to "Yes use use this email address", email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token), class: "btn" %>
<%= link_to "Yes use use this email address", email_address_confirmation_url(user_id: @user.id, email_address_token: @token), class: "btn" %>
<p class="margin-block-start-double">If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.</p>
@@ -3,6 +3,6 @@ Confirm your email address change
Hit the link below to use this email address in Fizzy:
<%= email_address_confirmation_url(membership_id: @membership.id, email_address_token: @token) %>
<%= email_address_confirmation_url(user_id: @user.id, email_address_token: @token) %>
If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.
If you didnt request this change, you can ignore this email. Your email address WILL NOT be changed unless you hit the button.
@@ -1,5 +1,5 @@
<% if @identity.memberships.any? %>
<% if @identity.users.any? %>
<h1 class="title">Fizzy verification code</h1>
<p class="subtitle">Please enter this 6-character verification code on the Fizzy sign-in page:</p>
<% else %>
@@ -1,4 +1,4 @@
<% if @identity.memberships.any? %>
<% if @identity.users.any? %>
Please enter this 6-character verification code on the Fizzy sign-in page:
<% else %>
Please enter this 6-character verification code on the Fizzy sign-up page to create your new account:
+1 -1
View File
@@ -1,7 +1,7 @@
<div class="flex gap">
<div class="flex flex-column full-width justify-center gap-half">
<div class="nav__header">
<% if Current.user.identity.memberships.many? %>
<% if Current.user.identity.users.many? %>
<div class="nav__header-title max-width">
<div class="overflow-ellipsis"><strong><%= Current.account.name %></strong></div>
</div>
@@ -1,8 +1,8 @@
<%= turbo_frame_tag Current.identity, :account_menu do %>
<% cache [ Current.identity, @memberships ] do %>
<% cache [ Current.identity, @accounts ] do %>
<%= collapsible_nav_section "Accounts" do %>
<% @memberships.each do |membership| %>
<%= filter_place_menu_item landing_url(script_name: "/#{membership.tenant}"), "#{membership.account_name}", "marker", new_window: true %>
<% @accounts.each do |account| %>
<%= filter_place_menu_item landing_url(script_name: account.slug, account.name, "marker", new_window: true %>
<% end %>
<% end %>
<% end %>
+5 -5
View File
@@ -1,20 +1,20 @@
<% @page_title = "Choose an account" %>
<% cache [ Current.identity, @memberships ] do %>
<% cache [ Current.identity, @accounts ] do %>
<div
class="panel panel--centered flex flex-column gap-half"
style="--popup-icon-size: 24px; --popup-item-padding-inline: 0.5rem;"
>
<% if @memberships.present? %>
<% if @accounts.any? %>
<h1 class="txt-x-large font-weight-black margin-none">
Your Fizzy accounts
</h1>
<menu class="popup__list pad border-radius border">
<% @memberships.each do |membership| %>
<% @accounts.each do |account| %>
<li class="popup__item txt-medium">
<%= icon_tag "marker", class: "popup__icon" %>
<%= link_to landing_url(script_name: "/#{membership.tenant}"), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
<strong><%= membership.account_name %></strong>
<%= link_to landing_url(script_name: account.slug), class: "btn overflow-ellipsis fill-transparent popup__btn" do %>
<strong><%= account.name %></strong>
<% end %>
</li>
<% end %>
+1 -1
View File
@@ -40,7 +40,7 @@
<div class="flex align-center gap">
<div class="flex align-center gap input input--actor">
<%= form.email_field :email_address, class: "input full-width", autocomplete: "username", placeholder: "Email address", required: true, readonly: true, value: @user.identity.email_address %>
<%= link_to "Change email", new_email_address_url(script_name: nil, membership_id: Current.membership.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
<%= link_to "Change email", new_email_address_url(script_name: nil, user_id: Current.user.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
</div>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center" data-form-target="submit">
@@ -8,7 +8,7 @@
<p class="margin-none">Just a sec while we confirm your new email address.</p>
</header>
<%= form_with url: email_address_confirmation_path(membership_id: @membership.id), method: :post, data: { controller: "form auto-submit" } do |form| %>
<%= form_with url: email_address_confirmation_path(user_id: @user.id), method: :post, data: { controller: "form auto-submit" } do |form| %>
<%= form.hidden_field :email_address_token, value: params[:email_address_token] %>
<button type="submit" class="btn btn--link center" data-form-target="submit">
@@ -2,7 +2,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= link_to edit_user_path(@membership.user, script_name: "/#{@tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<%= link_to edit_user_path(@user, script_name: @user.account.slug), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<span class="overflow-ellipsis flex align-center">
<strong>Back to My profile</strong>
<kbd class="txt-x-small margin-inline-start hide-on-touch">&larr;</kbd>
@@ -16,5 +16,5 @@
<p class="margin-none">We just sent an email to <strong><%= params[:email_address] %></strong></p>
<p class="margin-none-block-start">Hit the link in the email to confirm this is the email address you want to use with Fizzy going forward.</p>
<%= link_to "Done", edit_user_path(@membership.user, script_name: "/#{@tenant}"), class: "btn btn--link center" %>
<%= link_to "Done", edit_user_path(@user, script_name: @user.account.slug), class: "btn btn--link center" %>
</div>
@@ -2,7 +2,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= link_to edit_user_path(@membership.user, script_name: "/#{@membership.tenant}"), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<%= link_to edit_user_path(@user, script_name: @user.account.slug), class: "btn borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %>
<span class="overflow-ellipsis flex align-center">
<strong>Back to My profile</strong>
<kbd class="txt-x-small margin-inline-start hide-on-touch">&larr;</kbd>
@@ -16,8 +16,8 @@
<%= @page_title %>
</h1>
<%= form_with url: email_addresses_path(membership_id: @membership.id), method: :post, class: "flex flex-column gap-half", data: { controller: "form", turbo: false } do |form| %>
<div class="flex align-center gap-haf">
<%= form_with url: user_email_addresses_path(user_id: @user.id), method: :post, class: "flex flex-column gap-half", data: { controller: "form", turbo: false } do |form| %>
<div class="flex align-center gap-half">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, class: "input full-width", autocomplete: "email", placeholder: "New email address", autofocus: true, required: true %>
</label>
+1 -1
View File
@@ -3,7 +3,7 @@
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-large font-weight-black margin-none"><%= @page_title %></h1>
<%= form_with scope: "user", url: users_joins_path(membership: params[:membership]), class: "flex flex-column gap txt-medium", data: { controller: "form" } do |form| %>
<%= form_with scope: "user", url: users_joins_path, class: "flex flex-column gap txt-medium", data: { controller: "form" } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.text_field :name, class: "input full-width", autocomplete: "name", placeholder: "Full name", autofocus: true, required: true, data: { "1p-ignore": true } %>
@@ -0,0 +1,7 @@
class DropMemberships < ActiveRecord::Migration[8.2]
def change
add_reference :users, :identity, type: :uuid, null: true, foreign_key: true
remove_column :users, :membership_id, :bigint
drop_table :memberships
end
end
Generated
+4 -14
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_11_12_184932) do
ActiveRecord::Schema[8.2].define(version: 2025_11_13_111501) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "board_id", null: false
@@ -288,16 +288,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_12_184932) do
t.index ["identity_id"], name: "index_magic_links_on_identity_id"
end
create_table "memberships", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.uuid "identity_id", null: false
t.string "tenant", null: false
t.datetime "updated_at", null: false
t.index ["identity_id"], name: "index_memberships_on_identity_id"
t.index ["tenant", "identity_id"], name: "index_memberships_on_tenant_and_identity_id", unique: true
t.index ["tenant"], name: "index_memberships_on_user_tenant_and_user_id"
end
create_table "mentions", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "created_at", null: false
t.uuid "mentionee_id", null: false
@@ -632,11 +622,11 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_12_184932) do
t.uuid "account_id"
t.boolean "active", default: true, null: false
t.datetime "created_at", null: false
t.uuid "membership_id"
t.uuid "identity_id"
t.string "name", null: false
t.string "role", default: "member", null: false
t.datetime "updated_at", null: false
t.index ["membership_id"], name: "index_users_on_membership_id"
t.index ["identity_id"], name: "index_users_on_identity_id"
t.index ["role"], name: "index_users_on_role"
end
@@ -700,7 +690,6 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_12_184932) do
add_foreign_key "comments", "cards"
add_foreign_key "events", "boards"
add_foreign_key "magic_links", "identities"
add_foreign_key "memberships", "identities"
add_foreign_key "mentions", "users", column: "mentionee_id"
add_foreign_key "mentions", "users", column: "mentioner_id"
add_foreign_key "notification_bundles", "users"
@@ -715,6 +704,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_11_12_184932) do
add_foreign_key "taggings", "cards"
add_foreign_key "taggings", "tags"
add_foreign_key "user_settings", "users"
add_foreign_key "users", "identities"
add_foreign_key "watches", "cards"
add_foreign_key "watches", "users"
add_foreign_key "webhook_delinquency_trackers", "webhooks"
+15 -14
View File
@@ -113,7 +113,6 @@ class Import
account = import.accounts.sole
new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address)
new_membership = new_identity.memberships.find_or_create_by!(tenant: account.external_account_id.to_s)
if Account.all.exists?(external_account_id: account.external_account_id)
raise "Account already exists"
@@ -125,7 +124,7 @@ class Import
},
owner: {
name: oldest_admin.name,
membership_id: new_membership.id
identity: new_identity
}
)
@tenant = @account.external_account_id
@@ -147,26 +146,28 @@ class Import
step("Copying users", "Copied %{count} users in %{duration}") do
mapping[:users] ||= {}
import.users.find_each do |old_user|
new_membership = nil
new_identity = nil
if old_user.active && old_user.membership_id
if old_user.membership_id
membership = untenanted.memberships.find(old_user.membership_id)
new_identity = Identity.find_or_create_by!(email_address: membership.identity.email_address)
new_membership = new_identity.memberships.find_or_create_by!(tenant: tenant)
end
new_user = User.find_or_create_by!(account_id: account.id, membership_id: new_membership&.id) do |u|
u.name = old_user.name
u.role = old_user.role
u.active = old_user.active
end
new_user = User.create!(
account: account,
identity: new_identity,
name: old_user.name,
role: old_user.role,
active: old_user.active,
)
old_settings = old_user.settings
if old_settings
User::Settings.find_or_create_by!(user_id: new_user.id) do |s|
s.bundle_email_frequency = old_settings.bundle_email_frequency
s.timezone_name = old_settings.timezone_name
end
User::Settings.create!(
user: new_user,
bundle_email_frequency: old_settings.bundle_email_frequency,
timezone_name: old_settings.timezone_name
)
end
mapping[:users][old_user.id] = new_user.id