Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
106 commits
Select commit Hold shift + click to select a range
b229ef0
[TAN-5943] Add parameter to delete user participation data
adessy Nov 26, 2025
a4a7b18
[TAN-5943] Add GET participation_stats endpoint
adessy Nov 27, 2025
4c39d13
[TAN-5943] Add delete user modal with participation stats
adessy Nov 27, 2025
35e726f
Fix typo: setChangingToRowType → setChangingToRoleType
adessy Dec 1, 2025
8b7e5e4
Translations updated by CI (extract-intl)
Dec 1, 2025
0db404c
[TAN-5943] Restrict participation_stats policy to admins only
adessy Dec 10, 2025
dc8320e
[TAN-5943] Replace `moment` with `date-fns` in UserDeleteModal
adessy Dec 10, 2025
0e77c3f
[TAN-5943] Simplify `UserPolicy` using `active_admin?` helper
adessy Dec 10, 2025
2a71456
[TAN-5943] Fix participation stats caching
adessy Dec 11, 2025
dfe3079
[TAN-5943] Split `ideas_count` into ideas, proposals, and survey resp…
adessy Dec 11, 2025
c302666
Translations updated by CI (extract-intl)
Dec 11, 2025
9fec977
[TAN-5943] Improve font sizes in `UserDeleteModal`
adessy Dec 11, 2025
933d862
[TAN-5945] Add EmailBan model and table
adessy Dec 1, 2025
24c6e87
[TAN-5945] Block banned emails from registration
adessy Dec 1, 2025
dac6b8e
[TAN-5945] Add `ban_email` and `ban_reason` params to user deletion
adessy Dec 1, 2025
410f7ba
[TAN-5945] Add `email_bans` admin endpoints
adessy Dec 1, 2025
c1f3dcd
[TAN-5945] Add ban toggle to delete user modal
adessy Dec 1, 2025
8cca423
[TAN-5945] Add banned emails admin UI
adessy Dec 1, 2025
87059aa
[TAN-5945] Rename `EmailBan.find_by_email` to `find_for`
adessy Dec 1, 2025
719a22f
[TAN-5945] Use RESTful path params for email_bans endpoints
adessy Dec 1, 2025
c0dcad3
[TAN-5945] Add EmailBan serializer
adessy Dec 1, 2025
d25d712
[TAN-5945] Refactor email_bans acceptance specs
adessy Dec 2, 2025
aac4c74
[TAN-5945] Invalidate email bans cache on user deletion
adessy Dec 2, 2025
6aae0e8
[TAN-5945] Simplify EmailBan model
adessy Dec 5, 2025
b90852c
[TAN-5945] Handle banned emails in invites
adessy Dec 5, 2025
d912b7f
[TAN-5945] Add documentation
adessy Dec 5, 2025
d9c092a
[TAN-5943] Show banned email status in delete user modal
adessy Dec 5, 2025
bd1f2cd
[TAN-5946] Fix Rubocop offenses
adessy Dec 5, 2025
adb03f9
Translations updated by CI (extract-intl)
Dec 5, 2025
8aaf950
Translations updated by CI (extract-intl)
Dec 11, 2025
f431a57
[TAN-5945] Address review comments
adessy Dec 16, 2025
e0e6822
[TAN-5945] Use proper query key factory `emailBansKeys.item()` in `us…
adessy Dec 16, 2025
b33b51e
[TAN-5945] Conditionally render `DeleteUserModal` instead of using `o…
adessy Dec 16, 2025
187c10d
[TAN-5945] Conditionally render `UnblockUserModal` instead of using `…
adessy Dec 16, 2025
19f2de5
[TAN-5945] Conditionally render `BlockUserModal` instead of using `op…
adessy Dec 16, 2025
e84ed9b
[TAN-5945] Remove dead code
adessy Dec 16, 2025
5bdee2e
Merge pull request #12576 from CitizenLabDotCo/TAN-5945/ban-email
adessy Dec 16, 2025
70cecbb
Merge branch 'master' into TAN-5943/user-delete-participation-data
adessy Dec 16, 2025
699c4a3
[TAN-5943] Fix count updates in `destroy_user_participation_data`
adessy Jan 7, 2026
0c7a24c
Merge branch 'master' into TAN-5943/user-delete-participation-data
adessy Jan 7, 2026
9c00e3f
New translations en.json (Italian)
cl-dev-bot Jan 7, 2026
11b48c4
Merge pull request #12565 from CitizenLabDotCo/TAN-5943/user-delete-p…
adessy Jan 7, 2026
967512d
New translations en.json (French)
cl-dev-bot Jan 7, 2026
bed9e65
New translations en.json (French)
cl-dev-bot Jan 7, 2026
a47b6f3
New translations en.json (Spanish)
cl-dev-bot Jan 7, 2026
1e53d7b
New translations en.json (Spanish)
cl-dev-bot Jan 7, 2026
805a77b
New translations en.json (Arabic)
cl-dev-bot Jan 7, 2026
766195e
New translations en.json (Arabic)
cl-dev-bot Jan 7, 2026
13e8bbc
New translations en.json (Catalan)
cl-dev-bot Jan 7, 2026
46157ca
New translations en.json (Danish)
cl-dev-bot Jan 7, 2026
424f014
New translations en.json (Danish)
cl-dev-bot Jan 7, 2026
6721d1d
New translations en.json (German)
cl-dev-bot Jan 7, 2026
2eeaf3d
New translations en.json (German)
cl-dev-bot Jan 7, 2026
0f84364
New translations en.json (Greek)
cl-dev-bot Jan 7, 2026
5d78c3e
New translations en.json (Finnish)
cl-dev-bot Jan 7, 2026
0dace30
New translations en.json (Finnish)
cl-dev-bot Jan 7, 2026
db2d97d
New translations en.json (Hungarian)
cl-dev-bot Jan 7, 2026
1ca3730
New translations en.json (Italian)
cl-dev-bot Jan 7, 2026
35714aa
New translations en.json (Italian)
cl-dev-bot Jan 7, 2026
5bc878e
New translations en.json (Lithuanian)
cl-dev-bot Jan 7, 2026
450f410
New translations en.json (Lithuanian)
cl-dev-bot Jan 7, 2026
f5cee6e
New translations en.json (Dutch)
cl-dev-bot Jan 7, 2026
880aa46
New translations en.json (Dutch)
cl-dev-bot Jan 7, 2026
c059855
New translations en.json (Punjabi)
cl-dev-bot Jan 7, 2026
05cc4c5
New translations en.json (Punjabi)
cl-dev-bot Jan 7, 2026
8c1c31e
New translations en.json (Polish)
cl-dev-bot Jan 7, 2026
b14a4f4
New translations en.json (Polish)
cl-dev-bot Jan 7, 2026
33607db
New translations en.json (Serbian (Cyrillic))
cl-dev-bot Jan 7, 2026
8f1ac02
New translations en.json (Serbian (Cyrillic))
cl-dev-bot Jan 7, 2026
5f00659
New translations en.json (Swedish)
cl-dev-bot Jan 7, 2026
e3947f8
New translations en.json (Swedish)
cl-dev-bot Jan 7, 2026
8266944
New translations en.json (Turkish)
cl-dev-bot Jan 7, 2026
8807081
New translations en.json (Turkish)
cl-dev-bot Jan 7, 2026
f87693d
New translations en.json (Urdu (Pakistan))
cl-dev-bot Jan 7, 2026
4767571
New translations en.json (Urdu (Pakistan))
cl-dev-bot Jan 7, 2026
8f26342
New translations en.json (Portuguese, Brazilian)
cl-dev-bot Jan 7, 2026
b38baba
New translations en.json (Portuguese, Brazilian)
cl-dev-bot Jan 7, 2026
9ffbf2a
New translations en.json (Spanish, Chile)
cl-dev-bot Jan 7, 2026
23c154f
New translations en.json (Spanish, Chile)
cl-dev-bot Jan 7, 2026
099be02
New translations en.json (Croatian)
cl-dev-bot Jan 7, 2026
52c652a
New translations en.json (Croatian)
cl-dev-bot Jan 7, 2026
db303a6
New translations en.json (Latvian)
cl-dev-bot Jan 7, 2026
08c8ada
New translations en.json (Latvian)
cl-dev-bot Jan 7, 2026
3c335fa
New translations en.json (English, Canada)
cl-dev-bot Jan 7, 2026
4c06fc6
New translations en.json (English, Canada)
cl-dev-bot Jan 7, 2026
e0c0c89
New translations en.json (English, United Kingdom)
cl-dev-bot Jan 7, 2026
05ecb09
New translations en.json (English, United Kingdom)
cl-dev-bot Jan 7, 2026
8231b53
New translations en.json (Welsh)
cl-dev-bot Jan 7, 2026
af124bc
New translations en.json (Welsh)
cl-dev-bot Jan 7, 2026
556e690
New translations en.json (Luxembourgish)
cl-dev-bot Jan 7, 2026
0832d81
New translations en.json (Norwegian Bokmal)
cl-dev-bot Jan 7, 2026
18ab5fa
New translations en.json (Norwegian Bokmal)
cl-dev-bot Jan 7, 2026
cc85274
New translations en.json (Serbian (Latin))
cl-dev-bot Jan 7, 2026
15f8821
New translations en.json (Serbian (Latin))
cl-dev-bot Jan 7, 2026
cb056d0
New translations en.json (Dutch, Belgium)
cl-dev-bot Jan 7, 2026
a1e60ab
New translations en.json (Dutch, Belgium)
cl-dev-bot Jan 7, 2026
11abbe3
New translations en.json (English, Ireland)
cl-dev-bot Jan 7, 2026
37711c9
New translations en.json (English, Ireland)
cl-dev-bot Jan 7, 2026
af4f1aa
New translations en.json (French, Belgium)
cl-dev-bot Jan 7, 2026
2a3b447
New translations en.json (French, Belgium)
cl-dev-bot Jan 7, 2026
e8a64da
New translations en.json (Greenlandic)
cl-dev-bot Jan 7, 2026
7939aaa
New translations en.json (Moroccan Arabic)
cl-dev-bot Jan 7, 2026
74781cf
New translations en.json (Acholi)
cl-dev-bot Jan 7, 2026
bcb0cf5
New translations en.json (Acholi)
cl-dev-bot Jan 7, 2026
8301f90
New translations en.json (Italian)
cl-dev-bot Jan 7, 2026
48eee0b
Merge pull request #12814 from CitizenLabDotCo/l10n_master
adessy Jan 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions back/.rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ Layout/EndAlignment:
Layout/TrailingWhitespace:
AllowInHeredoc: true

