Add age verification on sign-up (#34150)

This commit is contained in:
Eugen Rochko 2025-03-14 15:07:29 +01:00 committed by GitHub
parent 4a6cf67c46
commit d213c585ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 268 additions and 38 deletions

View file

@ -119,7 +119,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def account_params
params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code)
params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth)
end
def invite

View file

@ -62,7 +62,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def configure_sign_up_params
devise_parameter_sanitizer.permit(:sign_up) do |user_params|
user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password, :date_of_birth)
end
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
class DateOfBirthInput < SimpleForm::Inputs::Base
OPTIONS = [
{ autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze,
{ autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze,
{ autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze,
].freeze
def input(wrapper_options = nil)
merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
merged_input_options[:inputmode] = 'numeric'
values = (object.public_send(attribute_name) || '').split('.')
safe_join(Array.new(3) do |index|
options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index]
@builder.text_field("#{attribute_name}(#{index + 1}i)", options)
end)
end
def label_target
"#{attribute_name}_1i"
end
private
def generate_id(index)
"#{object_name}_#{attribute_name}_#{index + 1}i"
end
end

View file

@ -353,6 +353,22 @@ code {
}
}
.input.date_of_birth .label_input {
display: flex;
gap: 8px;
align-items: center;
input {
box-sizing: content-box;
width: 32px;
flex: 0;
&:last-child {
width: 64px;
}
}
}
.input.select.select--languages {
min-width: 32ch;
}

View file

@ -39,12 +39,14 @@ class Form::AdminSettings
authorized_fetch
app_icon
favicon
min_age
).freeze
INTEGER_KEYS = %i(
media_cache_retention_period
content_cache_retention_period
backups_retention_period
min_age
).freeze
BOOLEAN_KEYS = %i(
@ -88,6 +90,7 @@ class Form::AdminSettings
validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks) }
validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks_rationale) }
validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) }
validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) }
validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) }
validates :status_page_url, url: true, allow_blank: true
validate :validate_site_uploads

View file

@ -5,41 +5,42 @@
# Table name: users
#
# id :bigint(8) not null, primary key
# email :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
# encrypted_password :string default(""), not null
# reset_password_token :string
# reset_password_sent_at :datetime
# sign_in_count :integer default(0), not null
# current_sign_in_at :datetime
# last_sign_in_at :datetime
# age_verified_at :datetime
# approved :boolean default(TRUE), not null
# chosen_languages :string is an Array
# confirmation_sent_at :datetime
# confirmation_token :string
# confirmed_at :datetime
# confirmation_sent_at :datetime
# unconfirmed_email :string
# locale :string
# consumed_timestep :integer
# current_sign_in_at :datetime
# disabled :boolean default(FALSE), not null
# email :string default(""), not null
# encrypted_otp_secret :string
# encrypted_otp_secret_iv :string
# encrypted_otp_secret_salt :string
# consumed_timestep :integer
# otp_required_for_login :boolean default(FALSE), not null
# encrypted_password :string default(""), not null
# last_emailed_at :datetime
# last_sign_in_at :datetime
# locale :string
# otp_backup_codes :string is an Array
# account_id :bigint(8) not null
# disabled :boolean default(FALSE), not null
# invite_id :bigint(8)
# chosen_languages :string is an Array
# created_by_application_id :bigint(8)
# approved :boolean default(TRUE), not null
# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# reset_password_sent_at :datetime
# reset_password_token :string
# settings :text
# sign_in_count :integer default(0), not null
# sign_in_token :string
# sign_in_token_sent_at :datetime
# webauthn_id :string
# sign_up_ip :inet
# role_id :bigint(8)
# settings :text
# time_zone :string
# otp_secret :string
# unconfirmed_email :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8) not null
# created_by_application_id :bigint(8)
# invite_id :bigint(8)
# role_id :bigint(8)
# webauthn_id :string
#
class User < ApplicationRecord
@ -111,6 +112,7 @@ class User < ApplicationRecord
validates_with RegistrationFormTimeValidator, on: :create
validates :website, absence: true, on: :create
validates :confirm_password, absence: true, on: :create
validates :date_of_birth, presence: true, date_of_birth: true, on: :create, if: -> { Setting.min_age.present? }
validate :validate_role_elevation
scope :account_not_suspended, -> { joins(:account).merge(Account.without_suspended) }
@ -129,6 +131,7 @@ class User < ApplicationRecord
before_validation :sanitize_role
before_create :set_approved
before_create :set_age_verified_at
after_commit :send_pending_devise_notifications
after_create_commit :trigger_webhooks
@ -140,7 +143,7 @@ class User < ApplicationRecord
delegate :can?, to: :role
attr_reader :invite_code
attr_reader :invite_code, :date_of_birth
attr_writer :external, :bypass_invite_request_check, :current_account
def self.those_who_can(*any_of_privileges)
@ -157,6 +160,17 @@ class User < ApplicationRecord
Rails.env.local?
end
def date_of_birth=(hash_or_string)
@date_of_birth = begin
if hash_or_string.is_a?(Hash)
day, month, year = hash_or_string.values_at(1, 2, 3)
"#{day}.#{month}.#{year}"
else
hash_or_string
end
end
end
def role
if role_id.nil?
UserRole.everyone
@ -432,6 +446,10 @@ class User < ApplicationRecord
end
end
def set_age_verified_at
self.age_verified_at = Time.now.utc if Setting.min_age.present?
end
def grant_approval_on_confirmation?
# Re-check approval on confirmation if the server has switched to open registrations
open_registrations? && !sign_up_from_ip_requires_approval? && !sign_up_email_requires_approval?

