[IMP] auth_top, *: revamp Two-factor authentication flow
Purpose ======= Review the UX of the 2-factor authentication flow in order to make it more clear and easy to use. Specifications ============== This commit applies multiple rewording of instructions, button, etc. Tests have been adapted accordingly. It also adds an 'invite to use two-factor authentication' flow that will send an email to the selected used to redirect them their account security settings. - If portal is not installed yet, the user is redirected to his account security settings in backend. - If portal is installed, the user is redirected to /my/profile if them are portal user. Otherwise, the redirection is still done at backend side. As the backend view of auth_totp wizard is used at frontend side, copyclipboard widget has to be rebuilt at frontend side (click event, style etc..). As API key section is now displayed only on debug mode, test urls have been adapted accordingly. Task-2487630 Part-of: odoo/odoo#71142
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
<field name="type">form</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='references']" position="after">
|
||||
<xpath expr="//page[@name='preferences']" position="after">
|
||||
<page string="Oauth" name="oauth">
|
||||
<group>
|
||||
<field name="oauth_provider_id"/>
|
||||
|
||||
@@ -14,17 +14,21 @@ Note: logically, two-factor prevents password-based RPC access for users
|
||||
where it is enabled. In order to be able to execute RPC scripts, the user
|
||||
can setup API keys to replace their main password.
|
||||
""",
|
||||
'depends': ['web'],
|
||||
'depends': ['web', 'mail'],
|
||||
'category': 'Extra Tools',
|
||||
'auto_install': True,
|
||||
'data': [
|
||||
'data/mail_templates_data.xml',
|
||||
'security/security.xml',
|
||||
'views/user_preferences.xml',
|
||||
'views/templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_tests': [
|
||||
'auth_totp/static/**/*',
|
||||
'auth_totp/static/tests/**/*',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'auth_totp/static/src/**/*',
|
||||
],
|
||||
},
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mail_template_totp_invite" model="mail.template">
|
||||
<field name="name">TOTP on User: Invite by email</field>
|
||||
<field name="model_id" ref="base.model_res_users" />
|
||||
<field name="subject">Invitation to activate two-factor authentication on your Odoo account</field>
|
||||
<field name="partner_to">${object.partner_id.id}</field>
|
||||
<field name="lang">${object.partner_id.lang}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
<p style="margin: 0px; padding: 0px; font-size: 13px;">
|
||||
Dear ${object.partner_id.name}<br/><br/>
|
||||
${user.name} requested you activate two-factor authentication to protect your account.<br/><br/>
|
||||
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.
|
||||
|
||||
<p style="margin: 16px 0px 16px 0px; text-align: center;">
|
||||
<a href="${object.get_totp_invite_url()}"
|
||||
style="background-color:#875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px;">
|
||||
Activate my two-factor Authentication
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -41,6 +41,16 @@ class Users(models.Model):
|
||||
for r, v in zip(self, self.sudo()):
|
||||
r.totp_enabled = bool(v.totp_secret)
|
||||
|
||||
def action_open_my_account_settings(self):
|
||||
action = {
|
||||
"name": _("Account Security"),
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "res.users",
|
||||
"views": [[self.env.ref('auth_totp.res_users_view_form_security').id, "form"]],
|
||||
"res_id": self.id,
|
||||
}
|
||||
return action
|
||||
|
||||
def _rpc_api_keys_only(self):
|
||||
# 2FA enabled means we can't allow password-based RPC
|
||||
self.ensure_one()
|
||||
@@ -79,8 +89,34 @@ class Users(models.Model):
|
||||
_logger.info("2FA enable: SUCCESS for %s %r", self, self.login)
|
||||
return True
|
||||
|
||||
def get_totp_invite_url(self):
|
||||
return '/web#action=auth_totp.action_activate_two_factor_authentication'
|
||||
|
||||
def action_totp_invite(self):
|
||||
invite_template = self.env.ref('auth_totp.mail_template_totp_invite')
|
||||
users_to_invite = self.sudo().filtered(lambda user: not user.totp_secret)
|
||||
for user in users_to_invite:
|
||||
email_values = {
|
||||
'email_from': self.env.user.email_formatted,
|
||||
'author_id': self.env.user.partner_id.id,
|
||||
}
|
||||
invite_template.send_mail(user.id, force_send=True, email_values=email_values,
|
||||
notif_layout='mail.mail_notification_light')
|
||||
|
||||
# Display a confirmation toaster
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'type': 'info',
|
||||
'sticky': False,
|
||||
'message': _("Invitation to use two-factor authentication sent for the following user(s): %s",
|
||||
', '.join(users_to_invite.mapped('name'))),
|
||||
}
|
||||
}
|
||||
|
||||
@check_identity
|
||||
def totp_disable(self):
|
||||
def action_totp_disable(self):
|
||||
logins = ', '.join(map(repr, self.mapped('login')))
|
||||
if not (self == self.env.user or self.env.user._is_admin() or self.env.su):
|
||||
_logger.info("2FA disable: REJECT for %s (%s) by uid #%s", self, logins, self.env.user.id)
|
||||
@@ -99,13 +135,13 @@ class Users(models.Model):
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'type': 'warning',
|
||||
'message': _("Two-factor authentication disabled for user(s) %s", logins),
|
||||
'message': _("Two-factor authentication disabled for the following user(s): %s", ', '.join(self.mapped('name'))),
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
}
|
||||
}
|
||||
|
||||
@check_identity
|
||||
def totp_enable_wizard(self):
|
||||
def action_totp_enable_wizard(self):
|
||||
if self.env.user != self:
|
||||
raise UserError(_("Two-factor authentication can only be enabled for yourself"))
|
||||
|
||||
@@ -124,11 +160,13 @@ class Users(models.Model):
|
||||
'type': 'ir.actions.act_window',
|
||||
'target': 'new',
|
||||
'res_model': 'auth_totp.wizard',
|
||||
'name': _("Enable Two-Factor Authentication"),
|
||||
'name': _("Two-Factor Authentication Activation"),
|
||||
'res_id': w.id,
|
||||
'views': [(False, 'form')],
|
||||
'context': self.env.context,
|
||||
}
|
||||
|
||||
|
||||
class TOTPWizard(models.TransientModel):
|
||||
_name = 'auth_totp.wizard'
|
||||
_description = "Two-Factor Setup Wizard"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.o_auth_2fa_btn {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
@@ -39,10 +39,10 @@ tour.register('totp_tour_setup', {
|
||||
url: '/web'
|
||||
}, [...openUserProfileAtSecurityTab(), {
|
||||
content: "Open totp wizard",
|
||||
trigger: 'button[name=totp_enable_wizard]',
|
||||
trigger: 'button[name=action_totp_enable_wizard]',
|
||||
}, {
|
||||
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",
|
||||
@@ -53,18 +53,21 @@ tour.register('totp_tour_setup', {
|
||||
trigger: "button:contains(Confirm Password)",
|
||||
}, {
|
||||
content: "Check the wizard has opened",
|
||||
trigger: 'div:contains("Scan the image below")',
|
||||
trigger: 'li:contains("When requested to do so")',
|
||||
run: () => {}
|
||||
}, {
|
||||
content: "Get secret from collapsed div",
|
||||
trigger: 'a:contains("show the code")',
|
||||
trigger: 'a:contains("Cannot scan it?")',
|
||||
run(helpers) {
|
||||
const secret = this.$anchor.closest('div').find('code').text();
|
||||
const $secret = this.$anchor.closest('div').find('span[name=secret]');
|
||||
const $copyBtn = $secret.find('button');
|
||||
$copyBtn.remove();
|
||||
const secret = $secret.text();
|
||||
ajax.jsonRpc('/totphook', 'call', {
|
||||
secret
|
||||
}).then((token) => {
|
||||
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)'));
|
||||
$('body').addClass('got-token')
|
||||
});
|
||||
}
|
||||
@@ -77,7 +80,7 @@ tour.register('totp_tour_setup', {
|
||||
...openUserProfileAtSecurityTab(),
|
||||
{
|
||||
content: "Check that the button has changed",
|
||||
trigger: 'button:contains(Disable two-factor authentication)',
|
||||
trigger: 'button[name=action_totp_disable]',
|
||||
run: () => {}
|
||||
}]);
|
||||
|
||||
@@ -109,7 +112,7 @@ tour.register('totp_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")'));
|
||||
});
|
||||
}
|
||||
}, {
|
||||
@@ -117,16 +120,16 @@ tour.register('totp_login_enabled', {
|
||||
trigger: ".o_user_menu .oe_topbar_name",
|
||||
run: () => {}
|
||||
},
|
||||
// now go and disable totp would be annoying to do in a separate tour
|
||||
// now go and disable two-factor authentication would be annoying to do in a separate tour
|
||||
// because we'd need to login & totp again as HttpCase.authenticate can't
|
||||
// succeed w/ totp enabled
|
||||
...openUserProfileAtSecurityTab(),
|
||||
{
|
||||
content: "Open totp wizard",
|
||||
trigger: 'button[name=totp_disable]',
|
||||
trigger: 'button[name=action_totp_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",
|
||||
@@ -140,7 +143,7 @@ tour.register('totp_login_enabled', {
|
||||
...openUserProfileAtSecurityTab(),
|
||||
{
|
||||
content: "Check that the button has changed",
|
||||
trigger: 'button:contains(Enable two-factor authentication)',
|
||||
trigger: 'button[name=action_totp_enable_wizard]',
|
||||
run: () => {}
|
||||
}]);
|
||||
|
||||
@@ -212,20 +215,17 @@ tour.register('totp_admin_disables', {
|
||||
}
|
||||
const $row = this.$anchor.closest('tr');
|
||||
const sel = $row.find('.o_list_record_selector input[type=checkbox]');
|
||||
const totp = $row[0].children[columns['totp_enabled']].querySelector('input');
|
||||
if (totp.checked) {
|
||||
helpers.click(sel);
|
||||
}
|
||||
helpers.click(sel);
|
||||
}
|
||||
}, {
|
||||
content: "Open Actions menu",
|
||||
trigger: 'button.o_dropdown_toggler:contains("Action")'
|
||||
}, {
|
||||
content: "Select totp remover",
|
||||
trigger: 'a.dropdown-item:contains(Disable TOTP on users)'
|
||||
trigger: 'a.dropdown-item:contains(Disable two-factor authentication)'
|
||||
}, { // enhanced security yo
|
||||
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",
|
||||
@@ -235,13 +235,13 @@ trigger: 'div:contains("confirm your password")',
|
||||
content: "Confirm",
|
||||
trigger: "button:contains(Confirm Password)",
|
||||
}, {
|
||||
content: "check that demo user has been de-totp'd",
|
||||
content: "open the user's form",
|
||||
trigger: "td.o_data_cell:contains(demo)",
|
||||
run: function () {
|
||||
const totpcell = this.$anchor.closest('tr')[0].children[columns['totp_enabled']];
|
||||
if (totpcell.querySelector('input').checked) {
|
||||
throw new Error("totp should have been disabled on demo user");
|
||||
}
|
||||
}
|
||||
}, {
|
||||
content: "go to Account security Tab",
|
||||
trigger: "a.nav-link:contains(Account Security)",
|
||||
}, {
|
||||
content: "check that demo user has been de-totp'd",
|
||||
trigger: "button:contains(Invite to use 2FA)",
|
||||
}])
|
||||
});
|
||||
|
||||
@@ -5,34 +5,37 @@
|
||||
<div class="oe_login_form">
|
||||
<h5 class="card-title">Two-factor Authentication</h5>
|
||||
<form method="POST" action="" class="">
|
||||
<div class="mb-2 mt-2 text-muted">
|
||||
To login, enter below the six-digit authentication code provided by your Authenticator app.
|
||||
<br/>
|
||||
<a href="https://www.odoo.com/documentation/15.0/applications/general/auth/2fa.html"
|
||||
title="Learn More" target="_blank">Learn More</a>
|
||||
</div>
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="redirect" t-att-value="redirect"/>
|
||||
<div class="form-group">
|
||||
<label for="totp_token">Authentication Code (6 digits)</label>
|
||||
<label class="font-weight-bold" for="totp_token">Authentication Code</label>
|
||||
<input id="totp_token" name="totp_token" class="form-control mb-2"
|
||||
autofocus="autofocus" required="required"/>
|
||||
autocomplete="off" inputmode="numeric" autofocus="autofocus" required="required" placeholder="e.g. 123456"/>
|
||||
</div>
|
||||
<p class="alert alert-danger" t-if="error" role="alert">
|
||||
<t t-esc="error"/>
|
||||
</p>
|
||||
<div t-attf-class="clearfix oe_login_buttons text-center mb-1">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
<div class="small mb-2 mt-2 text-muted">
|
||||
<i class="fa fa-2x fa-mobile pull-left"/>
|
||||
Open the two-factor authentication app on your
|
||||
device to obtain a code and verify your identity
|
||||
<div class="text-center py-2 border-top">
|
||||
<p class="alert alert-danger" t-if="error" role="alert">
|
||||
<t t-esc="error"/>
|
||||
</p>
|
||||
<div t-attf-class="clearfix oe_login_buttons text-center mb-1">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="text-center pb-2 border-top">
|
||||
<form method="POST" action="/web/session/logout" class="form-inline">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<button type="submit" class="btn btn-link btn-sm mb-2">
|
||||
Cancel
|
||||
</button>
|
||||
<div class="w-100 text-center">
|
||||
<button type="submit" class="btn btn-link btn-sm mb-2">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -1,51 +1,64 @@
|
||||
<odoo>
|
||||
<record model="ir.ui.view" id="view_totp_list">
|
||||
<field name="name">users list: add totp status</field>
|
||||
<record id="res_users_view_search" model="ir.ui.view">
|
||||
<field name="name">res.users.view.search.inherit.auth.totp</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_tree"/>
|
||||
<field name="inherit_id" ref="base.view_users_search" />
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="totp_enabled"/>
|
||||
</tree>
|
||||
<xpath expr="//search" position="inside">
|
||||
<separator/>
|
||||
<filter name="totp_enabled" string="Two-factor authentication Enabled" domain="[('totp_secret','!=',False)]"/>
|
||||
<separator/>
|
||||
<filter name="totp_disabled" string="Two-factor authentication Disabled" domain="[('totp_secret','=',False)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_totp_form">
|
||||
<field name="name">user form: add totp status</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='references']/group[1]" position="before">
|
||||
<field name="totp_enabled" invisible="1"/>
|
||||
<group attrs="{'invisible': [('totp_enabled', '!=', False)]}">
|
||||
<div>
|
||||
<span class="alert alert-info" role="status">
|
||||
<i class="fa fa-warning"/>
|
||||
Two-factor authentication not enabled
|
||||
</span>
|
||||
</div>
|
||||
</group>
|
||||
<group attrs="{'invisible': [('totp_enabled', '=', False)]}">
|
||||
<div>
|
||||
<span class="text-success">
|
||||
<i class="fa fa-check-circle"/>
|
||||
Two-factor authentication enabled
|
||||
</span>
|
||||
</div>
|
||||
</group>
|
||||
<xpath expr="//page[@name='preferences']" position="after">
|
||||
<page string="Account Security" name="security" attrs="{'invisible': [('id', '=', False)]}">
|
||||
<field name="totp_enabled" invisible="1"/>
|
||||
<!-- For own user, allow to activate the two-factor Authentication -->
|
||||
<group>
|
||||
<div>
|
||||
<div class="o_horizontal_separator d-flex align-items-center mt-0">Two-factor Authentication
|
||||
<div attrs="{'invisible': [('totp_enabled', '!=', False)]}">
|
||||
<button attrs="{'invisible': "[('id', '=', uid)]"}" name="totp_enable_wizard"
|
||||
disabled="1" type="object" class="fa fa-toggle-off o_auth_2fa_btn disabled" aria-label="Enable 2FA"></button>
|
||||
<button attrs="{'invisible': "[('id', '!=', uid)]"}" name="totp_enable_wizard"
|
||||
type="object" class="fa fa-toggle-off o_auth_2fa_btn disabled" aria-label="Enable 2FA"></button>
|
||||
<button groups="base.group_erp_manager" attrs="{'invisible': "[('id', '=', uid)]"}"
|
||||
name="totp_invite" string="Invite to use 2FA" type="object" class="btn btn-primary"></button>
|
||||
</div>
|
||||
<button attrs="{'invisible': [('totp_enabled', '=', False)]}" name="totp_disable" type="object"
|
||||
class="fa fa-toggle-on o_auth_2fa_btn text-primary enabled" aria-label="Disable 2FA"></button>
|
||||
</div>
|
||||
<span attrs="{'invisible': [('totp_enabled', '!=', False)]}" class="text-muted">
|
||||
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.
|
||||
<a href="https://www.odoo.com/documentation/15.0/applications/general/auth/2fa.html"
|
||||
title="Learn More" target="_blank">Learn More</a>
|
||||
</span>
|
||||
<span attrs="{'invisible': [('totp_enabled', '=', False)]}" class="text-muted">Your account is protected!</span>
|
||||
</div>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.server" id="action_disable_totp">
|
||||
<field name="name">Disable TOTP on users</field>
|
||||
<field name="name">Disable two-factor authentication</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
action = records.totp_disable()
|
||||
action = records.action_totp_disable()
|
||||
</field>
|
||||
<field name="groups_id" eval="[(4, ref('base.group_erp_manager'), 0)]"/>
|
||||
<field name="groups_id" eval="[(4, ref('base.group_erp_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_totp_wizard">
|
||||
@@ -54,51 +67,68 @@
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="row container">
|
||||
<div class="mb-3">
|
||||
<h3 class="font-weight-bold">Scan this barcode with your app</h3>
|
||||
<div>
|
||||
Scan the image below with the authenticator app on your phone.<br/>
|
||||
If you cannot scan the barcode, here are some alternative options:
|
||||
<ul>
|
||||
<li><field class="text-wrap" name="url" widget="url"
|
||||
options="{'website_path': True}"
|
||||
text="Click on this link to open your authenticator app"/></li>
|
||||
<div class="o_auth_totp_enable_2FA container">
|
||||
<div class="mb-3 w-100">
|
||||
<h3 class="font-weight-bold">Authenticator App Setup</h3>
|
||||
<ul>
|
||||
<div class="d-md-none d-block">
|
||||
<li>
|
||||
<field class="text-wrap" name="url" widget="url" options="{'website_path': True}"
|
||||
text="Click on this link to open your authenticator app"/></li>
|
||||
</div>
|
||||
<li>
|
||||
<div class="d-flex align-items-center flex-wrap">
|
||||
<span class="d-md-none d-block">Or install an authenticator app</span>
|
||||
<span class="d-none d-md-block">Install an authenticator app on your mobile device</span>
|
||||
<div class="d-block d-md-none">
|
||||
<a href="https://play.google.com/store/search?q=authenticator&c=apps" class="mx-2" target="blank">
|
||||
<img alt="On Google Play" style="width: 24px;" src="/base_setup/static/src/img/logo_google_play.png"/>
|
||||
</a>
|
||||
<a href="http://appstore.com/2fa" class="mx-2" target="blank">
|
||||
<img alt="On Apple Store" style="width: 24px;" src="/base_setup/static/src/img/logo_apple_store.png"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>Or enter the secret code manually:
|
||||
<a data-toggle="collapse"
|
||||
href="#collapseTotpSecret" role="button" aria-expanded="false"
|
||||
aria-controls="collapseTotpSecret">show the code</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- code outside list to have more horiz space on mobile -->
|
||||
<div class="collapse col-12 col-md-6" id="collapseTotpSecret">
|
||||
<div class="card card-body">
|
||||
<h3>Your two-factor secret:</h3>
|
||||
<code class="text-center"><field name="secret"/></code>
|
||||
</div>
|
||||
<span class="text-muted">Popular ones include Authy, Google Authenticator or the Microsoft Authenticator.</span>
|
||||
<li>Look for an "Add an account" button</li>
|
||||
<li>
|
||||
<span class="d-none d-md-block">When requested to do so, scan the barcode below</span>
|
||||
<span class="d-block d-md-none">When requested to do so, copy the key below</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Desktop version -->
|
||||
<div class="text-center d-none d-md-block">
|
||||
<field name="qrcode" readonly="True" widget="image"/>
|
||||
|
||||
<h3 class="font-weight-bold"><a data-toggle="collapse"
|
||||
href="#collapseTotpSecret" role="button" aria-expanded="false"
|
||||
aria-controls="collapseTotpSecret">Cannot scan it?</a></h3>
|
||||
<div class="collapse" id="collapseTotpSecret">
|
||||
<field name="secret" widget="CopyClipboardChar" readonly="1" class="mb-3 pl-3"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<field class="offset-1" name="qrcode" readonly="True" widget="image"/>
|
||||
|
||||
<h3 class="font-weight-bold">Enter the 6-digit code from your app</h3>
|
||||
<div class="text-justify col-10 col-lg-6 px-0">
|
||||
After scanning the barcode, the app will display a 6-digit code that you
|
||||
should enter below. Don't worry if the code changes in the app,
|
||||
it stays valid a bit longer.
|
||||
<!-- Mobile Version -->
|
||||
<div class="text-center d-block d-md-none">
|
||||
<field name="secret" widget="CopyClipboardChar" readonly="1" class="mb-3 pl-3"/>
|
||||
</div>
|
||||
|
||||
<h3 class="font-weight-bold">Enter your six-digit code below</h3>
|
||||
<div class="mt-2">
|
||||
<label for="code" class="col-4 col-md-12 px-0">Verification Code</label>
|
||||
<field required="True" name="code" class="col-10 col-md-6 px-0"/>
|
||||
<label for="code" class="px-0">Verification Code</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<field required="True" name="code" autocomplete="one-time-code" class="px-0 mr-2" placeholder="e.g. 123456"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button type="object" name="enable" class="btn btn-primary"
|
||||
string="Enable two-factor authentication" data-hotkey="q"/>
|
||||
string="Activate" data-hotkey="q"/>
|
||||
<button string="Cancel" special="cancel" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -110,33 +140,81 @@
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
|
||||
<field name="arch" type="xml">
|
||||
<button name="preference_change_password" position="after">
|
||||
<field name="totp_enabled" invisible="1"/>
|
||||
<group attrs="{'invisible': [('totp_enabled', '!=', False)]}">
|
||||
<div>
|
||||
<span class="alert alert-info" role="status">
|
||||
<i class="fa fa-warning"/>
|
||||
Two-factor authentication not enabled
|
||||
<a href="https://www.odoo.com/documentation/14.0/applications/general/auth/2fa.html"
|
||||
title="What is this?" class="o_doc_link" target="_blank"></a>
|
||||
</span>
|
||||
<button name="totp_enable_wizard" type="object" string="Enable two-factor authentication"
|
||||
class="btn btn-info mx-3"/>
|
||||
<group name="auth" position="after">
|
||||
<field name="totp_enabled" invisible="1"/>
|
||||
<group>
|
||||
<div>
|
||||
<div class="o_horizontal_separator mt-0">Two-factor Authentication
|
||||
<button attrs="{'invisible': [('totp_enabled', '!=', False)]}" name="totp_enable_wizard"
|
||||
type="object" class="fa fa-toggle-off o_auth_2fa_btn mb-1" aria-label="Enable 2FA"/>
|
||||
<button attrs="{'invisible': [('totp_enabled', '=', False)]}" name="totp_disable"
|
||||
type="object" class="fa fa-toggle-on o_auth_2fa_btn text-primary" aria-label="Disable 2FA"/>
|
||||
</div>
|
||||
</group>
|
||||
<group attrs="{'invisible': [('totp_enabled', '=', False)]}">
|
||||
<div>
|
||||
<span class="text-success">
|
||||
<i class="fa fa-check-circle"/>
|
||||
Two-factor authentication enabled
|
||||
<a href="https://www.odoo.com/documentation/14.0/applications/general/auth/2fa.html"
|
||||
title="What is this?" class="o_doc_link" target="_blank"></a>
|
||||
</span>
|
||||
<button name="totp_disable" type="object" string="(Disable two-factor authentication)"
|
||||
class="btn btn-link text-muted"/>
|
||||
</div>
|
||||
</group>
|
||||
</button>
|
||||
<span attrs="{'invisible': [('totp_enabled', '!=', False)]}" class="text-muted">
|
||||
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.
|
||||
<a href="https://www.odoo.com/documentation/15.0/applications/general/auth/2fa.html"
|
||||
title="Learn More" target="_blank">Learn More</a>
|
||||
</span>
|
||||
<span attrs="{'invisible': [('totp_enabled', '=', False)]}" class="text-muted">Your account is protected!</span>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Invite to user 2FA -->
|
||||
<record model="ir.actions.server" id="action_invite_totp">
|
||||
<field name="name">Invite to use two-factor authentication</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
action = records.action_totp_invite()
|
||||
</field>
|
||||
<field name="groups_id" eval="[(4, ref('base.group_erp_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="res_users_view_form_security">
|
||||
<field name="name">users preference: Account Security</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<form position="attributes">
|
||||
<attribute name='create'>0</attribute>
|
||||
<attribute name='edit'>0</attribute>
|
||||
<attribute name='delete'>0</attribute>
|
||||
</form>
|
||||
<h1 position="replace"/>
|
||||
<xpath expr="//field[@name='image_1920']" position="replace"/>
|
||||
<notebook position="replace">
|
||||
<header>
|
||||
</header>
|
||||
<sheet>$0</sheet>
|
||||
</notebook>
|
||||
<notebook position="before">
|
||||
<field name="image_1920" widget="image" class="oe_avatar" options="{'zoom': true, 'preview_image':'image_128'}"/>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Name" required="True" readonly="context.get('from_my_profile', False)"/>
|
||||
</h1>
|
||||
</div>
|
||||
</notebook>
|
||||
<page name="preferences_page" position="replace"></page>
|
||||
<footer position="replace"/>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.actions.server" id="action_activate_two_factor_authentication">
|
||||
<field name="name">Open two-factor authentication configuration</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
user = env.user
|
||||
action = user.action_open_my_account_settings()
|
||||
</field>
|
||||
<field name="groups_id" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import res_users
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -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?
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="portal.identitycheck">
|
||||
<form string="Security Control">
|
||||
<h3><strong>Please confirm your password to continue</strong></h3>
|
||||
<p>This is necessary for security-related changes. The authorization will last for a few minutes.</p>
|
||||
<h3><strong>Please enter your password to confirm you own this account</strong></h3>
|
||||
<br/>
|
||||
<div>
|
||||
<label for="password" class="col-4 col-md-12 px-0">Password</label>
|
||||
<input class="form-control col-10 col-md-6" autocomplete="current-password"
|
||||
name="password" type="password" required="required"/>
|
||||
</div>
|
||||
<a href="/web/reset_password/" class="btn btn-link" role="button">Forgot password?</a>
|
||||
</form>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -797,7 +797,7 @@
|
||||
<td width="100%"><input type="password" class="o_field_widget o_input" name="new_password" minlength="1" autocomplete="new-password"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="o_td_label"><label for="confirm_pwd" class="o_form_label">Confirm New Password</label></td>
|
||||
<td class="o_td_label"><label for="confirm_pwd" class="o_form_label">New Password (Confirmation)</label></td>
|
||||
<td width="100%"><input type="password" class="o_field_widget o_input" name="confirm_pwd" minlength="1" autocomplete="new-password"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -39,19 +39,18 @@
|
||||
<record id="identity_check_wizard" model="ir.ui.view">
|
||||
<field name="model">res.users.identitycheck</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Security Control">
|
||||
<form string="Password Confirmation">
|
||||
<sheet clas="bg-primary">
|
||||
<h3><strong>Please confirm your password to continue</strong></h3>
|
||||
<p>This is necessary for security-related changes. The authorization will last for a few minutes.</p>
|
||||
<h3><strong>Please enter your password to confirm you own this account</strong></h3>
|
||||
<br/>
|
||||
<div>
|
||||
<label for="password" class="col-4 col-md-12 px-0"/>
|
||||
<field class="col-10 col-md-6 px-0" name="password" autocomplete="current-password" required="True" password="True"/>
|
||||
</div>
|
||||
<a href="/web/reset_password/" class="btn btn-link" role="button">Forgot password?</a>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button string="Confirm Password" type="object" name="run_check" class="btn btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" special="cancel" data-hotkey="z" class="btn btn-secondary"/>
|
||||
<a href="/web/reset_password/" class="btn btn-link" role="button">Forgot password?</a>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
@@ -235,7 +234,7 @@
|
||||
</group>
|
||||
<field name="groups_id"/>
|
||||
</page>
|
||||
<page string="Preferences" name="references">
|
||||
<page string="Preferences" name="preferences">
|
||||
<group>
|
||||
<group string="Localization" name="preferences">
|
||||
<field name="active" invisible="1"/>
|
||||
@@ -433,7 +432,7 @@
|
||||
<field name="name" readonly="1" class="oe_inline"/>
|
||||
</h1>
|
||||
<notebook>
|
||||
<page string="Preferences">
|
||||
<page string="Preferences" name="preferences_page">
|
||||
<group name="preferences">
|
||||
<group>
|
||||
<field name="email" widget="email" readonly="0"/>
|
||||
@@ -465,16 +464,17 @@
|
||||
<group name="preference_contact"></group>
|
||||
</page>
|
||||
<page string="Account Security">
|
||||
<group name="auth" string="Authentication">
|
||||
<group name="auth" string="Password Management">
|
||||
<div>
|
||||
<button name="preference_change_password" type="object" string="Change password" class="btn btn-secondary"/>
|
||||
</div>
|
||||
</group>
|
||||
<group string="Developer API Keys" help="test">
|
||||
<a href="https://www.odoo.com/documentation/14.0/developer/webservices/odoo.html#api-keys" target="_blank">
|
||||
<i class="fa fa-fw o_button_icon fa-arrow-right"></i>
|
||||
<span>Documentation</span>
|
||||
</a>
|
||||
<group groups="base.group_no_one">
|
||||
<div class="o_horizontal_separator">Developer API Keys
|
||||
<a href="https://www.odoo.com/documentation/14.0/developer/webservices/odoo.html#api-keys" target="_blank">
|
||||
<i title="Documentation" class="fa fa-fw o_button_icon fa-info-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div colspan="2" attrs="{'invisible': [('api_key_ids', '=', [])]}">
|
||||
<field name="api_key_ids" nolabel="1" colspan="4" readonly="0">
|
||||
<tree editable="bottom" create="false" delete="false">
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user