# rspec-parameterized uses a DSL that overrides the `|` operator.
Lint/BinaryOperatorWithIdenticalOperands:
Exclude:
- 'spec/**/*'

Lint/AmbiguousBlockAssociation:
AllowedPatterns:
- change
Expand Down
29 changes: 29 additions & 0 deletions back/app/controllers/web_api/v1/email_bans_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

class WebApi::V1::EmailBansController < ApplicationController
def count
authorize(:email_ban)
render json: raw_json({ count: EmailBan.count })
end

# GET /email_bans/:email
def show
authorize(:email_ban)
ban = EmailBan.find_for(params[:email])
return head :not_found unless ban

render json: WebApi::V1::EmailBanSerializer
.new(ban, params: jsonapi_serializer_params)
.serializable_hash
end

# DELETE /email_bans/:email
def destroy
authorize(:email_ban)
ban = EmailBan.find_for(params[:email])
return head :not_found unless ban

ban.destroy!
head :ok
end
end
18 changes: 16 additions & 2 deletions back/app/controllers/web_api/v1/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class WebApi::V1::UsersController < ApplicationController
include BlockingProfanity

before_action :set_user, only: %i[show update destroy ideas_count comments_count block unblock]
before_action :set_user, only: %i[show update destroy ideas_count comments_count block unblock participation_stats]
skip_before_action :authenticate_user, only: %i[create show check by_slug by_invite ideas_count comments_count]

rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
Expand Down Expand Up @@ -184,7 +184,16 @@ def update
end

def destroy
DeleteUserJob.perform_now(@user.id, current_user)
delete_participation_data = ActiveModel::Type::Boolean.new.cast(params[:delete_participation_data])
ban_email = ActiveModel::Type::Boolean.new.cast(params[:ban_email])

DeleteUserJob.perform_now(
@user.id,
current_user,
delete_participation_data:,
ban_email:,
ban_reason: params[:ban_reason]
)
head :ok
end

Expand Down Expand Up @@ -231,6 +240,11 @@ def comments_count
render json: raw_json({ count: count }), status: :ok
end

def participation_stats
stats = ParticipantsService.new.user_participation_stats(@user)
render json: raw_json(stats)
end

def update_password
@user = current_user
authorize @user
Expand Down
26 changes: 23 additions & 3 deletions back/app/jobs/delete_user_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,29 @@ class DeleteUserJob < ApplicationJob

# @param [User,String] user user or user identifier
# @param [User,NilClass] current_user
def run(user, current_user = nil)
# @param [Boolean] delete_participation_data When true, permanently deletes all user
# content instead of anonymizing it
# @param [Boolean] ban_email When true, bans the user's email from future registration
# @param [String,NilClass] ban_reason Optional reason for the email ban
def run(
user,
current_user = nil,
delete_participation_data: false,
ban_email: false,
ban_reason: nil
)
user = User.find(user) unless user.respond_to?(:id)
user.destroy!
SideFxUserService.new.after_destroy(user, current_user)
email_to_ban = user.email if ban_email

