Add verified_at timestamp to use for spam prevention
User are marked as verified after a join code is redeemed. The user is redirected to Users::VerificationsController, either: - after submitting a valid magic link code, - or immediately after redeeming the join code (if they're already authenticated with the correct identity) Account owners are automatically verified when the account is created (because they have already provided a magic link code at that point). This sets up for later commits that will backfill existing users and require verification before sending notification emails. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ class JoinCodesController < ApplicationController
|
|||||||
if identity == Current.identity && user.setup?
|
if identity == Current.identity && user.setup?
|
||||||
redirect_to landing_url(script_name: @join_code.account.slug)
|
redirect_to landing_url(script_name: @join_code.account.slug)
|
||||||
elsif identity == Current.identity
|
elsif identity == Current.identity
|
||||||
redirect_to new_users_join_url(script_name: @join_code.account.slug)
|
redirect_to new_users_verification_url(script_name: @join_code.account.slug)
|
||||||
else
|
else
|
||||||
logout_and_send_new_magic_link(identity)
|
logout_and_send_new_magic_link(identity)
|
||||||
redirect_to session_magic_link_url(script_name: nil)
|
redirect_to session_magic_link_url(script_name: nil)
|
||||||
@@ -44,6 +44,6 @@ class JoinCodesController < ApplicationController
|
|||||||
magic_link = identity.send_magic_link
|
magic_link = identity.send_magic_link
|
||||||
serve_development_magic_link(magic_link)
|
serve_development_magic_link(magic_link)
|
||||||
|
|
||||||
session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug)
|
session[:return_to_after_authenticating] = new_users_verification_url(script_name: @join_code.account.slug)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
class Users::VerificationsController < ApplicationController
|
||||||
|
layout "public"
|
||||||
|
|
||||||
|
def new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
Current.user.verify
|
||||||
|
redirect_to new_users_join_path
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -21,7 +21,7 @@ class Account < ApplicationRecord
|
|||||||
def create_with_owner(account:, owner:)
|
def create_with_owner(account:, owner:)
|
||||||
create!(**account).tap do |account|
|
create!(**account).tap do |account|
|
||||||
account.users.create!(role: :system, name: "System")
|
account.users.create!(role: :system, name: "System")
|
||||||
account.users.create!(**owner.reverse_merge(role: "owner"))
|
account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ class User < ApplicationRecord
|
|||||||
name != identity.email_address
|
name != identity.email_address
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def verified?
|
||||||
|
verified_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify
|
||||||
|
update!(verified_at: Time.current) unless verified?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def close_remote_connections
|
def close_remote_connections
|
||||||
ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false)
|
ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<%= auto_submit_form_with url: users_verifications_path, method: :post %>
|
||||||
@@ -138,6 +138,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
namespace :users do
|
namespace :users do
|
||||||
resources :joins
|
resources :joins
|
||||||
|
resources :verifications, only: %i[ new create ]
|
||||||
end
|
end
|
||||||
|
|
||||||
resource :session do
|
resource :session do
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddVerifiedAtToUsers < ActiveRecord::Migration[8.2]
|
||||||
|
def change
|
||||||
|
add_column :users, :verified_at, :datetime
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+2
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
|
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
|
||||||
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.datetime "accessed_at"
|
t.datetime "accessed_at"
|
||||||
t.uuid "account_id", null: false
|
t.uuid "account_id", null: false
|
||||||
@@ -726,6 +726,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
|
|||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "role", default: "member", null: false
|
t.string "role", default: "member", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "verified_at"
|
||||||
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
|
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
|
||||||
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
|
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
|
||||||
t.index ["identity_id"], name: "index_users_on_identity_id"
|
t.index ["identity_id"], name: "index_users_on_identity_id"
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
|
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
|
||||||
create_table "accesses", id: :uuid, force: :cascade do |t|
|
create_table "accesses", id: :uuid, force: :cascade do |t|
|
||||||
t.datetime "accessed_at"
|
t.datetime "accessed_at"
|
||||||
t.uuid "account_id", null: false
|
t.uuid "account_id", null: false
|
||||||
@@ -499,6 +499,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
|
|||||||
t.string "name", limit: 255, null: false
|
t.string "name", limit: 255, null: false
|
||||||
t.string "role", limit: 255, default: "member", null: false
|
t.string "role", limit: 255, default: "member", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "verified_at"
|
||||||
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
|
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
|
||||||
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
|
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
|
||||||
t.index ["identity_id"], name: "index_users_on_identity_id"
|
t.index ["identity_id"], name: "index_users_on_identity_id"
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ else
|
|||||||
if user = identity.users.find_by(account: Current.account)
|
if user = identity.users.find_by(account: Current.account)
|
||||||
user
|
user
|
||||||
else
|
else
|
||||||
User.create!(name: full_name, identity: identity, account: Current.account)
|
User.create!(name: full_name, identity: identity, account: Current.account, verified_at: Time.current)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
|
|
||||||
assert_redirected_to session_magic_link_url(script_name: nil)
|
assert_redirected_to session_magic_link_url(script_name: nil)
|
||||||
assert_equal new_users_join_url(script_name: @account.slug), session[:return_to_after_authenticating]
|
assert_equal new_users_verification_url(script_name: @account.slug), session[:return_to_after_authenticating]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create for existing identity" do
|
test "create for existing identity" do
|
||||||
@@ -55,7 +55,7 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_redirected_to landing_url(script_name: @account.slug)
|
assert_redirected_to landing_url(script_name: @account.slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create for signed-in identity without a user in the account redirects to user setup" do
|
test "create for signed-in identity without a user in the account redirects to verification" do
|
||||||
identity = identities(:mike)
|
identity = identities(:mike)
|
||||||
sign_in_as :mike
|
sign_in_as :mike
|
||||||
|
|
||||||
@@ -67,6 +67,6 @@ class JoinCodesControllerTest < ActionDispatch::IntegrationTest
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_redirected_to new_users_join_url(script_name: @account.slug)
|
assert_redirected_to new_users_verification_url(script_name: @account.slug)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Users::VerificationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
test "new renders the auto-submit form" do
|
||||||
|
sign_in_as :david
|
||||||
|
|
||||||
|
get new_users_verification_path
|
||||||
|
|
||||||
|
assert_response :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create verifies the user and redirects to join" do
|
||||||
|
sign_in_as :david
|
||||||
|
|
||||||
|
user = users(:david)
|
||||||
|
user.update_column(:verified_at, nil)
|
||||||
|
assert_not user.verified?
|
||||||
|
|
||||||
|
post users_verifications_path
|
||||||
|
|
||||||
|
assert_redirected_to new_users_join_path
|
||||||
|
assert user.reload.verified?
|
||||||
|
end
|
||||||
|
end
|
||||||
Vendored
+5
@@ -4,6 +4,7 @@ david:
|
|||||||
role: member
|
role: member
|
||||||
identity: david
|
identity: david
|
||||||
account: 37s_uuid
|
account: 37s_uuid
|
||||||
|
verified_at: <%= Time.current.to_fs(:db) %>
|
||||||
|
|
||||||
jz:
|
jz:
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %>
|
id: <%= ActiveRecord::FixtureSet.identify("jz", :uuid) %>
|
||||||
@@ -11,6 +12,7 @@ jz:
|
|||||||
role: member
|
role: member
|
||||||
identity: jz
|
identity: jz
|
||||||
account: 37s_uuid
|
account: 37s_uuid
|
||||||
|
verified_at: <%= Time.current.to_fs(:db) %>
|
||||||
|
|
||||||
jason:
|
jason:
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("jason", :uuid) %>
|
id: <%= ActiveRecord::FixtureSet.identify("jason", :uuid) %>
|
||||||
@@ -18,6 +20,7 @@ jason:
|
|||||||
role: owner
|
role: owner
|
||||||
identity: jason
|
identity: jason
|
||||||
account: 37s_uuid
|
account: 37s_uuid
|
||||||
|
verified_at: <%= Time.current.to_fs(:db) %>
|
||||||
|
|
||||||
kevin:
|
kevin:
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("kevin", :uuid) %>
|
id: <%= ActiveRecord::FixtureSet.identify("kevin", :uuid) %>
|
||||||
@@ -25,6 +28,7 @@ kevin:
|
|||||||
role: admin
|
role: admin
|
||||||
identity: kevin
|
identity: kevin
|
||||||
account: 37s_uuid
|
account: 37s_uuid
|
||||||
|
verified_at: <%= Time.current.to_fs(:db) %>
|
||||||
|
|
||||||
system:
|
system:
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("system", :uuid) %>
|
id: <%= ActiveRecord::FixtureSet.identify("system", :uuid) %>
|
||||||
@@ -38,6 +42,7 @@ mike:
|
|||||||
role: admin
|
role: admin
|
||||||
identity: mike
|
identity: mike
|
||||||
account: initech_uuid
|
account: initech_uuid
|
||||||
|
verified_at: <%= Time.current.to_fs(:db) %>
|
||||||
|
|
||||||
system_initech:
|
system_initech:
|
||||||
id: <%= ActiveRecord::FixtureSet.identify("system_initech", :uuid) %>
|
id: <%= ActiveRecord::FixtureSet.identify("system_initech", :uuid) %>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class AccountTest < ActiveSupport::TestCase
|
|||||||
assert owner.admin?, "owner should also be considered an admin"
|
assert owner.admin?, "owner should also be considered an admin"
|
||||||
|
|
||||||
assert_predicate account.system_user, :present?
|
assert_predicate account.system_user, :present?
|
||||||
|
|
||||||
|
assert owner.verified?, "owner should be verified on account creation"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -48,4 +48,36 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
user.update!(name: "Kevin")
|
user.update!(name: "Kevin")
|
||||||
assert user.setup?
|
assert user.setup?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "verified? returns true when verified_at is present" do
|
||||||
|
user = users(:david)
|
||||||
|
user.update_column(:verified_at, Time.current)
|
||||||
|
|
||||||
|
assert user.verified?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "verified? returns false when verified_at is nil" do
|
||||||
|
user = users(:david)
|
||||||
|
user.update_column(:verified_at, nil)
|
||||||
|
|
||||||
|
assert_not user.verified?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "verify sets verified_at when not already verified" do
|
||||||
|
user = users(:david)
|
||||||
|
user.update_column(:verified_at, nil)
|
||||||
|
|
||||||
|
assert_nil user.verified_at
|
||||||
|
user.verify
|
||||||
|
assert_not_nil user.reload.verified_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "verify does not update verified_at when already verified" do
|
||||||
|
user = users(:david)
|
||||||
|
original_time = 1.day.ago
|
||||||
|
user.update_column(:verified_at, original_time)
|
||||||
|
|
||||||
|
user.verify
|
||||||
|
assert_equal original_time.to_i, user.reload.verified_at.to_i
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class SmokeTest < ApplicationSystemTestCase
|
|||||||
send_keys :enter
|
send_keys :enter
|
||||||
|
|
||||||
assert_selector "input[id=user_name]"
|
assert_selector "input[id=user_name]"
|
||||||
|
assert account.users.find_by!(identity:).verified?, "User was not properly verified"
|
||||||
fill_in "Full name", with: "New Bee"
|
fill_in "Full name", with: "New Bee"
|
||||||
click_on "Continue"
|
click_on "Continue"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user