View file

@ -107,6 +107,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
enabled: registrations_enabled?,
approval_required: Setting.registrations_mode == 'approved',
message: registrations_enabled? ? nil : registrations_message,
min_age: Setting.min_age.presence,
url: ENV.fetch('SSO_ACCOUNT_SIGN_UP', nil),
}
end

View file

@ -41,7 +41,7 @@ class AppSignUpService < BaseService
end
def user_params
@params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code)
@params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code, :date_of_birth)
end
def account_params

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
class DateOfBirthValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
record.errors.add(attribute, :below_limit) if value.present? && value.to_date > min_age.ago
rescue Date::Error
record.errors.add(attribute, :invalid)
end
private
def min_age
Setting.min_age.to_i.years
end
end

View file

@ -12,6 +12,9 @@
.flash-message= t('admin.settings.registrations.moderation_recommandation')
.fields-group
= f.input :min_age, as: :string, wrapper: :with_block_label, input_html: { inputmode: 'numeric' }
.fields-row
.fields-row__column.fields-row__column-6.fields-group
= f.input :registrations_mode,

View file

@ -21,20 +21,19 @@
= f.simple_fields_for :account do |ff|
= ff.input :username,
append: "@#{site_hostname}",
input_html: { 'aria-label': t('simple_form.labels.defaults.username'), autocomplete: 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT },
label: false,
input_html: { autocomplete: 'off', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT, placeholder: ' ' },
required: true,
wrapper: :with_label
= f.input :email,
hint: false,
input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'username' },
placeholder: t('simple_form.labels.defaults.email'),
required: true
input_html: { autocomplete: 'username', placeholder: ' ' },
required: true,
wrapper: :with_label
= f.input :password,
hint: false,
input_html: { 'aria-label': t('simple_form.labels.defaults.password'), autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last },
placeholder: t('simple_form.labels.defaults.password'),
required: true
input_html: { autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last, placeholder: ' ' },
required: true,
wrapper: :with_label
= f.input :password_confirmation,
hint: false,
input_html: { 'aria-label': t('simple_form.labels.defaults.confirm_password'), autocomplete: 'new-password', maxlength: User.password_length.last },
@ -53,6 +52,14 @@
required: false,
wrapper: :with_label
- if Setting.min_age.present?
.fields-group
= f.input :date_of_birth,
as: :date_of_birth,
hint: t('simple_form.hints.user.date_of_birth', age: Setting.min_age.to_i),
required: true,
wrapper: :with_block_label
- if approved_registrations? && @invite.blank?
%p.lead= t('auth.sign_up.manual_review', domain: site_hostname)