Skip to content

Commit 4ad463c

Browse files
authored
WIP: feat(captcha): implement captcha validation hooks and integrate … (#1129)
This pull request introduces extensible support for CAPTCHA validation in the user registration flow, allowing host applications to add custom security checks (such as Turnstile or reCAPTCHA) without modifying the core codebase. The changes include new hook methods in the controller, updates to the registration view for easy extension, and improved error messaging with localization. **Extensibility for CAPTCHA Validation** * Added `validate_captcha_if_enabled` and `handle_captcha_validation_failure` hook methods to `BetterTogether::Users::RegistrationsController`, allowing host apps to implement and customize CAPTCHA logic and error handling. Default implementations are provided and can be overridden. * Updated the registration controller to call CAPTCHA validation before creating a user, and to handle validation failures gracefully. **View and Form Extensibility** * Added a partial `_extra_registration_fields.html.erb` with guidance for host apps to add custom registration fields (e.g., CAPTCHA), and rendered this partial in the registration form. [[1]](diffhunk://#diff-ecf6f38f2429afe717f11900326fcd2b19a886656b387b435478a18908a517ebR1-R3) [[2]](diffhunk://#diff-744d99555c831425cf5883c199055bc537850fbdd075776c69dbad920497bb1fR133-R135) **Localization and Error Messaging** * Added `captcha_validation_failed` error message to English, Spanish, and French locale files for improved user feedback on CAPTCHA failures. [[1]](diffhunk://#diff-44438ce218f5287c58d0017f965d888715635d94280669896f75841fbd7b4cd7R1724) [[2]](diffhunk://#diff-4bbf4ee302c9607c80408361708b8b9fde3ee7afc5b505bfd69d429dd433f915R1740) [[3]](diffhunk://#diff-2c5ab6165f7efe573a84107e0e51102ad47cefb0c65629759d7458eee14326e7R1748) **Testing** * Added controller specs to verify the default behavior of the new CAPTCHA hook methods, ensuring extensibility and reliability.
2 parents 936f893 + b5b6bd9 commit 4ad463c

File tree

7 files changed

+158
-66
lines changed

7 files changed

+158
-66
lines changed

app/controllers/better_together/users/registrations_controller.rb

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,19 @@ def new
7474
end
7575
end
7676

77-
def create
77+
def create # rubocop:todo Metrics/MethodLength
7878
unless agreements_accepted?
7979
handle_agreements_not_accepted
8080
return
8181
end
8282

83+
# Validate captcha if enabled by host application
84+
unless validate_captcha_if_enabled?
85+
build_resource(sign_up_params)
86+
handle_captcha_validation_failure(resource)
87+
return
88+
end
89+
8390
ActiveRecord::Base.transaction do
8491
super do |user|
8592
handle_user_creation(user) if user.persisted?
@@ -104,6 +111,25 @@ def set_required_agreements
104111
@code_of_conduct_agreement = BetterTogether::Agreement.find_by(identifier: 'code_of_conduct')
105112
end
106113

114+
# Hook method for host applications to implement captcha validation
115+
# Override this method in host applications to add Turnstile or other captcha validation
116+
# @return [Boolean] true if captcha is valid or not enabled, false if validation fails
117+
def validate_captcha_if_enabled?
118+
# Default implementation - no captcha validation
119+
# Host applications should override this method to implement their captcha logic
120+
true
121+
end
122+
123+
# Hook method for host applications to handle captcha validation failures
124+
# Override this method in host applications to customize error handling
125+
# @param resource [User] the user resource being created
126+
def handle_captcha_validation_failure(resource)
127+
# Default implementation - adds a generic error message
128+
resource.errors.add(:base, I18n.t('better_together.registrations.captcha_validation_failed',
129+
default: 'Security verification failed. Please try again.'))
130+
respond_with resource
131+
end
132+
107133
def after_sign_up_path_for(resource)
108134
# Redirect to event if signed up via event invitation
109135
return better_together.event_path(@event_invitation.event) if @event_invitation&.event
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<%# Override this partial in your host app to add additional fields to the registration form %>
2+
<%# Place custom form elements here (e.g., CAPTCHA, extra validation fields, etc.) %>
3+
<%# Available locals: form (form builder), resource (user resource being created) %>

app/views/devise/registrations/new.html.erb

Lines changed: 54 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -29,112 +29,119 @@
2929
<% else %>
3030
<div class="row justify-content-center">
3131
<div class="col-md-6">
32-
<h2 class="mb-4"><%= t('.sign_up') %></h2>
32+
<h2 id="registration-title" class="mb-4"><%= t('.sign_up') %></h2>
3333

34-
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { class: 'card card-body needs-validation', novalidate: true }, data: { controller: 'better_together--form-validation', turbo: false }) do |f| %>
34+
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { id: 'registration-form', class: 'card card-body needs-validation registration-form', novalidate: true }, data: { controller: 'better_together--form-validation', turbo: false }) do |f| %>
3535
<%= render "devise/shared/error_messages", resource: resource %>
3636

