-
-
- Two-factor authentication not enabled
-
-
-
+
+
+
+
+
Two-factor Authentication
+
+
-
-
-
-
-
- Two-factor authentication enabled
-
-
-
-
-
-
+
+ Two-factor Authentication ("2FA") is a system of double authentication. The first one is done with your password and the second one with a code you get from a dedicated mobile app. Popular ones include Authy, Google Authenticator or the Microsoft Authenticator.
+ Learn More
+
+
Your account is protected!
+
+
+
+
+
+
+ Invite to use two-factor authentication
+
+
+ list
+ code
+
+ action = records.action_totp_invite()
+
+
+
+
+
+ users preference: Account Security
+ res.users
+
+ primary
+
+
+
+
+
+
+ $0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Open two-factor authentication configuration
+
+ code
+
+user = env.user
+action = user.action_open_my_account_settings()
+
+
+
+
diff --git a/addons/auth_totp_portal/__init__.py b/addons/auth_totp_portal/__init__.py
index 40a96afc6ff..dc5e6b693d1 100644
--- a/addons/auth_totp_portal/__init__.py
+++ b/addons/auth_totp_portal/__init__.py
@@ -1 +1,4 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
diff --git a/addons/auth_totp_portal/models/__init__.py b/addons/auth_totp_portal/models/__init__.py
new file mode 100644
index 00000000000..74f3017f7e6
--- /dev/null
+++ b/addons/auth_totp_portal/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import res_users
diff --git a/addons/auth_totp_portal/models/res_users.py b/addons/auth_totp_portal/models/res_users.py
new file mode 100644
index 00000000000..7f68d1f56e4
--- /dev/null
+++ b/addons/auth_totp_portal/models/res_users.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class Users(models.Model):
+ _inherit = 'res.users'
+
+ def get_totp_invite_url(self):
+ if not self.has_group('base.group_user'):
+ return '/my/security'
+ else:
+ return super(Users, self).get_totp_invite_url()
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 5f55970344c..c5dd6dd0adc 100644
--- a/addons/auth_totp_portal/static/src/js/totp_frontend.js
+++ b/addons/auth_totp_portal/static/src/js/totp_frontend.js
@@ -13,7 +13,7 @@ function fromField(f, record) {
switch (f.getAttribute('name')) {
case 'qrcode':
const qrcode = document.createElement('img');
- qrcode.setAttribute('class', 'img img-fluid offset-1');
+ qrcode.setAttribute('class', 'img img-fluid');
qrcode.setAttribute('src', 'data:image/png;base64,' + record['qrcode']);
return qrcode;
case 'url':
@@ -30,6 +30,53 @@ function fromField(f, record) {
code.maxLength = 6;
code.minLength = 6;
return code;
+ case 'secret':
+ // As CopyClipboard wizard is backend only, mimic his behaviour to use it in frontend.
+ // Field
+ const secretSpan = document.createElement('span');
+ secretSpan.setAttribute('name', 'secret');
+ secretSpan.setAttribute('class', 'o_field_copy_url');
+ secretSpan.textContent = record['secret'];
+
+ // Copy Button
+ const copySpanIcon = document.createElement('span');
+ copySpanIcon.setAttribute('class', 'fa fa-clipboard');
+ const copySpanText = document.createElement('span');
+ copySpanText.textContent = _t(' Copy');
+
+ const copyButton = document.createElement('button');
+ copyButton.setAttribute('class', 'btn btn-sm btn-primary o_clipboard_button o_btn_char_copy py-0 px-2');
+ copyButton.onclick = function(event) {
+ event.preventDefault();
+ $(copyButton).tooltip({title: _t("Copied !"), trigger: "manual", placement: "bottom"});
+ var clipboard = new ClipboardJS('.o_clipboard_button', {
+ target: function () {
+ return $(secretSpan)[0];
+ },
+ container: this.el
+ });
+ clipboard.on('success', function () {
+ clipboard.destroy();
+ $(copyButton).tooltip('show');
+ _.delay(function () {
+ $(copyButton).tooltip("hide");
+ }, 800);
+ });
+ clipboard.on('error', function (e) {
+ clipboard.destroy();
+ });
+ };
+
+ copyButton.appendChild(copySpanIcon);
+ copyButton.appendChild(copySpanText);
+
+ // CopyClipboard Div
+ const secretDiv = document.createElement('div');
+ secretDiv.setAttribute('class', 'o_field_copy d-flex justify-content-center align-items-center');
+ secretDiv.appendChild(secretSpan);
+ secretDiv.appendChild(copyButton);
+
+ return secretDiv;
default: // just display the field's data
return document.createTextNode(record[f.getAttribute('name')] || '');
}
@@ -159,7 +206,7 @@ publicWidget.registry.TOTPButton = publicWidget.Widget.extend({
const w = await handleCheckIdentity(this.proxy('_rpc'), this._rpc({
model: 'res.users',
- method: 'totp_enable_wizard',
+ method: 'action_totp_enable_wizard',
args: [this.getSession().user_id]
}));
@@ -213,7 +260,7 @@ publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({
e.preventDefault();
await handleCheckIdentity(
this.proxy('_rpc'),
- this._rpc({model: 'res.users', method: 'totp_disable', args: [this.getSession().user_id]})
+ this._rpc({model: 'res.users', method: 'action_totp_disable', args: [this.getSession().user_id]})
)
window.location = window.location;
}
diff --git a/addons/auth_totp_portal/static/src/scss/auth_totp_portal.scss b/addons/auth_totp_portal/static/src/scss/auth_totp_portal.scss
new file mode 100644
index 00000000000..7380d51e15e
--- /dev/null
+++ b/addons/auth_totp_portal/static/src/scss/auth_totp_portal.scss
@@ -0,0 +1,35 @@
+/* As we use the Enable TOTP backend wizard in frontend, we need to
+ import the needed css from backend to replicate backend copyclipboard
+ widget style. We cannot put this into the original view as all those
+ classes are generated in the backend widget. */
+.o_auth_totp_enable_2FA {
+ .o_field_copy {
+ height: 24px;
+ position: relative;
+ width: 100% !important;
+ border-radius: 5px;
+ border: 1px solid $primary;
+ font-size: $font-size-sm;
+ text-transform: uppercase;
+ color: $o-brand-primary;
+ font-weight: $badge-font-weight;
+ text-align: center;
+ padding-right: 6rem;
+ word-break: break-word;
+ .o_clipboard_button {
+ @include o-position-absolute($top: 0, $right: 0);
+ &.o_btn_text_copy {
+ position: absolute;
+ top: 0;
+ right: 0;
+ }
+ &.o_btn_char_copy {
+ height: 100%;
+ }
+ }
+ &.o_field_copy_url {
+ cursor: pointer;
+ padding-left: 3px;
+ }
+ }
+}
diff --git a/addons/auth_totp_portal/static/tests/totp_portal.js b/addons/auth_totp_portal/static/tests/totp_portal.js
index 6424647aabe..a60d04e783b 100644
--- a/addons/auth_totp_portal/static/tests/totp_portal.js
+++ b/addons/auth_totp_portal/static/tests/totp_portal.js
@@ -12,7 +12,7 @@ tour.register('totportal_tour_setup', {
trigger: 'button#auth_totp_portal_enable',
}, {
content: "Check that we have to enter enhanced security mode",
- trigger: 'div:contains("confirm your password")',
+ trigger: 'div:contains("enter your password")',
run: () => {},
}, {
content: "Input password",
@@ -23,18 +23,18 @@ tour.register('totportal_tour_setup', {
trigger: "button:contains(Confirm Password)",
}, {
content: "Check the wizard has opened",
- trigger: 'div:contains("Scan the image below")',
+ trigger: 'li:contains("scan the barcode below")',
run: () => {}
}, {
content: "Get secret from collapsed div",
- trigger: 'a:contains("show the code")',
+ trigger: 'a:contains("Cannot scan it?")',
run: async function(helpers) {
- const secret = this.$anchor.closest('div').find('code').text();
+ const secret = this.$anchor.closest('div').find('span[name="secret"]').text();
const token = await ajax.jsonRpc('/totphook', 'call', {
secret
});
helpers._text(helpers._get_action_values('input[name=code]'), token);
- helpers._click(helpers._get_action_values('button.btn-primary:contains(Enable)'));
+ helpers._click(helpers._get_action_values('button.btn-primary:contains(Activate)'));
}
}, {
content: "Check that the button has changed",
@@ -70,7 +70,7 @@ tour.register('totportal_login_enabled', {
helpers._text(helpers._get_action_values(), token);
// FIXME: is there a way to put the button as its own step trigger without
// the tour straight blowing through and not waiting for this?
- helpers._click(helpers._get_action_values('button:contains("Verify")'));
+ helpers._click(helpers._get_action_values('button:contains("Login")'));
}
}, {
content: "check we're logged in",
@@ -84,7 +84,7 @@ tour.register('totportal_login_enabled', {
trigger: 'button#auth_totp_portal_disable',
}, {
content: "Check that we have to enter enhanced security mode",
- trigger: 'div:contains("confirm your password")',
+ trigger: 'div:contains("enter your password")',
run: () => {},
}, {
content: "Input password",
diff --git a/addons/base_setup/static/src/img/logo_apple_store.png b/addons/base_setup/static/src/img/logo_apple_store.png
new file mode 100644
index 00000000000..1df39627c07
Binary files /dev/null and b/addons/base_setup/static/src/img/logo_apple_store.png differ
diff --git a/addons/base_setup/static/src/img/logo_google_play.png b/addons/base_setup/static/src/img/logo_google_play.png
new file mode 100644
index 00000000000..9dfe8e3c172
Binary files /dev/null and b/addons/base_setup/static/src/img/logo_google_play.png differ
diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js
index 183f383b4ed..ac6dac67a6e 100644
--- a/addons/portal/static/src/js/portal.js
+++ b/addons/portal/static/src/js/portal.js
@@ -207,9 +207,6 @@ function handleCheckIdentity(rpc, wrapped) {
}
}, {
text: _t('Cancel'), close: true
- }, {
- text: _t('Forgot password?'), classes: 'btn btn-link',
- click() { window.location.href = "/web/reset_password/"; }
}]
}).on('close', null, () => {
// unlink wizard object?
diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml
index ff5e62e895a..2c19cfeaa0d 100644
--- a/addons/portal/static/src/xml/portal_security.xml
+++ b/addons/portal/static/src/xml/portal_security.xml
@@ -1,13 +1,13 @@
diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml
index 62827e01646..f2e4e3228ba 100644
--- a/addons/web/static/src/legacy/xml/base.xml
+++ b/addons/web/static/src/legacy/xml/base.xml
@@ -797,7 +797,7 @@
- Confirm New Password
+ New Password (Confirmation)
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index b85bf7fc699..d794249ab2e 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -1533,7 +1533,11 @@ class CheckIdentity(models.TransientModel):
def run_check(self):
assert request, "This method can only be accessed over HTTP"
- self.create_uid._check_credentials(self.password, {'interactive': True})
+ 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."))
+
self.password = False
request.session['identity-check-last'] = time.time()
diff --git a/odoo/addons/base/views/res_users_views.xml b/odoo/addons/base/views/res_users_views.xml
index 3287782937d..2ba49f799b5 100644
--- a/odoo/addons/base/views/res_users_views.xml
+++ b/odoo/addons/base/views/res_users_views.xml
@@ -39,19 +39,18 @@
res.users.identitycheck
-
@@ -235,7 +234,7 @@
-
+
@@ -433,7 +432,7 @@
-
+
@@ -465,16 +464,17 @@
-
+
-
-
-
- Documentation
-
+
+ Developer API Keys
+
+
+
+
diff --git a/odoo/addons/test_apikeys/static/tests/apikey_flow.js b/odoo/addons/test_apikeys/static/tests/apikey_flow.js
index 8507f3e4001..2415c63e099 100644
--- a/odoo/addons/test_apikeys/static/tests/apikey_flow.js
+++ b/odoo/addons/test_apikeys/static/tests/apikey_flow.js
@@ -6,7 +6,7 @@ const ajax = require('web.ajax');
tour.register('apikeys_tour_setup', {
test: true,
- url: '/web',
+ url: '/web?debug=1', // Needed as API key part is now only displayed in debug mode
}, [{
content: 'Open user account menu',
trigger: '.o_user_menu .oe_topbar_name',
@@ -24,7 +24,7 @@ tour.register('apikeys_tour_setup', {
trigger: 'button:contains("New API Key")',
}, {
content: "Check that we have to enter enhanced security mode",
- trigger: 'div:contains("confirm your password")',
+ trigger: 'div:contains("enter your password")',
run: () => {},
}, {
content: "Input password",
@@ -72,7 +72,7 @@ tour.register('apikeys_tour_setup', {
// deletes the previously created key
tour.register('apikeys_tour_teardown', {
test: true,
- url: '/web',
+ url: '/web?debug=1', // Needed as API key part is now only displayed in debug mode
}, [{
content: 'Open preferences',
trigger: '.o_user_menu .oe_topbar_name',