diff --git a/addons/auth_totp_portal/static/src/js/totp_frontend.js b/addons/auth_totp_portal/static/src/js/totp_frontend.js
index 7bc1067da8c..c8c916bae8d 100644
--- a/addons/auth_totp_portal/static/src/js/totp_frontend.js
+++ b/addons/auth_totp_portal/static/src/js/totp_frontend.js
@@ -3,7 +3,7 @@
import {_t} from "web.core";
import publicWidget from "web.public.widget";
import Dialog from "web.Dialog";
-import {handleCheckIdentity} from "portal.portal";
+import {handleCheckIdentity} from "portal.security";
/**
* Replaces specific elements by normal HTML, strip out the rest entirely
diff --git a/addons/portal/__manifest__.py b/addons/portal/__manifest__.py
index 3561e462e5a..66ec2599ef4 100644
--- a/addons/portal/__manifest__.py
+++ b/addons/portal/__manifest__.py
@@ -42,6 +42,7 @@ a dependency towards website editing and customization capabilities.""",
'portal/static/src/js/portal_chatter.js',
'portal/static/src/xml/portal_chatter.xml',
'portal/static/src/js/portal_composer.js',
+ 'portal/static/src/js/portal_security.js',
'portal/static/src/js/portal_signature.js',
'portal/static/src/xml/portal_signature.xml',
'portal/static/src/js/portal_sidebar.js',
diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js
index 05ef0f2f25c..7efa4580800 100644
--- a/addons/portal/static/src/js/portal.js
+++ b/addons/portal/static/src/js/portal.js
@@ -1,9 +1,6 @@
/** @odoo-module alias=portal.portal **/
import publicWidget from "web.public.widget";
-import Dialog from "web.Dialog";
-import {_t, qweb} from "web.core";
-import session from "web.session";
publicWidget.registry.portalDetails = publicWidget.Widget.extend({
selector: '.o_portal_details',
@@ -177,184 +174,3 @@ publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({
this._search();
},
});
-
-publicWidget.registry.NewAPIKeyButton = publicWidget.Widget.extend({
- selector: '.o_portal_new_api_key',
- events: {
- click: '_onClick'
- },
-
- async _onClick(e){
- e.preventDefault();
- // This call is done just so it asks for the password confirmation before starting displaying the
- // dialog forms, to mimic the behavior from the backend, in which it asks for the password before
- // displaying the wizard.
- // The result of the call is unused. But it's required to call a method with the decorator `@check_identity`
- // in order to use `handleCheckIdentity`.
- await handleCheckIdentity(this.proxy('_rpc'), this._rpc({
- model: 'res.users',
- method: 'api_key_wizard',
- args: [session.user_id],
- }));
- const self = this;
- const d_description = new Dialog(self, {
- title: _t('New API Key'),
- $content: qweb.render('portal.keydescription'),
- buttons: [{text: _t('Confirm'), classes: 'btn-primary', close: true, click: async () => {
- var description = d_description.el.querySelector('[name="description"]').value;
- var wizard_id = await this._rpc({
- model: 'res.users.apikeys.description',
- method: 'create',
- args: [{name: description}],
- });
- var res = await handleCheckIdentity(
- this.proxy('_rpc'),
- this._rpc({
- model: 'res.users.apikeys.description',
- method: 'make_key',
- args: [wizard_id],
- })
- );
- const d_show = new Dialog(self, {
- title: _t('API Key Ready'),
- $content: qweb.render('portal.keyshow', {key: res.context.default_key}),
- buttons: [{text: _t('Close'), clases: 'btn-primary', close: true}],
- });
- d_show.on('closed', this, () => {
- window.location = window.location;
- });
- d_show.open();
- }}, {text: _t('Discard'), close: true}],
- });
- d_description.opened(() => {
- const input = d_description.el.querySelector('[name="description"]');
- input.focus();
- d_description.el.addEventListener('submit', (e) => {
- e.preventDefault();
- d_description.$footer.find('.btn-primary').click();
- });
- });
- d_description.open();
- }
-});
-
-publicWidget.registry.RemoveAPIKeyButton = publicWidget.Widget.extend({
- selector: '.o_portal_remove_api_key',
- events: {
- click: '_onClick'
- },
-
- async _onClick(e){
- e.preventDefault();
- await handleCheckIdentity(
- this.proxy('_rpc'),
- this._rpc({
- model: 'res.users.apikeys',
- method: 'remove',
- args: [parseInt(this.el.id)]
- })
- );
- window.location = window.location;
- }
-});
-
-publicWidget.registry.portalSecurity = publicWidget.Widget.extend({
- selector: '.o_portal_security_body',
-
- /**
- * @override
- */
- init: function () {
- // Show the "deactivate your account" modal if needed
- $('.modal.show#portal_deactivate_account_modal').removeClass('d-block').modal('show');
-
- // Remove the error messages when we close the modal,
- // so when we re-open it again we get a fresh new form
- $('.modal#portal_deactivate_account_modal').on('hide.bs.modal', (event) => {
- const $target = $(event.currentTarget);
- $target.find('.alert').remove();
- $target.find('.invalid-feedback').remove();
- $target.find('.is-invalid').removeClass('is-invalid');
- });
-
- return this._super(...arguments);
- },
-
-});
-
-/**
- * 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 (!(r.type === "ir.actions.act_window" && r.res_model === "res.users.identitycheck")) {
- return r;
- }
- const check_id = r.res_id;
- return 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();
- });
- });
-}
-export default {
- handleCheckIdentity,
-}
diff --git a/addons/portal/static/src/js/portal_security.js b/addons/portal/static/src/js/portal_security.js
new file mode 100644
index 00000000000..6c1de4babd0
--- /dev/null
+++ b/addons/portal/static/src/js/portal_security.js
@@ -0,0 +1,266 @@
+/** @odoo-module alias=portal.security **/
+
+import publicWidget from "web.public.widget";
+import Dialog from "web.Dialog";
+import {_t, qweb} from "web.core";
+import session from "web.session";
+
+publicWidget.registry.NewAPIKeyButton = publicWidget.Widget.extend({
+ selector: '.o_portal_new_api_key',
+ events: {
+ click: '_onClick'
+ },
+
+ async _onClick(e){
+ e.preventDefault();
+ // This call is done just so it asks for the password confirmation before starting displaying the
+ // dialog forms, to mimic the behavior from the backend, in which it asks for the password before
+ // displaying the wizard.
+ // The result of the call is unused. But it's required to call a method with the decorator `@check_identity`
+ // in order to use `handleCheckIdentity`.
+ await handleCheckIdentity(this.proxy('_rpc'), this._rpc({
+ model: 'res.users',
+ method: 'api_key_wizard',
+ args: [session.user_id],
+ }));
+ const self = this;
+ const d_description = new Dialog(self, {
+ title: _t('New API Key'),
+ $content: qweb.render('portal.keydescription'),
+ buttons: [{text: _t('Confirm'), classes: 'btn-primary', close: true, click: async () => {
+ var description = d_description.el.querySelector('[name="description"]').value;
+ var wizard_id = await this._rpc({
+ model: 'res.users.apikeys.description',
+ method: 'create',
+ args: [{name: description}],
+ });
+ var res = await handleCheckIdentity(
+ this.proxy('_rpc'),
+ this._rpc({
+ model: 'res.users.apikeys.description',
+ method: 'make_key',
+ args: [wizard_id],
+ })
+ );
+ const d_show = new Dialog(self, {
+ title: _t('API Key Ready'),
+ $content: qweb.render('portal.keyshow', {key: res.context.default_key}),
+ buttons: [{text: _t('Close'), clases: 'btn-primary', close: true}],
+ });
+ d_show.on('closed', this, () => {
+ window.location = window.location;
+ });
+ d_show.open();
+ }}, {text: _t('Discard'), close: true}],
+ });
+ d_description.opened(() => {
+ const input = d_description.el.querySelector('[name="description"]');
+ input.focus();
+ d_description.el.addEventListener('submit', (e) => {
+ e.preventDefault();
+ d_description.$footer.find('.btn-primary').click();
+ });
+ });
+ d_description.open();
+ }
+});
+
+publicWidget.registry.RemoveAPIKeyButton = publicWidget.Widget.extend({
+ selector: '.o_portal_remove_api_key',
+ events: {
+ click: '_onClick'
+ },
+
+ async _onClick(e){
+ e.preventDefault();
+ await handleCheckIdentity(
+ this.proxy('_rpc'),
+ this._rpc({
+ model: 'res.users.apikeys',
+ method: 'remove',
+ args: [parseInt(this.el.id)]
+ })
+ );
+ window.location = window.location;
+ }
+});
+
+publicWidget.registry.portalSecurity = publicWidget.Widget.extend({
+ selector: '.o_portal_security_body',
+
+ /**
+ * @override
+ */
+ init: function () {
+ // Show the "deactivate your account" modal if needed
+ $('.modal.show#portal_deactivate_account_modal').removeClass('d-block').modal('show');
+
+ // Remove the error messages when we close the modal,
+ // so when we re-open it again we get a fresh new form
+ $('.modal#portal_deactivate_account_modal').on('hide.bs.modal', (event) => {
+ const $target = $(event.currentTarget);
+ $target.find('.alert').remove();
+ $target.find('.invalid-feedback').remove();
+ $target.find('.is-invalid').removeClass('is-invalid');
+ });
+
+ return this._super(...arguments);
+ },
+
+});
+
+/**
+ * Defining what happens when you click the "Log out from all devices"
+ * on the "/my/security" page.
+ */
+publicWidget.registry.RevokeSessionsButton = publicWidget.Widget.extend({
+ selector: '#portal_revoke_all_sessions_popup',
+ events: {
+ click: '_onClick',
+ },
+
+ async _onClick() {
+ const rpc = this.proxy('_rpc');
+ const wrapped = this._rpc({
+ model: 'res.users',
+ method: 'api_key_wizard',
+ args: [session.user_id],
+ });
+ await handleRevokeSessions(rpc, wrapped);
+ window.location.href = "/web/session/logout?redirect=/";
+
+ function handleRevokeSessions(rpc, wrapped) {
+ return wrapped.then((inst) => {
+ const check_id = inst.res_id
+ var $content = $(qweb.render("portal.revoke_all_devices_popup_template"));
+ return new Promise((resolve) => {
+ var dialog = new Dialog(this, {
+ title: _t("Log out from all devices?"),
+ $content,
+ buttons: [{
+ text: _t("Log out from all devices"), classes: 'btn btn-primary',
+ // nb: if click & close, waits for click to resolve before closing
+ async click() {
+ const password_input = this.el.querySelector('[name=password]');
+ if (!password_input.reportValidity()) {
+ password_input.classList.add('is-invalid');
+ return;
+ };
+ await rpc({
+ model: 'res.users.identitycheck',
+ method: 'write',
+ args: [check_id, {password: password_input.value}]
+ });
+ await rpc({
+ model: 'res.users.identitycheck',
+ method: 'revoke_all_devices',
+ args: [check_id]
+ }).then((inst) => {
+ this.close();
+ resolve(inst);
+ }, (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
+ }]
+ });
+ dialog.opened(() => {
+ const password_input = dialog.el.querySelector('[name="password"]');
+ password_input.focus();
+ password_input.addEventListener('input', () => {
+ password_input.classList.remove('is-invalid');
+ password_input.setCustomValidity('');
+ });
+ dialog.el.addEventListener('submit', (e) => {
+ e.preventDefault();
+ dialog.$footer.find('.btn-primary').click();
+ });
+ });
+ dialog.open();
+ })
+ })
+ }
+ }
+});
+
+/**
+ * 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 (!(r.type === "ir.actions.act_window" && r.res_model === "res.users.identitycheck")) {
+ return r;
+ }
+ const check_id = r.res_id;
+ return 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();
+ });
+ });
+}
+export default {
+ handleCheckIdentity,
+}
diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml
index 290c1779e61..34aa2a2071d 100644
--- a/addons/portal/static/src/xml/portal_security.xml
+++ b/addons/portal/static/src/xml/portal_security.xml
@@ -38,4 +38,25 @@
+
+
+
+
+ You are about to log out from all devices that currently have access to this user's account.
+
+ password
+
+
+
diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml
index 33523366e21..dd786494a70 100644
--- a/addons/portal/views/portal_templates.xml
+++ b/addons/portal/views/portal_templates.xml
@@ -531,6 +531,13 @@
+
+ Revoke All Sessions
+
+
+
Developer API Keys
diff --git a/odoo/addons/base/__manifest__.py b/odoo/addons/base/__manifest__.py
index bd4488558c6..d77e0f11e8e 100644
--- a/odoo/addons/base/__manifest__.py
+++ b/odoo/addons/base/__manifest__.py
@@ -69,6 +69,7 @@ The kernel of Odoo, needed for all installation.
'views/res_country_views.xml',
'views/res_currency_views.xml',
'views/res_users_views.xml',
+ 'views/res_users_identitycheck_views.xml',
'views/ir_property_views.xml',
'views/res_config_settings_views.xml',
'views/report_paperformat_views.xml',
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index d929f224247..c8e53839e5f 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -964,6 +964,18 @@ class Users(models.Model):
'view_mode': 'form',
}
+ def action_revoke_all_devices(self):
+ ctx = dict(self.env.context, dialog_size='medium')
+ return {
+ 'name': _('Log out from all devices?'),
+ 'type': 'ir.actions.act_window',
+ 'target': 'new',
+ 'res_model': 'res.users.identitycheck',
+ 'view_mode': 'form',
+ 'view_id': self.env.ref('base.res_users_identitycheck_view_form_revokedevices').id,
+ 'context': ctx,
+ }
+
@api.model
def has_group(self, group_ext_id):
# use singleton's id if called on a non-empty recordset, otherwise
@@ -1872,7 +1884,8 @@ class UsersView(models.Model):
return res
class CheckIdentity(models.TransientModel):
- """ Wizard used to re-check the user's credentials (password)
+ """ Wizard used to re-check the user's credentials (password) and eventually
+ revoke access to his account to every device he has an active session on.
Might be useful before the more security-sensitive operations, users might be
leaving their computer unlocked & unattended. Re-checking credentials mitigates
@@ -1885,13 +1898,15 @@ class CheckIdentity(models.TransientModel):
request = fields.Char(readonly=True, groups=fields.NO_ACCESS)
password = fields.Char()
- def run_check(self):
- assert request, "This method can only be accessed over HTTP"
+ def _check_identity(self):
try:
self.create_uid._check_credentials(self.password, {'interactive': True})
except AccessDenied:
raise UserError(_("Incorrect Password, try again or click on Forgot Password to reset your password."))
+ def run_check(self):
+ assert request, "This method can only be accessed over HTTP"
+ self._check_identity()
self.password = False
request.session['identity-check-last'] = time.time()
@@ -1900,6 +1915,12 @@ class CheckIdentity(models.TransientModel):
assert getattr(method, '__has_check_identity', False)
return method()
+ def revoke_all_devices(self):
+ self._check_identity()
+ self.env.user._change_password(self.password)
+ self.sudo().unlink()
+ return {'type': 'ir.actions.client', 'tag': 'reload'}
+
#----------------------------------------------------------
# change password wizard
#----------------------------------------------------------
@@ -1908,6 +1929,7 @@ class ChangePasswordWizard(models.TransientModel):
""" A wizard to manage the change of users' passwords. """
_name = "change.password.wizard"
_description = "Change Password Wizard"
+ _transient_max_hours = 0.2
def _default_user_ids(self):
user_ids = self._context.get('active_model') == 'res.users' and self._context.get('active_ids') or []
diff --git a/odoo/addons/base/views/res_users_identitycheck_views.xml b/odoo/addons/base/views/res_users_identitycheck_views.xml
new file mode 100644
index 00000000000..966b5a70c0e
--- /dev/null
+++ b/odoo/addons/base/views/res_users_identitycheck_views.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+ res.users.identitycheck
+
+
+
+
+
+
+ Revoke All Devices
+ res.users.identitycheck
+ 40
+
+
+
+
+
+
+
diff --git a/odoo/addons/base/views/res_users_views.xml b/odoo/addons/base/views/res_users_views.xml
index e9d8840d610..33ba6d6a393 100644
--- a/odoo/addons/base/views/res_users_views.xml
+++ b/odoo/addons/base/views/res_users_views.xml
@@ -52,27 +52,6 @@
-
- res.users.identitycheck
-
-
-
-
-
res.groups.search
@@ -493,6 +472,11 @@
+
+
+
+
+
API Keys are used to connect to Odoo from external tools without the need for a password or Two-factor Authentication.