3737
<% if @platform_invitation %>
38-
<%= hidden_field_tag :invitation_code, @platform_invitation.token %>
38+
<%= hidden_field_tag :invitation_code, @platform_invitation.token, id: 'invitation-code-field' %>
3939
<% end %>
4040

4141
<!-- Email Field -->
42-
<div class="mb-3">
43-
<%= f.label :email, t('.email.label'), class: 'form-label' %>
44-
<%= f.email_field :email, autofocus: true, autocomplete: "email", class: 'form-control', required: true %>
45-
<small class="form-text text-muted"><%= t('.email.help') %></small>
42+
<div id="email-field-group" class="mb-3 form-field-group">
43+
<%= f.label :email, t('.email.label'), class: 'form-label', for: 'user_email' %>
44+
<%= f.email_field :email, autofocus: true, autocomplete: "email", class: 'form-control', required: true, id: 'user_email' %>
45+
<small id="email-help-text" class="form-text text-muted"><%= t('.email.help') %></small>
4646
</div>
4747

4848
<!-- Password Field -->
49-
<div class="mb-3" data-controller="better_together--password-toggle">
50-
<%= f.label :password, t('.password.label'), class: 'form-label' %>
49+
<div id="password-field-group" class="mb-3 form-field-group" data-controller="better_together--password-toggle">
50+
<%= f.label :password, t('.password.label'), class: 'form-label', for: 'user_password' %>
5151
<% if @minimum_password_length %>
52-
<em><%= t('devise.shared.minimum_password_length', count: @minimum_password_length) %></em>
52+
<em id="password-length-requirement"><%= t('devise.shared.minimum_password_length', count: @minimum_password_length) %></em>
5353
<% end %>
5454
<div class="input-group">
55-
<%= f.password_field :password, autocomplete: "current-password", class: 'form-control', "data-target": "better_together--password-toggle.field", required: true, minlength: @minimum_password_length || 12 %>
56-
<button type="button" data-action="click->better_together--password-toggle#password" class="btn btn-outline-secondary" data-bs-toggle="tooltip" title="<%= t('devise.sessions.new.password.toggle') %>">
55+
<%= f.password_field :password, autocomplete: "current-password", class: 'form-control', "data-target": "better_together--password-toggle.field", required: true, minlength: @minimum_password_length || 12, id: 'user_password' %>
56+
<button id="password-toggle-btn" type="button" data-action="click->better_together--password-toggle#password" class="btn btn-outline-secondary" data-bs-toggle="tooltip" title="<%= t('devise.sessions.new.password.toggle') %>">
5757
<i class="password-field-icon-1 far fa-eye-slash" data-target="better_together--password-toggle.icon"></i>
5858
</button>
5959
</div>
60-
<small class="form-text text-muted"><%= t('.password.help') %></small>
60+
<small id="password-help-text" class="form-text text-muted"><%= t('.password.help') %></small>
6161
</div>
6262

6363
<!-- Password Confirmation Field -->
64-
<div class="mb-3" data-controller="better_together--password-toggle">
65-
<%= f.label :password_confirmation, t('.password_confirmation.label'), class: 'form-label' %>
64+
<div id="password-confirmation-field-group" class="mb-3 form-field-group" data-controller="better_together--password-toggle">
65+
<%= f.label :password_confirmation, t('.password_confirmation.label'), class: 'form-label', for: 'user_password_confirmation' %>
6666

