Skip to content

Commit

Permalink
[F] Add the ability for admins to verify users
Browse files Browse the repository at this point in the history
  • Loading branch information
scryptmouse authored and zdavis committed Feb 23, 2024
1 parent 44ada2a commit 950c4fa
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 35 deletions.
2 changes: 1 addition & 1 deletion api/app/controllers/concerns/validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def user_params
persistent_ui, notification_preferences_by_kind, :unsubscribe,
:consent_manifold_analytics, :consent_google_analytics,
:terms_and_conditions_accepted_at]
attributes << :role if current_user&.admin?
attributes += %i[admin_verified role] if current_user&.admin?
relationships = [:makers]
param_config = structure_params(attributes: attributes, relationships: relationships)
params.permit(param_config)
Expand Down
81 changes: 65 additions & 16 deletions api/app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class User < ApplicationRecord
classy_enum_attr :role, enum: "RoleName", allow_blank: false, default: :reader
classy_enum_attr :kind, enum: "RoleName", allow_blank: false, default: :reader

rolify after_add: :synchronize_kind!, after_remove: :synchronize_kind!
rolify after_add: :recalculate_role_derivations!, after_remove: :recalculate_role_derivations!

has_many :identities, inverse_of: :user, autosave: true, dependent: :destroy
has_many :annotations, -> { sans_orphaned_from_text }, foreign_key: "creator_id", dependent: :destroy,
Expand Down Expand Up @@ -63,11 +63,14 @@ class User < ApplicationRecord
validates :email, uniqueness: true, email_format: { message: "is not valid" }
validates :first_name, :last_name, presence: true

before_validation :infer_established!
before_validation :infer_kind!
before_validation :infer_role!
before_validation :infer_trusted!

after_save :sync_global_role!, if: :saved_change_to_role?
after_save :prepare_email_confirmation!, if: :saved_change_to_email?
after_touch :synchronize_established!

# Attachments
manifold_has_attached_file :avatar, :image
Expand All @@ -82,21 +85,39 @@ class User < ApplicationRecord

# Scopes
scope :by_email, ->(email) { where(arel_table[:email].matches("#{email}%")) if email.present? }
scope :with_order, lambda { |by|
scope :with_order, ->(by) do
return order(:last_name, :first_name) unless by.present?

order(by)
}
end
scope :by_role, ->(role) { RoleName[role].then { |r| with_role(r.to_sym) if r.present? } }
scope :by_cached_role, ->(*role) { where(role: role) }
scope :email_confirmed, -> { where.not(email_confirmed_at: nil) }
scope :email_unconfirmed, -> { where(email_confirmed_at: nil) }

scope :established, -> { where(established: true) }
scope :unestablished, -> { where(established: false) }
scope :trusted, -> { where(trusted: true) }
scope :untrusted, -> { where(trusted: false) }

# Search
searchkick word_start: TYPEAHEAD_ATTRIBUTES, callbacks: :async

delegate *RoleName.global_predicates, to: :role
delegate *RoleName.scoped_predicates, to: :kind

# @!attribute [rw] admin_verified
# @return [Boolean]
def admin_verified
verified_by_admin_at?
end

alias admin_verified? admin_verified

def admin_verified=(value)
self.verified_by_admin_at = ManifoldApi::Container["utility.booleanize"].(value) ? Time.current : nil
end

# @!attribute [r] email_confirmed
# @return [Boolean]
def email_confirmed
Expand All @@ -105,14 +126,6 @@ def email_confirmed

alias email_confirmed? email_confirmed

# This is used to check whether the user is permitted to create certain kinds
# of public content.
#
# For now, it is just based on whether or not they have confirmed their email.
def established?
email_confirmed?
end