ActiveRecord::Base.transaction do
ParticipantsService.new.destroy_user_participation_data(user) if delete_participation_data
user.destroy!
EmailBan.ban!(email_to_ban, reason: ban_reason, banned_by: current_user) if email_to_ban.present?
end

SideFxUserService.new.after_destroy(
user, current_user,
participation_data_deleted: delete_participation_data
)
end
end
57 changes: 57 additions & 0 deletions back/app/models/email_ban.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

# == Schema Information
#
# Table name: email_bans
#
# id :uuid not null, primary key
# normalized_email_hash :string not null
# reason :text
# banned_by_id :uuid
# created_at :datetime not null
#
# Indexes
#
# index_email_bans_on_banned_by_id (banned_by_id)
# index_email_bans_on_normalized_email_hash (normalized_email_hash) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (banned_by_id => users.id)
#

# Following the principle of data minimization, we store email hashes instead of the
# actual emails. This keeps things simpler for GDPR compliance and avoids issues with
# banned users objecting to their email being stored.
class EmailBan < ApplicationRecord
belongs_to :banned_by, class_name: 'User', optional: true

validates :normalized_email_hash, presence: true, uniqueness: true

class << self
def banned?(email)
normalized = EmailNormalizationService.normalize(email)
exists?(normalized_email_hash: sha256(normalized))
end

def ban!(email, reason: nil, banned_by: nil)
normalized = EmailNormalizationService.normalize(email)
hash = sha256(normalized)

record = find_or_initialize_by(normalized_email_hash: hash)
record.update!(reason: reason, banned_by: banned_by)
record
end

def find_for(email)
normalized = EmailNormalizationService.normalize(email)
find_by(normalized_email_hash: sha256(normalized))
end

private

def sha256(string)
Digest::SHA256.hexdigest(string)
end
end
end
31 changes: 31 additions & 0 deletions back/app/models/files/restricted_file.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
# frozen_string_literal: true

# == Schema Information
#
# Table name: files
#
# id :uuid not null, primary key
# name :string
# content :string
# uploader_id(the user who uploaded the file) :uuid
# created_at :datetime not null
# updated_at :datetime not null
# size(in bytes) :integer
# mime_type :string
# category :string default("other"), not null
# description_multiloc :jsonb
# tsvector :tsvector
# ai_processing_allowed(whether consent was given to process the file with AI) :boolean default(FALSE), not null
#
# Indexes
#
# index_files_on_category (category)
# index_files_on_description_multiloc_text_gin_trgm_ops (((description_multiloc)::text) gin_trgm_ops) USING gin
# index_files_on_mime_type (mime_type)
# index_files_on_name_gin_trgm (name) USING gin
# index_files_on_size (size)
# index_files_on_tsvector (tsvector) USING gin
# index_files_on_uploader_id (uploader_id)
#
# Foreign Keys
#
# fk_rails_... (uploader_id => users.id)
#
# temporary-fix-for-vienna-svg-security-issue
module Files
class RestrictedFile < File
Expand Down
15 changes: 15 additions & 0 deletions back/app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ def oldest_admin
validate :validate_not_duplicate_new_email
validate :validate_can_update_email, on: :form_submission # only called if `save` is called w/ `context: :form_submission`
validate :validate_email_domains_blacklist, if: :email_or_new_email_changed?
validate :validate_emails_not_banned, if: :email_or_new_email_changed?

before_destroy :remove_initiated_notifications # Must occur before has_many :notifications (see https://github.com/rails/rails/issues/5205)
has_many :notifications, foreign_key: :recipient_id, dependent: :destroy
Expand Down Expand Up @@ -387,6 +388,20 @@ def validate_email_domain_blacklist(email_field)
end
end