6767
<div class="input-group">
68-
<%= f.password_field :password_confirmation, autocomplete: "current-password", class: 'form-control', "data-target": "better_together--password-toggle.field", required: true, minlength: @minimum_password_length || 12 %>
69-
<button type="button" data-action="click->better_together--password-toggle#password" class="btn btn-outline-secondary" data-bs-toggle="tooltip" title="<%= t('devise.sessions.new.password.toggle') %>">
68+
<%= f.password_field :password_confirmation, autocomplete: "current-password", class: 'form-control', "data-target": "better_together--password-toggle.field", required: true, minlength: @minimum_password_length || 12, id: 'user_password_confirmation' %>
69+
<button id="password-confirmation-toggle-btn" type="button" data-action="click->better_together--password-toggle#password" class="btn btn-outline-secondary" data-bs-toggle="tooltip" title="<%= t('devise.sessions.new.password.toggle') %>">
7070
<i class="password-field-icon-1 far fa-eye-slash" data-target="better_together--password-toggle.icon"></i>
7171
</button>
7272
</div>
73-
<small class="form-text text-muted"><%= t('.password_confirmation.help') %></small>
73+
<small id="password-confirmation-help-text" class="form-text text-muted"><%= t('.password_confirmation.help') %></small>
7474
</div>
7575

76-
<div id="profile-details" class="mb-4">
77-
<h4><%= t('.profile_details') %></h4>
76+
<div id="profile-details" class="mb-4 profile-details-section">
77+
<h4 id="profile-details-title"><%= t('.profile_details') %></h4>
7878
<!-- Person Identification Fields -->
7979
<%= f.fields_for :person do |person_form| %>
8080
<!-- Name Field -->
81-
<div class="mb-3">
82-
<%= person_form.label :name, t('.person.name'), class: 'form-label' %>
83-
<%= person_form.text_field :name, class: "form-control", required: true %>
84-
<small class="form-text text-muted"><%= t('.person.name_hint') %></small>
81+
<div id="name-field-group" class="mb-3 form-field-group">
82+
<%= person_form.label :name, t('.person.name'), class: 'form-label', for: 'user_person_attributes_name' %>
83+
<%= person_form.text_field :name, class: "form-control", required: true, id: 'user_person_attributes_name' %>
84+
<small id="name-help-text" class="form-text text-muted"><%= t('.person.name_hint') %></small>
8585
</div>
8686

8787
<!-- Username Field -->
88-
<div class="mb-3">
89-
<%= person_form.label :identifier, t('.person.identifier'), class: 'form-label' %>
90-
<%= person_form.text_field :identifier, class: "form-control", required: true, minlength: 3 %>
88+
<div id="identifier-field-group" class="mb-3 form-field-group">
89+
<%= person_form.label :identifier, t('.person.identifier'), class: 'form-label', for: 'user_person_attributes_identifier' %>
90+
<%= person_form.text_field :identifier, class: "form-control", required: true, minlength: 3, id: 'user_person_attributes_identifier' %>
9191
<!-- Hint text for the Handle -->
92-
<small class="form-text text-muted"><%= t('.person.identifier_hint_html', platform: host_platform) %></small>
92+
<small id="identifier-help-text" class="form-text text-muted"><%= t('.person.identifier_hint_html', platform: host_platform) %></small>
9393
</div>
9494

9595
<!-- Description Field -->
96-
<div class="mb-3">
97-
<%= person_form.label :description, t('.person.description'), class: 'form-label' %>
98-
<%= person_form.text_area :description, class: "form-control" %>
99-
<small class="form-text text-muted"><%= t('.person.description_hint') %></small>
96+
<div id="description-field-group" class="mb-3 form-field-group">
97+
<%= person_form.label :description, t('.person.description'), class: 'form-label', for: 'user_person_attributes_description' %>
98+
<%= person_form.text_area :description, class: "form-control", id: 'user_person_attributes_description' %>
99+
<small id="description-help-text" class="form-text text-muted"><%= t('.person.description_hint') %></small>
100100
</div>
101101
<% end %>
102102
</div>
103103

