diff --git a/addons/auth_password_policy_portal/__init__.py b/addons/auth_password_policy_portal/__init__.py new file mode 100644 index 00000000000..153a9e31ebb --- /dev/null +++ b/addons/auth_password_policy_portal/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import controllers diff --git a/addons/auth_password_policy_portal/__manifest__.py b/addons/auth_password_policy_portal/__manifest__.py new file mode 100644 index 00000000000..8241c90a670 --- /dev/null +++ b/addons/auth_password_policy_portal/__manifest__.py @@ -0,0 +1,7 @@ +{ + 'name': "Password Policy support for Signup", + 'depends': ['auth_password_policy', 'portal'], + 'category': 'Tools', + 'auto_install': True, + 'data': ['views/templates.xml'] +} diff --git a/addons/auth_password_policy_portal/controllers.py b/addons/auth_password_policy_portal/controllers.py new file mode 100644 index 00000000000..89678520b97 --- /dev/null +++ b/addons/auth_password_policy_portal/controllers.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal + +class CustomerPortalPasswordPolicy(CustomerPortal): + def _prepare_portal_layout_values(self): + d = super()._prepare_portal_layout_values() + d['password_minimum_length'] = request.env['ir.config_parameter'].sudo().get_param('auth_password_policy.minlength') + return d diff --git a/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss b/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss new file mode 100644 index 00000000000..8a3cb187ece --- /dev/null +++ b/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss @@ -0,0 +1,6 @@ +.o_portal_security_body #new-password-group { + position: relative; + meter { + bottom: calc(#{$input-height-sm} / 2 - 7px); + } +} diff --git a/addons/auth_password_policy_portal/views/templates.xml b/addons/auth_password_policy_portal/views/templates.xml new file mode 100644 index 00000000000..427ee055e51 --- /dev/null +++ b/addons/auth_password_policy_portal/views/templates.xml @@ -0,0 +1,17 @@ + + + + diff --git a/addons/auth_password_policy_signup/static/src/js/signup_policy.js b/addons/auth_password_policy_signup/static/src/js/signup_policy.js index 1d37484a34c..14e3d8d19ca 100644 --- a/addons/auth_password_policy_signup/static/src/js/signup_policy.js +++ b/addons/auth_password_policy_signup/static/src/js/signup_policy.js @@ -11,7 +11,7 @@ if (!$signupForm.length) { return; } // hook in password strength meter // * requirement is the password field's minlength // * recommendations are from the module -var $password = $signupForm.find('#password'); +var $password = $('[type=password][minlength]'); var minlength = Number($password.attr('minlength')); if (isNaN(minlength)) { return; } diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py index 7e063c763fb..18f7a01d582 100644 --- a/addons/portal/controllers/portal.py +++ b/addons/portal/controllers/portal.py @@ -2,14 +2,16 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import base64 +import functools import json +import logging import math import re from werkzeug import urls from odoo import fields as odoo_fields, http, tools, _, SUPERUSER_ID -from odoo.exceptions import ValidationError, AccessError, MissingError, UserError +from odoo.exceptions import ValidationError, AccessError, MissingError, UserError, AccessDenied from odoo.http import content_disposition, Controller, request, route from odoo.tools import consteq @@ -17,7 +19,7 @@ from odoo.tools import consteq # Misc tools # -------------------------------------------------- - +_logger = logging.getLogger(__name__) def pager(url, total, page=1, step=30, scope=5, url_args=None): """ Generate a dict with required value to render `website.pager` template. This method compute url, page range to display, ... in the pager. @@ -212,6 +214,41 @@ class CustomerPortal(Controller): response.headers['X-Frame-Options'] = 'DENY' return response + @route('/my/security', type='http', auth='user', website=True, methods=['GET', 'POST']) + def security(self, **post): + values = self._prepare_portal_layout_values() + values['get_error'] = get_error + + if request.httprequest.method == 'POST': + values.update(self._update_password( + post['old'].strip(), + post['new1'].strip(), + post['new2'].strip() + )) + + return request.render('portal.portal_my_security', values, headers={ + 'X-Frame-Options': 'DENY' + }) + + def _update_password(self, old, new1, new2): + for k, v in [('old', old), ('new1', new1), ('new2', new2)]: + if not v: + return {'errors': {'password': {k: _("You cannot leave any password empty.")}}} + + if new1 != new2: + return {'errors': {'password': {'new2': _("The new password and its confirmation must be identical.")}}} + + try: + request.env['res.users'].change_password(old, new1) + except UserError as e: + return {'errors': {'password': e.name}} + except AccessDenied as e: + msg = e.args[0] + if msg == AccessDenied().args[0]: + msg = _('The old password you provided is incorrect, your password was not changed.') + return {'errors': {'password': {'old': msg}}} + return {'success': {'password': True}} + @http.route('/portal/attachment/add', type='http', auth='public', methods=['POST'], website=True) def attachment_add(self, name, file, res_model, res_id, access_token=None, **kwargs): """Process a file uploaded from the portal chatter and create the @@ -392,3 +429,15 @@ class CustomerPortal(Controller): filename = "%s.pdf" % (re.sub('\W+', '-', model._get_report_base_filename())) reporthttpheaders.append(('Content-Disposition', content_disposition(filename))) return request.make_response(report, headers=reporthttpheaders) + +def get_error(e, path=''): + """ Recursively dereferences `path` (a period-separated sequence of dict + keys) in `e` (an error dict or value), returns the final resolution IIF it's + an str, otherwise returns None + """ + for k in (path.split('.') if path else []): + if not isinstance(e, dict): + return None + e = e.get(k) + + return e if isinstance(e, str) else None diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js index 8c117167699..ee6bbe16eb5 100644 --- a/addons/portal/static/src/js/portal.js +++ b/addons/portal/static/src/js/portal.js @@ -2,6 +2,9 @@ odoo.define('portal.portal', function (require) { 'use strict'; var publicWidget = require('web.public.widget'); +const Dialog = require('web.Dialog'); +const {_t, qweb} = require('web.core'); +const ajax = require('web.ajax'); publicWidget.registry.portalDetails = publicWidget.Widget.extend({ selector: '.o_portal_details', @@ -119,4 +122,83 @@ publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({ } }, }); + +const tmpl = ajax.loadXML('/portal/static/src/xml/portal_security.xml', qweb); + +/** + * Wraps an RPC call in a check for the result being an identity check action + * descriptor. If no such result is found, just returns the wrapped promise's + * result as-is; otherwise shows an identity check dialog and resumes the call + * on success. + * + * Warning: does not in and of itself trigger an identity check, a promise which + * never triggers and identity check internally will do nothing of use. + * + * @param {Function} rpc Widget#_rpc bound do the widget + * @param {Promise} wrapped promise to check for an identity check request + * @returns {Promise} result of the original call + */ +function handleCheckIdentity(rpc, wrapped) { + return wrapped.then((r) => { + if (!_.isMatch(r, {type: 'ir.actions.act_window', res_model: 'res.users.identitycheck'})) { + return r; + } + const check_id = r.res_id; + return tmpl.then(() => new Promise((resolve, reject) => { + const d = new Dialog(null, { + title: _t("Security Control"), + $content: qweb.render('portal.identitycheck'), + buttons: [{ + text: _t("Confirm Password"), classes: 'btn btn-primary', + // nb: if click & close, waits for click to resolve before closing + click() { + const password_input = this.el.querySelector('[name=password]'); + if (!password_input.reportValidity()) { + password_input.classList.add('is-invalid'); + return; + } + return rpc({ + model: 'res.users.identitycheck', + method: 'write', + args: [check_id, {password: password_input.value}] + }).then(() => rpc({ + model: 'res.users.identitycheck', + method: 'run_check', + args: [check_id] + })).then((r) => { + this.close(); + resolve(r); + }, (err) => { + err.event.preventDefault(); // suppress crashmanager + password_input.classList.add('is-invalid'); + password_input.setCustomValidity(_t("Check failed")); + password_input.reportValidity(); + }); + } + }, { + text: _t('Cancel'), close: true + }] + }).on('close', null, () => { + // unlink wizard object? + reject(); + }); + d.opened(() => { + const pw = d.el.querySelector('[name="password"]'); + pw.focus(); + pw.addEventListener('input', () => { + pw.classList.remove('is-invalid'); + pw.setCustomValidity(''); + }); + d.el.addEventListener('submit', (e) => { + e.preventDefault(); + d.$footer.find('.btn-primary').click(); + }); + }); + d.open(); + })); + }); +} +return { + handleCheckIdentity, +} }); diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss index 604c0a70a25..be50f1c20e8 100644 --- a/addons/portal/static/src/scss/portal.scss +++ b/addons/portal/static/src/scss/portal.scss @@ -536,6 +536,25 @@ form label { } } +.o_portal_security_body { + @extend .mx-auto; + max-width: map-get($container-max-widths, sm); + section { + @extend .mt-4; + form.oe_reset_password_form { + max-width: initial; + margin: initial; + } + .form-group { + // for the absolutely positioned meter on new password + position: relative; + } + label, button { + @extend .text-nowrap; + } + } +} + // Copyright .o_footer_copyright { @@ -551,3 +570,4 @@ form label { } } } + diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml new file mode 100644 index 00000000000..1f905563ede --- /dev/null +++ b/addons/portal/static/src/xml/portal_security.xml @@ -0,0 +1,28 @@ + + +
+
+
+
+
+ + Security Control +
+
+ Forgot password? +

Please confirm your password to continue

+

+ This is necessary for security-related changes. The authorization will last for a few minutes. +

+
+ + +
+
+
+
+
+ +
+
diff --git a/addons/portal/tests/__init__.py b/addons/portal/tests/__init__.py index 06a8d4cd529..f0834f66623 100644 --- a/addons/portal/tests/__init__.py +++ b/addons/portal/tests/__init__.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import test_load_process +from . import test_tours diff --git a/addons/portal/tests/test_load_process.py b/addons/portal/tests/test_tours.py similarity index 100% rename from addons/portal/tests/test_load_process.py rename to addons/portal/tests/test_tours.py diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 642b818981b..be4a35f2db0 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -117,6 +117,11 @@
+
+

Account Security

+
+ Edit Security Settings +
@@ -438,6 +443,58 @@ + +