def validate_emails_not_banned
validate_email_not_banned(:email)
validate_email_not_banned(:new_email)
end

def validate_email_not_banned(field)
value = send(field)
return if value.blank?
return unless EmailBan.banned?(value)

errors.add(field, 'something_went_wrong', code: 'zrb-43')
Rails.logger.info "Validation error! Email banned: #{value.split('@')&.last}"
end

def remove_initiated_notifications
initiator_notifications.each do |notification|
unless notification.update initiating_user: nil
Expand Down
15 changes: 15 additions & 0 deletions back/app/policies/email_ban_policy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# frozen_string_literal: true

class EmailBanPolicy < ApplicationPolicy
def count?
active_admin?
end

def show?
active_admin?
end

def destroy?
active_admin?
end
end
16 changes: 10 additions & 6 deletions back/app/policies/user_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,12 @@ def seats?
end

def index_xlsx?
user&.active? && user.admin?
active_admin?
end

def create?
app_config = AppConfiguration.instance

allow_signup = app_config.feature_activated?('password_login') && app_config.settings('password_login', 'enable_signup')

allow_signup || active_admin?
end

Expand All @@ -60,15 +58,15 @@ def update?
end

def destroy?
record.id == user&.id || (user&.active? && user.admin?)
record.id == user&.id || active_admin?
end

def block?
user&.active? && user.admin?
active_admin?
end

def unblock?
user&.active? && user.admin?
active_admin?
end

def blocked_count?
Expand All @@ -83,6 +81,12 @@ def comments_count?
true
end

def participation_stats?
# Currently, participation_stats is only used in the delete user modal, so only admin
# users should have access (self is not even used).
record.id == user&.id || active_admin?
end

def update_password?
user&.active? && (record.id == user.id)
end
Expand Down
7 changes: 7 additions & 0 deletions back/app/serializers/web_api/v1/email_ban_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class WebApi::V1::EmailBanSerializer < WebApi::V1::BaseSerializer
attributes :reason, :created_at

belongs_to :banned_by, serializer: WebApi::V1::UserSerializer
end
29 changes: 29 additions & 0 deletions back/app/services/email_normalization_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# frozen_string_literal: true

class EmailNormalizationService
# Providers that support plus-addressing (user+tag@domain.com)
PLUS_ADDRESSING_DOMAINS = %w[
gmail.com googlemail.com
outlook.com hotmail.com live.com
yahoo.com
protonmail.com proton.me
].freeze

# Gmail ignores dots in local part
GMAIL_DOMAINS = %w[gmail.com googlemail.com].freeze

# Be careful when changing this method. Removing a normalization rule could
# potentially unban emails that were previously banned.
def self.normalize(email)
return '' if email.blank?

local, domain = email.strip.downcase.split('@', 2)
return email.strip.downcase unless domain

local = local.delete('.') if GMAIL_DOMAINS.include?(domain)
local = local.split('+').first if PLUS_ADDRESSING_DOMAINS.include?(domain)
domain = 'gmail.com' if domain.in?(GMAIL_DOMAINS)

"#{local}@#{domain}"
end
end
3 changes: 2 additions & 1 deletion back/app/services/invites/error_storage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ class Invites::ErrorStorage
invalid_row: 'invalid_row',
email_already_invited: 'email_already_invited',
email_already_active: 'email_already_active',
emails_duplicate: 'emails_duplicate'
emails_duplicate: 'emails_duplicate',
email_banned: 'email_banned'
}

def initialize
Expand Down
2 changes: 2 additions & 0 deletions back/app/services/invites/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ def validate_invitee(invitee)
add_error(:unknown_locale, row: @current_row, value: error_descriptor[:value], raw_error: e)
elsif field == :email && error_descriptor[:error] == :invalid
add_error(:invalid_email, row: @current_row, value: error_descriptor[:value], raw_error: e)
elsif field == :email && error_descriptor[:code] == 'zrb-43'
add_error(:email_banned, row: @current_row, value: error_descriptor[:value], raw_error: e)
# :taken and :taken_by_invite should not happen after 662da0dc85
# ToDo: remove these two elsif branches and the `ignore` option of `add_error`.
elsif field == :email && error_descriptor[:error] == :taken
Expand Down
Loading