104104
<% if @terms_of_service_agreement || @privacy_policy_agreement || @code_of_conduct_agreement %>
105+
<div id="agreements-section" class="agreements-section">
105106
<% if @terms_of_service_agreement %>
106-
<div class="mb-3 form-check">
107-
<%= check_box_tag :terms_of_service_agreement, '1', false, class: 'form-check-input agreement-checkbox', data: { agreement_identifier: 'terms_of_service' }, required: true, aria: { describedby: 'terms-of-service-summary', disabled: 'true' } %>
108-
<%= label_tag :terms_of_service_agreement, t('.terms_of_service.label'), class: 'form-check-label' %>
107+
<div id="terms-of-service-agreement-group" class="mb-3 form-check agreement-group">
108+
<%= check_box_tag :terms_of_service_agreement, '1', false, class: 'form-check-input agreement-checkbox', data: { agreement_identifier: 'terms_of_service' }, required: true, aria: { describedby: 'terms-of-service-summary', disabled: 'true' }, id: 'terms_of_service_agreement_checkbox' %>
109+
<%= label_tag :terms_of_service_agreement, t('.terms_of_service.label'), class: 'form-check-label', for: 'terms_of_service_agreement_checkbox' %>
109110
<%= link_to t('.view', default: 'View'), agreement_path(@terms_of_service_agreement, locale: I18n.locale), class: 'ms-2 agreement-modal-link', role: 'button', data: { agreement_identifier: 'terms_of_service' } %>
110-
<p id="terms-of-service-summary" class="form-text text-muted"><%= @terms_of_service_agreement.description %></p>
111+
<small id="terms-of-service-summary" class="form-text text-muted"><%= @terms_of_service_agreement.description %></small>
111112
</div>
112113
<% end %>
113114

114115
<% if @privacy_policy_agreement %>
115-
<div class="mb-3 form-check">
116-
<%= check_box_tag :privacy_policy_agreement, '1', false, class: 'form-check-input agreement-checkbox', data: { agreement_identifier: 'privacy_policy' }, required: true, aria: { describedby: 'privacy-policy-summary', disabled: 'true' } %>
117-
<%= label_tag :privacy_policy_agreement, t('.privacy_policy.label'), class: 'form-check-label' %>
116+
<div id="privacy-policy-agreement-group" class="mb-3 form-check agreement-group">
117+
<%= check_box_tag :privacy_policy_agreement, '1', false, class: 'form-check-input agreement-checkbox', data: { agreement_identifier: 'privacy_policy' }, required: true, aria: { describedby: 'privacy-policy-summary', disabled: 'true' }, id: 'privacy_policy_agreement_checkbox' %>
118+
<%= label_tag :privacy_policy_agreement, t('.privacy_policy.label'), class: 'form-check-label', for: 'privacy_policy_agreement_checkbox' %>
118119
<%= link_to t('.view', default: 'View'), agreement_path(@privacy_policy_agreement, locale: I18n.locale), class: 'ms-2 agreement-modal-link', role: 'button', data: { agreement_identifier: 'privacy_policy' } %>
119-
<p id="privacy-policy-summary" class="form-text text-muted"><%= @privacy_policy_agreement.description %></p>
120+
<small id="privacy-policy-summary" class="form-text text-muted"><%= @privacy_policy_agreement.description %></small>
120121
</div>
121122
<% end %>
122123

123124
<% if @code_of_conduct_agreement %>
124-
<div class="mb-3 form-check">
125-
<%= check_box_tag :code_of_conduct_agreement, '1', false, class: 'form-check-input agreement-checkbox', data: { agreement_identifier: 'code_of_conduct' }, required: true, aria: { describedby: 'code-of-conduct-summary', disabled: 'true' } %>
126-
<%= label_tag :code_of_conduct_agreement, t('.code_of_conduct.label'), class: 'form-check-label' %>
125+
<div id="code-of-conduct-agreement-group" class="mb-3 form-check agreement-group">
126+
<%= check_box_tag :code_of_conduct_agreement, '1', false, class: 'form-check-input agreement-checkbox', data: { agreement_identifier: 'code_of_conduct' }, required: true, aria: { describedby: 'code-of-conduct-summary', disabled: 'true' }, id: 'code_of_conduct_agreement_checkbox' %>
127+
<%= label_tag :code_of_conduct_agreement, t('.code_of_conduct.label'), class: 'form-check-label', for: 'code_of_conduct_agreement_checkbox' %>
127128
<%= link_to t('.view', default: 'View'), agreement_path(@code_of_conduct_agreement, locale: I18n.locale), class: 'ms-2 agreement-modal-link', role: 'button', data: { agreement_identifier: 'code_of_conduct' } %>
128-
<p id="code-of-conduct-summary" class="form-text text-muted"><%= @code_of_conduct_agreement.description %></p>
129+
<small id="code-of-conduct-summary" class="form-text text-muted"><%= @code_of_conduct_agreement.description %></small>
129130
</div>
130131
<% end %>
132+
</div>
131133
<% end %>
132134