def search_data
{
search_result_type: search_result_type,
Expand Down Expand Up @@ -238,14 +251,11 @@ def sync_global_role!
add_role role.to_sym
infer_kind!
update_column :kind, kind if kind_changed?
synchronize_trusted!(force: true)
ensure
@synchronizing_global_role = false
end

def trusted?
has_any_role?(:admin, :editor, :moderator) || has_role?(:project_editor, :any)
end

class << self
# @param [#project] subject
# @return [ActiveRecord::Relation<User>]
Expand All @@ -260,6 +270,21 @@ def receiving_comment_notifications_for(subject)

private

# @return [Boolean]
def calculate_established
verified_by_admin_at? || email_confirmed?
end

# @return [Boolean]
def calculate_trusted
has_any_role?(:admin, :editor, :moderator, :marketeer) || has_role?(:project_editor, :any)
end

# @return [void]
def infer_established!
self.established = calculate_established
end

# @see RoleName.fetch_for_kind
# @return [void]
def infer_kind!
Expand All @@ -272,7 +297,24 @@ def infer_role!
self.role = RoleName.fetch_for(self) unless will_save_change_to_role?
end

# @param [Role]
# @return [void]
def infer_trusted!
self.trusted = calculate_trusted
end

# @param [Role] role
# @return [void]
def recalculate_role_derivations!(role)
synchronize_kind! role
synchronize_trusted!
end

# @return [void]
def synchronize_established!
update_column :established, calculate_established
end

# @param [Role] role
# @return [void]
def synchronize_kind!(role)
sync_notification_preferences! role
Expand All @@ -287,6 +329,13 @@ def synchronize_kind!(role)
update_columns updates if updates.any?
end

# @return [void]
def synchronize_trusted!(force: false)
return if !force && @synchronizing_global_role

update_column :trusted, calculate_trusted
end

def password_not_blank!
return if password.nil?

Expand Down
5 changes: 2 additions & 3 deletions api/app/serializers/v1/concerns/user_attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,11 @@ module UserAttributes
object.consent_needed_google_analytics?
end

typed_attribute :admin_verified, Types::Bool.meta(private: true)
typed_attribute :email_confirmation_sent_at, Types::DateTime.optional.meta(read_only: true), private: true
typed_attribute :email_confirmed_at, Types::DateTime.optional.meta(read_only: true), private: true
typed_attribute :email_confirmed, Types::Bool.meta(read_only: true)
typed_attribute :established, Types::Bool.meta(read_only: true) do |object|
object.established?
end
typed_attribute :established, Types::Bool.meta(read_only: true)

typed_attribute :nickname, Types::String
typed_attribute :first_name, Types::String
Expand Down
46 changes: 46 additions & 0 deletions api/db/migrate/20240223163849_add_verified_by_admin_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

class AddVerifiedByAdminToUsers < ActiveRecord::Migration[6.1]
def change
change_table :users do |t|
t.timestamp :verified_by_admin_at

t.boolean :established, null: false, default: false
t.boolean :trusted, null: false, default: false

t.index :established
t.index :trusted
end

reversible do |dir|
dir.up do
say_with_time "Calculating established for users" do
exec_update <<~SQL
UPDATE users SET established = email_confirmed_at IS NOT NULL;
SQL
end

say_with_time "Calculating trusted for users" do
exec_update <<~SQL
WITH trustedness AS (
SELECT DISTINCT "users"."id" AS user_id
FROM "users"
INNER JOIN "users_roles" ON "users_roles"."user_id" = "users"."id"
INNER JOIN "roles" ON "roles"."id" = "users_roles"."role_id"
WHERE
(
(roles.name IN ('admin', 'editor', 'moderator', 'marketeer')) AND (roles.resource_type IS NULL) AND (roles.resource_id IS NULL)
)
OR
(
(roles.name = 'project_editor') AND (roles.resource_type = 'Project')
)
)
UPDATE users SET "trusted" = TRUE
WHERE id IN (SELECT user_id FROM trustedness);
SQL
end
end
end
end
end
22 changes: 20 additions & 2 deletions api/db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1168,7 +1168,10 @@ CREATE TABLE public.users (
terms_and_conditions_accepted_at timestamp without time zone,
email_confirmation_token text,
email_confirmation_sent_at timestamp(6) without time zone,
email_confirmed_at timestamp(6) without time zone
email_confirmed_at timestamp(6) without time zone,
verified_by_admin_at timestamp without time zone,
established boolean DEFAULT false NOT NULL,
trusted boolean DEFAULT false NOT NULL
);


Expand Down Expand Up @@ -5985,6 +5988,13 @@ CREATE INDEX index_user_collected_texts_on_user_id ON public.user_collected_text
CREATE INDEX index_users_on_email_confirmed_at ON public.users USING btree (email_confirmed_at);


--
-- Name: index_users_on_established; Type: INDEX; Schema: public; Owner: -
--

CREATE INDEX index_users_on_established ON public.users USING btree (established);


--
-- Name: index_users_on_import_source_id; Type: INDEX; Schema: public; Owner: -
--
Expand All @@ -6006,6 +6016,13 @@ CREATE INDEX index_users_on_kind ON public.users USING btree (kind);
CREATE INDEX index_users_on_role ON public.users USING btree (role);


--
-- Name: index_users_on_trusted; Type: INDEX; Schema: public; Owner: -
--

CREATE INDEX index_users_on_trusted ON public.users USING btree (trusted);


--
-- Name: index_users_roles_on_role_id; Type: INDEX; Schema: public; Owner: -
--
Expand Down Expand Up @@ -7174,6 +7191,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20231005175407'),
('20231010184158'),
('20231129172116'),
('20240220212417');
('20240220212417'),
('20240223163849');


35 changes: 22 additions & 13 deletions api/spec/models/user_spec.rb
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
require "rails_helper"
# frozen_string_literal: true

# rubocop:disable Layout/LineLength
RSpec.describe User, type: :model do
it "has a valid factory" do
expect(FactoryBot.build(:user)).to be_valid
end

it "has many favorites" do
user = User.new
2.times { user.favorites.build }
expect(user.favorites.length).to be 2
end

it "reports whether or not a favoritable is among its favorites" do
user = FactoryBot.create(:user)
project = FactoryBot.create(:project)
Expand Down Expand Up @@ -89,6 +78,24 @@
expect(User.find_by(email: "[email protected]")).to eq user
end

context "when setting admin_verified" do
let!(:user) { FactoryBot.create :user }

it "updates other properties" do
expect do
user.admin_verified = true
user.save!
end.to change { user.verified_by_admin_at }.from(nil).to(a_kind_of(ActiveSupport::TimeWithZone))
.and change { user.established }.from(false).to(true)

expect do
user.admin_verified = ?0
user.save!
end.to change { user.verified_by_admin_at }.from(a_kind_of(ActiveSupport::TimeWithZone)).to(nil)
.and change { user.established }.from(true).to(false)
end
end

context "when changing a role" do
let!(:user) { FactoryBot.create :user, :editor }
let!(:project) { FactoryBot.create :project }
Expand All @@ -114,7 +121,9 @@
it "updates the role and kind" do
expect do
user.add_role :editor
end.to change { user.role.to_sym }.from(:reader).to(:editor).and change { user.kind.to_sym }.from(:reader).to(:editor)
end.to change { user.role.to_sym }.from(:reader).to(:editor)
.and change { user.kind.to_sym }.from(:reader).to(:editor)
.and change { user.trusted }.from(false).to(true)
end
end

Expand Down
14 changes: 14 additions & 0 deletions api/spec/operations/users/mark_email_confirmed_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

RSpec.describe Users::MarkEmailConfirmed, type: :operation do
let_it_be(:user, refind: true) { FactoryBot.create :user }

let(:operation_args) { [user] }

it "will confirm the user and mark them as trusted" do
expect do
expect_calling_the_operation.to succeed
end.to change { user.reload.email_confirmed? }.from(false).to(true)
.and change { user.reload.established? }.from(false).to(true)
end
end
2 changes: 2 additions & 0 deletions api/spec/support/helpers/operations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ def expect_calling_the_operation
include TestHelpers::Operations

let(:operation) { described_class.new }
let(:operation_args) { [] }
let(:operation_options) { {} }
end

RSpec.configure do |config|
Expand Down

0 comments on commit 950c4fa

Please sign in to comment.