[IMP] google_recaptcha, *: integrate recaptchaV3
* = base_setup, website_form, website_sale, website_crm, crm_iap_lead_website, website_hr_recruitment, website_mass_mailing Integrate reCaptchaV3 on website_form submit and website_mass_mailing subscription. You can now use ReCaptchaV3 to add reCaptcha verification in any module using google_recaptcha. Also added a better error management on the form with custom messages. task-2217980 closes odoo/odoo#48466 Related: odoo/enterprise#9649 Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
This commit is contained in:
@@ -30,6 +30,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
module_web_unsplash = fields.Boolean("Unsplash Image Library")
|
||||
module_partner_autocomplete = fields.Boolean("Partner Autocomplete")
|
||||
module_base_geolocalize = fields.Boolean("GeoLocalize")
|
||||
module_google_recaptcha = fields.Boolean("reCAPTCHA: Easy on Humans, Hard on Bots")
|
||||
report_footer = fields.Text(related="company_id.report_footer", string='Custom Report Footer', help="Footer text displayed at the bottom of all reports.", readonly=False)
|
||||
group_multi_currency = fields.Boolean(string='Multi-Currencies',
|
||||
implied_group='base.group_multi_currency',
|
||||
|
||||
@@ -354,6 +354,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="recaptcha">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_google_recaptcha"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane" id="website_recaptcha_settings">
|
||||
<label for="module_google_recaptcha"/>
|
||||
<div class="text-muted">
|
||||
reCAPTCHA is a free service that protects your website from spam and abuse.
|
||||
</div>
|
||||
<div class="content-group" attrs="{'invisible': [('module_google_recaptcha', '=', False)]}" id="recaptcha_warning">
|
||||
<div class="mt16 text-warning"><strong>Save</strong> this page and come back here to set up reCaptcha.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<widget name='res_config_dev_tool'/>
|
||||
<div id='about'>
|
||||
|
||||
@@ -8,12 +8,11 @@ from odoo.http import request
|
||||
|
||||
class ContactController(WebsiteForm):
|
||||
|
||||
@http.route('/website_form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True)
|
||||
def website_form(self, model_name, **kwargs):
|
||||
def _handle_website_form(self, model_name, **kwargs):
|
||||
if model_name == 'crm.lead':
|
||||
# Add the ip_address to the request in order to add this to the lead
|
||||
# that will be created. With this, we avoid to create a lead from
|
||||
# reveal if a lead is already created from the contact form.
|
||||
request.params['reveal_ip'] = request.httprequest.remote_addr
|
||||
|
||||
return super(ContactController, self).website_form(model_name, **kwargs)
|
||||
return super(ContactController, self)._handle_website_form(model_name, **kwargs)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Google reCAPTCHA integration',
|
||||
'category': 'Hidden',
|
||||
'version': '1.0',
|
||||
'description': """
|
||||
This module implements reCaptchaV3 so that you can prevent bot spam on your public modules.
|
||||
""",
|
||||
'depends': ['base_setup'],
|
||||
'data': [
|
||||
'views/assets.xml',
|
||||
'views/res_config_settings_view.xml',
|
||||
],
|
||||
'auto_install': True,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import ir_http
|
||||
from . import res_config_settings
|
||||
@@ -0,0 +1,96 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from odoo import api, models, _
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Http(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
@api.model
|
||||
def _verify_request_recaptcha_token(self, action):
|
||||
""" Verify the recaptcha token for the current request.
|
||||
If no recaptcha private key is set the recaptcha verification
|
||||
is considered inactive and this method will return True.
|
||||
"""
|
||||
ip_addr = request.httprequest.remote_addr
|
||||
token = request.params.pop('recaptcha_token_response', False)
|
||||
recaptcha_result = request.env['ir.http']._verify_recaptcha_token(ip_addr, token, 'website_form')
|
||||
if recaptcha_result in ['is_human', 'no_secret']:
|
||||
return True
|
||||
if recaptcha_result == 'wrong_secret':
|
||||
raise ValidationError(_("The reCaptcha private key is invalid."))
|
||||
elif recaptcha_result == 'wrong_token':
|
||||
raise ValidationError(_("The reCaptcha token is invalid."))
|
||||
elif recaptcha_result == 'timeout':
|
||||
raise UserError(_("Your request has timed out, please retry."))
|
||||
elif recaptcha_result == 'bad_request':
|
||||
raise UserError(_("The request is invalid or malformed."))
|
||||
else:
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _verify_recaptcha_token(self, ip_addr, token, action=False):
|
||||
"""
|
||||
Verify a recaptchaV3 token and returns the result as a string.
|
||||
RecaptchaV3 verify DOC: https://developers.google.com/recaptcha/docs/verify
|
||||
|
||||
:return: The result of the call to the google API:
|
||||
is_human: The token is valid and the user trustworthy.
|
||||
is_bot: The user is not trustworthy and most likely a bot.
|
||||
no_secret: No reCaptcha secret set in settings.
|
||||
wrong_action: the action performed to obtain the token does not match the one we are verifying.
|
||||
wrong_token: The token provided is invalid or empty.
|
||||
wrong_secret: The private key provided in settings is invalid.
|
||||
timeout: The request has timout or the token provided is too old.
|
||||
bad_request: The request is invalid or malformed.
|
||||
:rtype: str
|
||||
"""
|
||||
private_key = request.env['ir.config_parameter'].sudo().get_param('recaptcha_private_key')
|
||||
if not private_key:
|
||||
return 'no_secret'
|
||||
min_score = request.env['ir.config_parameter'].sudo().get_param('recaptcha_min_score')
|
||||
try:
|
||||
r = requests.post('https://www.recaptcha.net/recaptcha/api/siteverify', {
|
||||
'secret': private_key,
|
||||
'response': token,
|
||||
'remoteip': ip_addr,
|
||||
}, timeout=2) # it takes ~50ms to retrieve the response
|
||||
result = r.json()
|
||||
res_success = result['success']
|
||||
res_action = res_success and action and result['action']
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error("Trial captcha verification timeout for ip address %s", ip_addr)
|
||||
return 'timeout'
|
||||
except Exception:
|
||||
logger.error("Trial captcha verification bad request response")
|
||||
return 'bad_request'
|
||||
|
||||
if res_success:
|
||||
score = result.get('score', False)
|
||||
if score < float(min_score):
|
||||
logger.warning("Trial captcha verification for ip address %s failed with score %f.", ip_addr, score)
|
||||
return 'is_bot'
|
||||
if res_action and res_action != action:
|
||||
logger.warning("Trial captcha verification for ip address %s failed with action %f, expected: %s.", ip_addr, score, action)
|
||||
return 'wrong_action'
|
||||
logger.info("Trial captcha verification for ip address %s succeeded with score %f.", ip_addr, score)
|
||||
return 'is_human'
|
||||
errors = result.get('error-codes', [])
|
||||
logger.warning("Trial captcha verification for ip address %s failed error codes %r. token was: [%s]", ip_addr, errors, token)
|
||||
for error in errors:
|
||||
if error in ['missing-input-secret', 'invalid-input-secret']:
|
||||
return 'wrong_secret'
|
||||
if error in ['missing-input-response', 'invalid-input-response']:
|
||||
return 'wrong_token'
|
||||
if error == 'timeout-or-duplicate':
|
||||
return 'timeout'
|
||||
if error == 'bad-request':
|
||||
return 'bad_request'
|
||||
return 'is_bot'
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import requests
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
recaptcha_public_key = fields.Char("Site Key", config_parameter='recaptcha_public_key', groups='base.group_system')
|
||||
recaptcha_private_key = fields.Char("Secret Key", config_parameter='recaptcha_private_key', groups='base.group_system')
|
||||
recaptcha_min_score = fields.Float("Minimum score", config_parameter='recaptcha_min_score', groups='base.group_system', default="0.5", help="Should be between 0.0 and 1.0")
|
||||
@@ -0,0 +1,60 @@
|
||||
odoo.define('google_recaptcha.ReCaptchaV3', function (require) {
|
||||
"use strict";
|
||||
|
||||
const ajax = require('web.ajax');
|
||||
const Class = require('web.Class');
|
||||
const core = require('web.core');
|
||||
|
||||
const _t = core._t;
|
||||
|
||||
const ReCaptcha = Class.extend({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._publicKey = odoo.reCaptchaPublicKey;
|
||||
},
|
||||
/**
|
||||
* Loads the recaptcha libraries.
|
||||
*
|
||||
* @returns {Promise|boolean} promise if libs are loading else false if the reCaptcha key is empty.
|
||||
*/
|
||||
loadLibs: function () {
|
||||
if (this._publicKey) {
|
||||
this._recaptchaReady = ajax.loadJS(`https://www.recaptcha.net/recaptcha/api.js?render=${this._publicKey}`)
|
||||
.then(() => new Promise(resolve => window.grecaptcha.ready(() => resolve())));
|
||||
return this._recaptchaReady.then(() => !!document.querySelector('.grecaptcha-badge'));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* Returns an object with the token if reCaptcha call succeeds
|
||||
* If no key is set an object with a message is returned
|
||||
* If an error occured an object with the error message is returned
|
||||
*
|
||||
* @param {string} action
|
||||
* @returns {Promise|Object}
|
||||
*/
|
||||
getToken: async function (action) {
|
||||
if (!this._publicKey) {
|
||||
return {
|
||||
message: _t("No recaptcha public key set."),
|
||||
};
|
||||
}
|
||||
await this._recaptchaReady;
|
||||
try {
|
||||
return {
|
||||
token: await window.grecaptcha.execute(this._publicKey, {action: action})
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
error: _t("The recaptcha public key is invalid."),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ReCaptcha: ReCaptcha,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// Hide google recaptcha V3 don't forget to add legal
|
||||
// https://developers.google.com/recaptcha/docs/faq#id-like-to-hide-the-recaptcha-badge.-what-is-allowed
|
||||
.grecaptcha-badge {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<t t-name="google_recaptcha.recaptcha_legal_terms">
|
||||
<small class="o_recaptcha_legal_terms">
|
||||
Protected by reCAPTCHA,
|
||||
<a href="https://policies.google.com/privacy" target="_blank">Privacy Policy</a>
|
||||
&
|
||||
<a href="https://policies.google.com/terms" target="_blank">Terms of Service</a>
|
||||
apply.
|
||||
</small>
|
||||
</t>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="assets_frontend" inherit_id="web.assets_frontend">
|
||||
<xpath expr="//link[last()]" position="after">
|
||||
<link rel="stylesheet" type="text/scss" href="/google_recaptcha/static/src/scss/recaptcha.scss"/>
|
||||
</xpath>
|
||||
<xpath expr="//script[last()]" position="after">
|
||||
<t t-set="recaptcha_public_key" t-value="request.env['ir.config_parameter'].sudo().get_param('recaptcha_public_key')"/>
|
||||
<script t-if="recaptcha_public_key" type="text/javascript">
|
||||
odoo.reCaptchaPublicKey = '<t t-raw="recaptcha_public_key"/>';
|
||||
</script>
|
||||
<script type="text/javascript" src="/google_recaptcha/static/src/js/recaptcha.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.web.recaptcha</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base_setup.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<div id="recaptcha_warning" position="replace">
|
||||
<div attrs="{'invisible': [('module_google_recaptcha', '=', False)]}">
|
||||
<div class="content-group mt16">
|
||||
<label for="recaptcha_public_key" class="o_light_label"/>
|
||||
<field name="recaptcha_public_key"/>
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<label for="recaptcha_private_key" class="o_light_label"/>
|
||||
<field name="recaptcha_private_key"/>
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<label for="recaptcha_min_score" class="o_light_label"/>
|
||||
<field name="recaptcha_min_score"/>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://www.google.com/recaptcha/admin/create" class="oe_link" target="_blank">
|
||||
<i class="fa fa-arrow-right"/> Generate site/secret keys for v3 reCaptcha type
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -18,8 +18,7 @@ class WebsiteForm(WebsiteForm):
|
||||
return ['phone', 'mobile']
|
||||
|
||||
# Check and insert values from the form on the model <model> + validation phone fields
|
||||
@http.route('/website_form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True)
|
||||
def website_form(self, model_name, **kwargs):
|
||||
def _handle_website_form(self, model_name, **kwargs):
|
||||
model_record = request.env['ir.model'].sudo().search([('model', '=', model_name), ('website_form_access', '=', True)])
|
||||
if model_record and hasattr(request.env[model_name], 'phone_format'):
|
||||
try:
|
||||
@@ -46,7 +45,7 @@ class WebsiteForm(WebsiteForm):
|
||||
state = request.env['res.country.state'].search([('code', '=', geoip_state_code), ('country_id.code', '=', geoip_country_code)])
|
||||
if state:
|
||||
request.params['state_id'] = state.id
|
||||
return super(WebsiteForm, self).website_form(model_name, **kwargs)
|
||||
return super(WebsiteForm, self)._handle_website_form(model_name, **kwargs)
|
||||
|
||||
def insert_record(self, request, model, values, custom, meta=None):
|
||||
is_lead_model = model.model == 'crm.lead'
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
This module adds a new building block in the website builder in order to build new forms from scratch in any website page.
|
||||
""",
|
||||
'version': '1.0',
|
||||
'depends': ['website', 'mail'],
|
||||
'depends': ['website', 'mail', 'google_recaptcha'],
|
||||
'data': [
|
||||
'data/mail_mail_data.xml',
|
||||
'views/assets.xml',
|
||||
|
||||
@@ -8,11 +8,11 @@ import pytz
|
||||
from datetime import datetime
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
from odoo import http, SUPERUSER_ID
|
||||
from odoo import http, SUPERUSER_ID, _
|
||||
from odoo.http import request
|
||||
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
|
||||
from odoo.tools.translate import _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
from odoo.addons.base.models.ir_qweb_fields import nl2br
|
||||
|
||||
|
||||
@@ -21,9 +21,22 @@ class WebsiteForm(http.Controller):
|
||||
# Check and insert values from the form on the model <model>
|
||||
@http.route('/website_form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True)
|
||||
def website_form(self, model_name, **kwargs):
|
||||
try:
|
||||
if request.env['ir.http']._verify_request_recaptcha_token('website_form'):
|
||||
return self._handle_website_form(model_name, **kwargs)
|
||||
error = _("Suspicious activity detected by Google reCaptcha.")
|
||||
except (ValidationError, UserError) as e:
|
||||
error = e.args[0]
|
||||
return json.dumps({
|
||||
'error': error,
|
||||
})
|
||||
|
||||
def _handle_website_form(self, model_name, **kwargs):
|
||||
model_record = request.env['ir.model'].sudo().search([('model', '=', model_name), ('website_form_access', '=', True)])
|
||||
if not model_record:
|
||||
return json.dumps(False)
|
||||
return json.dumps({
|
||||
'error': _("The form's specified model does not exist")
|
||||
})
|
||||
|
||||
try:
|
||||
data = self.extract_data(model_record, request.params)
|
||||
|
||||
@@ -3,6 +3,7 @@ odoo.define('website_form.s_website_form', function (require) {
|
||||
|
||||
var core = require('web.core');
|
||||
var time = require('web.time');
|
||||
const {ReCaptcha} = require('google_recaptcha.ReCaptchaV3');
|
||||
var ajax = require('web.ajax');
|
||||
var publicWidget = require('web.public.widget');
|
||||
|
||||
@@ -21,11 +22,19 @@ odoo.define('website_form.s_website_form', function (require) {
|
||||
*/
|
||||
init: function () {
|
||||
this._super(...arguments);
|
||||
this._recaptcha = new ReCaptcha();
|
||||
this.__started = new Promise(resolve => this.__startResolve = resolve);
|
||||
},
|
||||
|
||||
willStart: function () {
|
||||
const res = this._super(...arguments);
|
||||
if (!this.$target[0].classList.contains('s_website_form_no_recaptcha')) {
|
||||
this._recaptcha.loadLibs();
|
||||
}
|
||||
return res;
|
||||
},
|
||||
start: function () {
|
||||
var self = this;
|
||||
|
||||
// Initialize datetimepickers
|
||||
var datepickers_options = {
|
||||
minDate: moment({y: 1900}),
|
||||
@@ -86,7 +95,7 @@ odoo.define('website_form.s_website_form', function (require) {
|
||||
this.$target.parent().find('.s_website_form_end_message').addClass('d-none');
|
||||
},
|
||||
|
||||
send: function (e) {
|
||||
send: async function (e) {
|
||||
e.preventDefault(); // Prevent the default submit behavior
|
||||
// Prevent users from crazy clicking
|
||||
this.$target.find('.s_website_form_send, .o_website_form_send').addClass('disabled'); // !compatibility
|
||||
@@ -95,7 +104,7 @@ odoo.define('website_form.s_website_form', function (require) {
|
||||
|
||||
self.$target.find('#s_website_form_result, #o_website_form_result').empty(); // !compatibility
|
||||
if (!self.check_error_fields({})) {
|
||||
self.update_status('invalid');
|
||||
self.update_status('error', _t("Please fill in the form correctly."));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -132,6 +141,13 @@ odoo.define('website_form.s_website_form', function (require) {
|
||||
}
|
||||
});
|
||||
|
||||
const tokenObj = await this._recaptcha.getToken('website_form');
|
||||
if (tokenObj.token) {
|
||||
form_values['recaptcha_token_response'] = tokenObj.token;
|
||||
} else if (tokenObj.error) {
|
||||
self.update_status('error', tokenObj.error);
|
||||
return false;
|
||||
}
|
||||
// Post form and handle result
|
||||
ajax.post(this.$target.attr('action') + (this.$target.data('force_action') || this.$target.data('model_name')), form_values)
|
||||
.then(function (result_data) {
|
||||
@@ -140,7 +156,7 @@ odoo.define('website_form.s_website_form', function (require) {
|
||||
result_data = JSON.parse(result_data);
|
||||
if (!result_data.id) {
|
||||
// Failure, the server didn't return the created record ID
|
||||
self.update_status('error');
|
||||
self.update_status('error', result_data.error ? result_data.error : false);
|
||||
if (result_data.error_fields) {
|
||||
// If the server return a list of bad fields, show these fields for users
|
||||
self.check_error_fields(result_data.error_fields);
|
||||
@@ -265,17 +281,23 @@ odoo.define('website_form.s_website_form', function (require) {
|
||||
return value;
|
||||
},
|
||||
|
||||
update_status: function (status) {
|
||||
update_status: function (status, message) {
|
||||
if (status !== 'success') { // Restore send button behavior if result is an error
|
||||
this.$target.find('.s_website_form_send, .o_website_form_send').removeClass('disabled'); // !compatibility
|
||||
}
|
||||
var $result = this.$('#s_website_form_result, #o_website_form_result'); // !compatibility
|
||||
|
||||
if (status === 'error' && !message) {
|
||||
message = _t("An error has occured, the form has not been sent.");
|
||||
}
|
||||
|
||||
// Note: we still need to wait that the widget is properly started
|
||||
// before any qweb rendering which depends on xmlDependencies
|
||||
// because the event handlers are binded before the call to
|
||||
// willStart for public widgets...
|
||||
this.__started.then(() => $result.replaceWith(qweb.render(`website_form.status_${status}`)));
|
||||
this.__started.then(() => $result.replaceWith(qweb.render(`website_form.status_${status}`, {
|
||||
message: message,
|
||||
})));
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,15 +43,15 @@
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.s_website_form_submit {
|
||||
.s_website_form_submit, .s_website_form_recaptcha {
|
||||
.s_website_form_label {
|
||||
float: left;
|
||||
height: 1px;
|
||||
}
|
||||
&.s_website_form_no_submit_label {
|
||||
.s_website_form_label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.s_website_form_no_submit_label {
|
||||
.s_website_form_label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ const qweb = core.qweb;
|
||||
const _t = core._t;
|
||||
|
||||
const FormEditor = options.Class.extend({
|
||||
xmlDependencies: ['/website_form/static/src/xml/website_form_editor.xml'],
|
||||
xmlDependencies: [
|
||||
'/website_form/static/src/xml/website_form_editor.xml',
|
||||
'/google_recaptcha/static/src/xml/recaptcha.xml',
|
||||
],
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
@@ -340,8 +343,8 @@ options.registry.WebsiteFormEditor = FormEditor.extend({
|
||||
const proms = [this._super(...arguments)];
|
||||
// Disable text edition
|
||||
this.$target.attr('contentEditable', false);
|
||||
// Make button editable
|
||||
this.$target.find('.s_website_form_send').attr('contentEditable', true);
|
||||
// Make button and recaptcha editable
|
||||
this.$target.find('.s_website_form_send, .s_website_form_recaptcha').attr('contentEditable', true);
|
||||
// Get potential message
|
||||
this.$message = this.$target.parent().find('.s_website_form_end_message');
|
||||
this.showEndMessage = false;
|
||||
@@ -479,6 +482,22 @@ options.registry.WebsiteFormEditor = FormEditor.extend({
|
||||
this.$target[0].dataset.mark = value.trim();
|
||||
this._setLabelsMark();
|
||||
},
|
||||
/**
|
||||
* Toggle the recaptcha legal terms
|
||||
*/
|
||||
toggleRecaptchaLegal: function (previewMode, value, params) {
|
||||
const recaptchaLegalEl = this.$target[0].querySelector('.s_website_form_recaptcha');
|
||||
if (recaptchaLegalEl) {
|
||||
recaptchaLegalEl.remove();
|
||||
} else {
|
||||
const template = document.createElement('template');
|
||||
const labelWidth = this.$target[0].querySelector('.s_website_form_label').style.width;
|
||||
template.innerHTML = qweb.render("webite_form.s_website_form_recaptcha_legal", {labelWidth: labelWidth});
|
||||
const legal = template.content.firstElementChild;
|
||||
legal.setAttribute('contentEditable', true);
|
||||
this.$target.find('.s_website_form_submit').before(legal);
|
||||
}
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
@@ -503,6 +522,8 @@ options.registry.WebsiteFormEditor = FormEditor.extend({
|
||||
return this.$target[0].dataset.successMode;
|
||||
case 'setMark':
|
||||
return this._getMark();
|
||||
case 'toggleRecaptchaLegal':
|
||||
return !this.$target[0].querySelector('.s_website_form_recaptcha') || '';
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
@@ -643,7 +664,7 @@ options.registry.WebsiteFormEditor = FormEditor.extend({
|
||||
await formInfo.formFields.forEach(async field => {
|
||||
field.formatInfo = formatInfo;
|
||||
await this._fetchFieldRecords(field);
|
||||
this.$target.find('.s_website_form_submit').before(this._renderField(field));
|
||||
this.$target.find('.s_website_form_submit, .s_website_form_recaptcha').first().before(this._renderField(field));
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1219,7 +1240,7 @@ options.registry.AddFieldForm = FormEditor.extend({
|
||||
const field = this._getCustomField('char', 'Custom Text');
|
||||
field.formatInfo = this._getDefaultFormat();
|
||||
const htmlField = this._renderField(field);
|
||||
this.$target.find('.s_website_form_submit').before(htmlField);
|
||||
this.$target.find('.s_website_form_submit, .s_website_form_recaptcha').first().before(htmlField);
|
||||
this.trigger_up('activate_snippet', {
|
||||
$snippet: $(htmlField),
|
||||
});
|
||||
|
||||
@@ -8,17 +8,11 @@
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<!-- Missing field status -->
|
||||
<t t-name="website_form.status_invalid">
|
||||
<span id="s_website_form_result" class="text-danger ml8">
|
||||
<i class="fa fa-close mr4" role="img" aria-label="Error" title="Error"/>Please fill in the form correctly.
|
||||
</span>
|
||||
</t>
|
||||
|
||||
<!-- Error status -->
|
||||
<t t-name="website_form.status_error">
|
||||
<span id="s_website_form_result" class="text-danger ml8">
|
||||
<i class="fa fa-close mr4" role="img" aria-label="Error" title="Error"/>An error has occured, the form has not been sent.
|
||||
<i class="fa fa-close mr4" role="img" aria-label="Error" title="Error"/>
|
||||
<t t-esc="message"/>
|
||||
</span>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -21,6 +21,15 @@
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="webite_form.s_website_form_recaptcha_legal">
|
||||
<div class="col-12 s_website_form_recaptcha" data-name="Recaptcha Legal">
|
||||
<div t-attf-style="width: #{labelWidth or '200px'}" class="s_website_form_label"/>
|
||||
<div class="col-sm">
|
||||
<t t-call="google_recaptcha.recaptcha_legal_terms"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Generic Field Layout -->
|
||||
<!-- Changes made here needs to be reflected in the different Form view (Contact Us, Jobs, ...) -->
|
||||
<t t-name="website_form.field">
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
<we-button class="fa fa-fw fa-eye align-self-end toggle-edit-message" title="Edit Message" data-name="message_opt" data-dependencies="show_message_opt"/>
|
||||
</we-row>
|
||||
<we-urlpicker string="URL" data-select-data-attribute="/contactus-thank-you" data-attribute-name="successPage" data-name="url_opt" data-dependencies="show_redirect_opt"/>
|
||||
<t t-set="recaptcha_public_key" t-value="request.env['ir.config_parameter'].sudo().get_param('recaptcha_public_key')"/>
|
||||
<we-checkbox t-if="recaptcha_public_key" string="Show reCaptcha Policy" data-toggle-recaptcha-legal="" data-no-preview="true"/>
|
||||
</div>
|
||||
|
||||
<!-- Add Field Form -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _
|
||||
from odoo.http import route, request
|
||||
from odoo.osv import expression
|
||||
from odoo.addons.mass_mailing.controllers.main import MassMailController
|
||||
@@ -25,6 +26,11 @@ class MassMailController(MassMailController):
|
||||
|
||||
@route('/website_mass_mailing/subscribe', type='json', website=True, auth="public")
|
||||
def subscribe(self, list_id, email, **post):
|
||||
if not request.env['ir.http']._verify_request_recaptcha_token('website_mass_mailing_subscribe'):
|
||||
return {
|
||||
'toast_type': 'danger',
|
||||
'toast_content': _("Suspicious activity detected by Google reCaptcha."),
|
||||
}
|
||||
ContactSubscription = request.env['mailing.contact.subscription'].sudo()
|
||||
Contacts = request.env['mailing.contact'].sudo()
|
||||
name, email = Contacts.get_name_email(email)
|
||||
@@ -41,7 +47,10 @@ class MassMailController(MassMailController):
|
||||
# add email to session
|
||||
request.session['mass_mailing_email'] = email
|
||||
mass_mailing_list = request.env['mailing.list'].sudo().browse(list_id)
|
||||
return {'toast_content': mass_mailing_list.toast_content}
|
||||
return {
|
||||
'toast_type': 'success',
|
||||
'toast_content': mass_mailing_list.toast_content
|
||||
}
|
||||
|
||||
@route(['/website_mass_mailing/get_content'], type='json', website=True, auth="public")
|
||||
def get_mass_mailing_content(self, newsletter_id, **post):
|
||||
|
||||
@@ -6,6 +6,8 @@ var rpc = require('web.rpc');
|
||||
var WysiwygMultizone = require('web_editor.wysiwyg.multizone');
|
||||
var options = require('web_editor.snippets.options');
|
||||
var wUtils = require('website.utils');
|
||||
|
||||
const qweb = core.qweb;
|
||||
var _t = core._t;
|
||||
|
||||
|
||||
@@ -63,6 +65,39 @@ options.registry.mailing_list_subscribe = options.Class.extend({
|
||||
},
|
||||
});
|
||||
|
||||
options.registry.recaptchaSubscribe = options.Class.extend({
|
||||
xmlDependencies: ['/google_recaptcha/static/src/xml/recaptcha.xml'],
|
||||
|
||||
/**
|
||||
* Toggle the recaptcha legal terms
|
||||
*/
|
||||
toggleRecaptchaLegal: function (previewMode, value, params) {
|
||||
const recaptchaLegalEl = this.$target[0].querySelector('.o_recaptcha_legal_terms');
|
||||
if (recaptchaLegalEl) {
|
||||
recaptchaLegalEl.remove();
|
||||
} else {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = qweb.render("google_recaptcha.recaptcha_legal_terms");
|
||||
this.$target[0].appendChild(template.content.firstElementChild);
|
||||
}
|
||||
},
|
||||
|
||||
//----------------------------------------------------------------------
|
||||
// Private
|
||||
//----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_computeWidgetState: function (methodName, params) {
|
||||
switch (methodName) {
|
||||
case 'toggleRecaptchaLegal':
|
||||
return !this.$target[0].querySelector('.o_recaptcha_legal_terms') || '';
|
||||
}
|
||||
return this._super(...arguments);
|
||||
},
|
||||
});
|
||||
|
||||
options.registry.newsletter_popup = options.registry.mailing_list_subscribe.extend({
|
||||
popup_template_id: "editor_new_mailing_list_subscribe_popup",
|
||||
popup_title: _t("Add a Newsletter Subscribe Popup"),
|
||||
|
||||
@@ -6,6 +6,7 @@ var core = require('web.core');
|
||||
var Dialog = require('web.Dialog');
|
||||
var utils = require('web.utils');
|
||||
var publicWidget = require('web.public.widget');
|
||||
const {ReCaptcha} = require('google_recaptcha.ReCaptchaV3');
|
||||
|
||||
var _t = core._t;
|
||||
|
||||
@@ -16,6 +17,20 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({
|
||||
'click .js_subscribe_btn': '_onSubscribeClick',
|
||||
},
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
init: function () {
|
||||
this._super(...arguments);
|
||||
this._recaptcha = new ReCaptcha();
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
willStart: function () {
|
||||
this._recaptcha.loadLibs();
|
||||
return this._super(...arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
@@ -50,7 +65,9 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({
|
||||
* @override
|
||||
*/
|
||||
destroy: function () {
|
||||
this.$target.addClass('d-none');
|
||||
if (!this.editableMode) {
|
||||
this.$target.addClass('d-none');
|
||||
}
|
||||
this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
@@ -61,7 +78,7 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onSubscribeClick: function () {
|
||||
_onSubscribeClick: async function () {
|
||||
var self = this;
|
||||
var $email = this.$(".js_subscribe_email:visible");
|
||||
|
||||
@@ -70,22 +87,35 @@ publicWidget.registry.subscribe = publicWidget.Widget.extend({
|
||||
return false;
|
||||
}
|
||||
this.$target.removeClass('o_has_error').find('.form-control').removeClass('is-invalid');
|
||||
const tokenObj = await this._recaptcha.getToken('website_form');
|
||||
if (tokenObj.error) {
|
||||
self.displayNotification({
|
||||
type: 'danger',
|
||||
title: _t("Error"),
|
||||
message: tokenObj.error,
|
||||
sticky: true,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
this._rpc({
|
||||
route: '/website_mass_mailing/subscribe',
|
||||
params: {
|
||||
'list_id': this.$target.data('list-id'),
|
||||
'email': $email.length ? $email.val() : false,
|
||||
recaptcha_token_response: tokenObj.token,
|
||||
},
|
||||
}).then(function (result) {
|
||||
self.$(".js_subscribe_btn").addClass('d-none');
|
||||
self.$(".js_subscribed_btn").removeClass('d-none');
|
||||
self.$('input.js_subscribe_email').prop('disabled', !!result);
|
||||
if (self.$popup.length) {
|
||||
self.$popup.modal('hide');
|
||||
if (result.type === 'success') {
|
||||
self.$(".js_subscribe_btn").addClass('d-none');
|
||||
self.$(".js_subscribed_btn").removeClass('d-none');
|
||||
self.$('input.js_subscribe_email').prop('disabled', !!result);
|
||||
if (self.$popup.length) {
|
||||
self.$popup.modal('hide');
|
||||
}
|
||||
}
|
||||
self.displayNotification({
|
||||
type: 'success',
|
||||
title: _t("Success"),
|
||||
type: result.toast_type,
|
||||
title: _t(`${result.toast_type === 'success' ? 'Success' : 'Error'}`),
|
||||
message: result.toast_content,
|
||||
sticky: true,
|
||||
});
|
||||
|
||||
@@ -25,12 +25,14 @@
|
||||
</template>
|
||||
|
||||
<template id="s_newsletter_subscribe_form" name="Newsletter">
|
||||
<div class="input-group js_subscribe" data-list-id="0">
|
||||
<input type="email" name="email" class="js_subscribe_email form-control" placeholder="your email..."/>
|
||||
<span class="input-group-append">
|
||||
<a role="button" href="#" class="btn btn-primary js_subscribe_btn">Subscribe</a>
|
||||
<a role="button" href="#" class="btn btn-success js_subscribed_btn d-none" disabled="disabled">Thanks</a>
|
||||
</span>
|
||||
<div class="s_newsletter_subscribe_form js_subscribe" data-vxml="001" data-list-id="0">
|
||||
<div class="input-group">
|
||||
<input type="email" name="email" class="js_subscribe_email form-control" placeholder="your email..."/>
|
||||
<span class="input-group-append">
|
||||
<a role="button" href="#" class="btn btn-primary js_subscribe_btn">Subscribe</a>
|
||||
<a role="button" href="#" class="btn btn-success js_subscribed_btn d-none" disabled="disabled">Thanks</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,6 +68,11 @@
|
||||
t-attf-data-exclude=".o_newsletter_modal #{selector}">
|
||||
<we-button data-select_mailing_list="" data-no-preview="true">Change Newsletter</we-button>
|
||||
</div>
|
||||
<div data-js="recaptchaSubscribe"
|
||||
t-att-data-selector="selector">
|
||||
<t t-set="recaptcha_public_key" t-value="request.env['ir.config_parameter'].sudo().get_param('recaptcha_public_key')"/>
|
||||
<we-checkbox t-if="recaptcha_public_key" string="Show reCaptcha Policy" data-toggle-recaptcha-legal="" data-no-preview="true"/>
|
||||
</div>
|
||||
<div t-att-data-selector="selector" data-drop-near="p, h1, h2, h3, blockquote, .card"/>
|
||||
<div data-js="newsletter_popup"
|
||||
data-selector=".o_newsletter_popup">
|
||||
|
||||
@@ -748,7 +748,7 @@
|
||||
<div class="col-12 col-xl order-xl-1 oe_cart">
|
||||
<section class="s_website_form" data-vcss="001" data-snippet="s_website_form">
|
||||
<div class="container">
|
||||
<form action="/website_form/" method="post" enctype="multipart/form-data" class="o_mark_required" data-mark="*" data-force_action="shop.sale.order" data-model_name="sale.order" data-success-mode="redirect" data-success-page="/shop/payment" hide-change-model="true">
|
||||
<form action="/website_form/" method="post" enctype="multipart/form-data" class="o_mark_required s_website_form_no_recaptcha" data-mark="*" data-force_action="shop.sale.order" data-model_name="sale.order" data-success-mode="redirect" data-success-page="/shop/payment" hide-change-model="true">
|
||||
<div class="s_website_form_rows row s_col_no_bgcolor">
|
||||
<div class="form-group col-12 s_website_form_field" data-type="char" data-name="Field">
|
||||
<div class="row s_col_no_resize s_col_no_bgcolor">
|
||||
|
||||
Reference in New Issue
Block a user