135+
<!-- Host App Extensible Content (e.g., CAPTCHA, additional fields) -->
136+
<%= render partial: 'devise/registrations/extra_registration_fields', locals: { form: f, resource: resource } %>
137+
133138
<!-- Submit Button -->
134-
<div class="text-center">
135-
<%= f.submit t('.sign_up'), class: 'btn btn-primary' %>
139+
<div id="submit-section" class="text-center submit-section">
140+
<%= f.submit t('.sign_up'), class: 'btn btn-primary', id: 'registration-submit-btn' %>
136141
<!-- Additional Links -->
137-
<%= render "devise/shared/links" %>
142+
<div id="additional-links" class="additional-links">
143+
<%= render "devise/shared/links" %>
144+
</div>
138145
</div>
139146
<% end %>
140147
</div>

config/locales/en.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1418,6 +1418,8 @@ en:
14181418
updated_on_short: Updated
14191419
view_post: View Post
14201420
primary: Primary
1421+
registrations:
1422+
captcha_validation_failed: Security verification failed. Please try again.
14211423
remove: Remove
14221424
resource_permissions:
14231425
index:
@@ -1721,6 +1723,8 @@ en:
17211723
agreements_must_accept: You must accept the Terms of Service and Privacy Policy
17221724
to continue.
17231725
agreements_required: You must accept the Privacy Policy and Terms of Service
1726+
captcha_validation_failed: Security verification failed. Please complete the
1727+
security check and try again.
17241728
code_of_conduct:
17251729
label: I agree to the Code of Conduct
17261730
email:

config/locales/es.yml

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,6 +1428,9 @@ es:
14281428
updated_on_short: Actualizado
14291429
view_post: Ver publicación
14301430
primary: Primario
1431+
registrations:
1432+
captcha_validation_failed: La verificación de seguridad falló. Por favor, inténtelo
1433+
de nuevo.
14311434
remove: Eliminar
14321435
resource_permissions:
14331436
index:
@@ -1732,13 +1735,16 @@ es:
17321735
actual para confirmar los cambios
17331736
new:
17341737
agreements:
1735-
privacy_policy: I agree to the Privacy Policy
1736-
terms_of_service: I agree to the Terms of Service
1737-
agreements_must_accept: You must accept the Terms of Service and Privacy Policy
1738-
to continue.
1739-
agreements_required: You must accept the Privacy Policy and Terms of Service
1738+
privacy_policy: Acepto la Política de Privacidad
1739+
terms_of_service: Acepto los Términos de Servicio
1740+
agreements_must_accept: Debe aceptar los Términos de Servicio y la Política
1741+
de Privacidad para continuar.
1742+
agreements_required: Debe aceptar la Política de Privacidad y los Términos
1743+
de Servicio
1744+
captcha_validation_failed: La verificación de seguridad falló. Por favor,
1745+
completa la verificación de seguridad e inténtalo de nuevo.
17401746
code_of_conduct:
1741-
label: I agree to the Code of Conduct
1747+
label: Acepto el Código de Conducta
17421748
email:
17431749
help: Por favor, introduzca una dirección de correo válida.
17441750
label: Correo Electrónico
@@ -1764,13 +1770,13 @@ es:
17641770
name: Nombre
17651771
name_hint: Por favor, proporcione su nombre completo.
17661772
privacy_policy:
1767-
label: I agree to the Privacy Policy
1773+
label: Acepto la Política de Privacidad
17681774
profile_details: Detalles del Perfil
17691775
sign_up: Registrarse
17701776
submit: Enviar
17711777
terms_of_service:
1772-
label: I agree to the Terms of Service
1773-
view: View
1778+
label: Acepto los Términos de Servicio
1779+
view: Ver
17741780
signed_up: Bienvenido. Tu cuenta fue creada.
17751781
signed_up_but_inactive: Tu cuenta ha sido creada correctamente. Sin embargo,
17761782
no hemos podido iniciar la sesión porque tu cuenta aún no está activada.

0 commit comments

Comments
 (0)