19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:28 +01:00
parent 20ddc1b4a3
commit c0efcc53f5
1162 changed files with 125577 additions and 105287 deletions

View file

@ -0,0 +1,25 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { handleCheckIdentity } from "@portal/interactions/portal_security";
import { user } from "@web/core/user";
export class RevokeAllTrustedDevices extends Interaction {
static selector = "#auth_totp_portal_revoke_all_devices";
dynamicContent = {
_root: { "t-on-click.prevent": this.onClick },
};
async onClick() {
await this.waitFor(handleCheckIdentity(
this.waitFor(this.services.orm.call("res.users", "revoke_all_devices", [user.userId])),
this.services.orm,
this.services.dialog,
));
location.reload();
}
}
registry
.category("public.interactions")
.add("auth_totp_portal.revoke_all_trusted_devices", RevokeAllTrustedDevices);

View file

@ -0,0 +1,24 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { handleCheckIdentity } from "@portal/interactions/portal_security";
export class RevokeTrustedDevice extends Interaction {
static selector = "#totp_wizard_view + * .fa.fa-trash.text-danger";
dynamicContent = {
_root: { "t-on-click.prevent": this.onClick },
};
async onClick() {
await this.waitFor(handleCheckIdentity(
this.waitFor(this.services.orm.call("auth_totp.device", "remove", [parseInt(this.el.id)])),
this.services.orm,
this.services.dialog,
));
location.reload();
}
}
registry
.category("public.interactions")
.add("auth_totp_portal.revoke_trusted_device", RevokeTrustedDevice);

View file

@ -0,0 +1,26 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { handleCheckIdentity } from "@portal/interactions/portal_security";
import { user } from "@web/core/user";
export class TOTPDisable extends Interaction {
static selector = "#auth_totp_portal_disable";
dynamicContent = {
_root: { "t-on-click.prevent": this.onClick }
}
async onClick() {
await this.waitFor(handleCheckIdentity(
this.waitFor(this.services.orm.call("res.users", "action_totp_disable", [user.userId])),
this.services.orm,
this.services.dialog,
));
location.reload();
}
}
registry
.category("public.interactions")
.add("auth_totp_portal.totp_disable", TOTPDisable);

View file

@ -0,0 +1,199 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { InputConfirmationDialog } from "@portal/js/components/input_confirmation_dialog/input_confirmation_dialog";
import { handleCheckIdentity } from "@portal/interactions/portal_security";
import { browser } from "@web/core/browser/browser";
import { user } from "@web/core/user";
import { _t } from "@web/core/l10n/translation";
import { markup } from "@odoo/owl";
/**
* Replaces specific <field> elements by normal HTML, strip out the rest entirely
*/
function fromField(f, record) {
switch (f.getAttribute("name")) {
case "qrcode":
const qrcode = document.createElement("img");
qrcode.setAttribute("class", "img img-fluid");
qrcode.setAttribute("src", "data:image/png;base64," + record["qrcode"]);
return qrcode;
case "url":
const url = document.createElement("a");
url.setAttribute("href", record["url"]);
url.textContent = f.getAttribute("text") || record["url"];
return url;
case "code":
const code = document.createElement("input");
code.setAttribute("name", "code");
code.setAttribute("class", "form-control col-10 col-md-6");
code.setAttribute("placeholder", "6-digit code");
code.required = true;
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 = async function (event) {
event.preventDefault();
$(copyButton).tooltip({ title: _t("Copied!"), trigger: "manual", placement: "bottom" });
await browser.navigator.clipboard.writeText($(secretSpan)[0].innerText);
$(copyButton).tooltip("show");
setTimeout(() => $(copyButton).tooltip("hide"), 800);
};
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")] || "");
}
}
/**
* Apparently chrome literally absolutely can't handle parsing XML and using
* those nodes in an HTML document (even when parsing as application/xhtml+xml),
* this results in broken rendering and a number of things not working (e.g.
* classes) without any specific warning in the console or anything, things are
* just broken with no indication of why.
*
* So... rebuild the entire f'ing body using document.createElement to ensure
* we have HTML elements.
*
* This is a recursive implementation so it's not super efficient but the views
* to fixup *should* be relatively simple.
*/
function fixupViewBody(oldNode, record) {
let qrcode = null, code = null, node = null;
switch (oldNode.nodeType) {
case 1: // element
if (oldNode.tagName === "field") {
node = fromField(oldNode, record);
switch (oldNode.getAttribute("name")) {
case "qrcode":
qrcode = node;
break;
case "code":
code = node;
break
}
break; // no need to recurse here
}
node = document.createElement(oldNode.tagName);
for (let i = 0; i < oldNode.attributes.length; ++i) {
const attr = oldNode.attributes[i];
node.setAttribute(attr.name, attr.value);
}
for (let j = 0; j < oldNode.childNodes.length; ++j) {
const [ch, qr, co] = fixupViewBody(oldNode.childNodes[j], record);
if (ch) { node.appendChild(ch); }
if (qr) { qrcode = qr; }
if (co) { code = co; }
}
break;
case 3: case 4: // text, cdata
node = document.createTextNode(oldNode.data);
break;
default:
// don't care about PI & al
}
return [node, qrcode, code]
}
export class TOTPEnable extends Interaction {
static selector = "#auth_totp_portal_enable";
dynamicContent = {
_root: { "t-on-click.prevent": this.onClick },
};
async onClick() {
const data = await this.waitFor(handleCheckIdentity(
this.waitFor(this.services.orm.call("res.users", "action_totp_enable_wizard", [user.userId])),
this.services.orm,
this.services.dialog,
));
if (!data) {
// TOTP probably already enabled, just reload page
location.reload()
return;
}
const model = data.res_model;
const wizard_id = data.res_id;
const record = (await this.services.orm.read(model, [wizard_id], []))[0];
const doc = new DOMParser().parseFromString(
document.getElementById("totp_wizard_view").textContent,
"application/xhtml+xml"
);
const xmlBody = doc.querySelector("sheet *");
const [body, ,] = fixupViewBody(xmlBody, record);
this.services.dialog.add(InputConfirmationDialog, {
body: markup(body.outerHTML),
onInput: ({ inputEl }) => { inputEl.setCustomValidity("") },
confirmLabel: _t("Activate"),
confirm: async ({ inputEl }) => {
if (!inputEl.reportValidity()) {
inputEl.classList.add("is-invalid");
return false;
}
try {
await handleCheckIdentity(
this.waitFor(this.services.orm.call(model, "enable",
[ record.id ],
{ 'context': {'code': inputEl.value} },
)),
this.services.orm,
this.services.dialog
);
} catch (e) {
const errorMessage = (
!e.message ? e.toString()
: !e.message.data ? e.message.message
: e.message.data.message || _t("Operation failed for unknown reason.")
);
inputEl.classList.add("is-invalid");
// show custom validity error message
inputEl.setCustomValidity(errorMessage);
inputEl.reportValidity();
return false;
}
// reloads page, avoid window.location.reload() because it re-posts forms
location.reload();
},
cancel: () => { },
});
}
}
registry
.category("public.interactions")
.add("auth_totp_portal.totp_enable", TOTPEnable);

View file

@ -1,306 +0,0 @@
odoo.define('auth_totp_portal.button', function (require) {
'use strict';
const {_t} = require('web.core');
const publicWidget = require('web.public.widget');
const Dialog = require('web.Dialog');
const {handleCheckIdentity} = require('portal.portal');
/**
* Replaces specific <field> elements by normal HTML, strip out the rest entirely
*/
function fromField(f, record) {
switch (f.getAttribute('name')) {
case 'qrcode':
const qrcode = document.createElement('img');
qrcode.setAttribute('class', 'img img-fluid');
qrcode.setAttribute('src', 'data:image/png;base64,' + record['qrcode']);
return qrcode;
case 'url':
const url = document.createElement('a');
url.setAttribute('href', record['url']);
url.textContent = f.getAttribute('text') || record['url'];
return url;
case 'code':
const code = document.createElement('input');
code.setAttribute('name', 'code');
code.setAttribute('class', 'form-control col-10 col-md-6');
code.setAttribute('placeholder', '6-digit code');
code.required = true;
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')] || '');
}
}
/**
* Apparently chrome literally absolutely can't handle parsing XML and using
* those nodes in an HTML document (even when parsing as application/xhtml+xml),
* this results in broken rendering and a number of things not working (e.g.
* classes) without any specific warning in the console or anything, things are
* just broken with no indication of why.
*
* So... rebuild the entire f'ing body using document.createElement to ensure
* we have HTML elements.
*
* This is a recursive implementation so it's not super efficient but the views
* to fixup *should* be relatively simple.
*/
function fixupViewBody(oldNode, record) {
let qrcode = null, code = null, node = null;
switch (oldNode.nodeType) {
case 1: // element
if (oldNode.tagName === 'field') {
node = fromField(oldNode, record);
switch (oldNode.getAttribute('name')) {
case 'qrcode':
qrcode = node;
break;
case 'code':
code = node;
break
}
break; // no need to recurse here
}
node = document.createElement(oldNode.tagName);
for(let i=0; i<oldNode.attributes.length; ++i) {
const attr = oldNode.attributes[i];
node.setAttribute(attr.name, attr.value);
}
for(let j=0; j<oldNode.childNodes.length; ++j) {
const [ch, qr, co] = fixupViewBody(oldNode.childNodes[j], record);
if (ch) { node.appendChild(ch); }
if (qr) { qrcode = qr; }
if (co) { code = co; }
}
break;
case 3: case 4: // text, cdata
node = document.createTextNode(oldNode.data);
break;
default:
// don't care about PI & al
}
return [node, qrcode, code]
}
/**
* Converts a backend <button> element and a bunch of metadata into a structure
* which can kinda be of use to Dialog.
*/
class Button {
constructor(parent, model, record_id, input_node, button_node) {
this._parent = parent;
this.model = model;
this.record_id = record_id;
this.input = input_node;
this.text = button_node.getAttribute('string');
this.classes = button_node.getAttribute('class') || null;
this.action = button_node.getAttribute('name');
if (button_node.getAttribute('special') === 'cancel') {
this.close = true;
this.click = null;
} else {
this.close = false;
// because Dialog doesnt' call() click on the descriptor object
this.click = this._click.bind(this);
}
}
async _click() {
if (!this.input.reportValidity()) {
this.input.classList.add('is-invalid');
return;
}
try {
await this.callAction(this.record_id, {code: this.input.value});
} catch (e) {
this.input.classList.add('is-invalid');
// show custom validity error message
this.input.setCustomValidity(e.message);
this.input.reportValidity();
return;
}
this.input.classList.remove('is-invalid');
// reloads page, avoid window.location.reload() because it re-posts forms
window.location = window.location;
}
async callAction(id, update) {
try {
await this._parent._rpc({model: this.model, method: 'write', args: [id, update]});
await handleCheckIdentity(
this._parent.proxy('_rpc'),
this._parent._rpc({model: this.model, method: this.action, args: [id]})
);
} catch(e) {
// avoid error toast (crashmanager)
e.event.preventDefault();
// try to unwrap mess of an error object to a usable error message
throw new Error(
!e.message ? e.toString()
: !e.message.data ? e.message.message
: e.message.data.message || _t("Operation failed for unknown reason.")
);
}
}
}
publicWidget.registry.TOTPButton = publicWidget.Widget.extend({
selector: '#auth_totp_portal_enable',
events: {
click: '_onClick',
},
async _onClick(e) {
e.preventDefault();
const w = await handleCheckIdentity(this.proxy('_rpc'), this._rpc({
model: 'res.users',
method: 'action_totp_enable_wizard',
args: [this.getSession().user_id]
}));
if (!w) {
// TOTP probably already enabled, just reload page
window.location = window.location;
return;
}
const {res_model: model, res_id: wizard_id} = w;
const record = await this._rpc({
model, method: 'read', args: [wizard_id, []]
}).then(ar => ar[0]);
const doc = new DOMParser().parseFromString(
document.getElementById('totp_wizard_view').textContent,
'application/xhtml+xml'
);
const xmlBody = doc.querySelector('sheet *');
const [body, , codeInput] = fixupViewBody(xmlBody, record);
// remove custom validity error message any time the field changes
// otherwise it sticks and browsers suppress submit
codeInput.addEventListener('input', () => codeInput.setCustomValidity(''));
const buttons = [];
for(const button of doc.querySelectorAll('footer button')) {
buttons.push(new Button(this, model, record.id, codeInput, button));
}
// wrap in a root host of .modal-body otherwise it breaks our neat flex layout
const $content = document.createElement('form');
$content.appendChild(body);
// implicit submission by pressing [return] from within input
$content.addEventListener('submit', (e) => {
e.preventDefault();
// sadness: footer not available as normal element
dialog.$footer.find('.btn-primary').click();
});
var dialog = new Dialog(this, {$content, buttons}).open();
}
});
publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({
selector: '#auth_totp_portal_disable',
events: {
click: '_onClick'
},
async _onClick(e) {
e.preventDefault();
await handleCheckIdentity(
this.proxy('_rpc'),
this._rpc({model: 'res.users', method: 'action_totp_disable', args: [this.getSession().user_id]})
)
window.location = window.location;
}
});
publicWidget.registry.RevokeTrustedDeviceButton = publicWidget.Widget.extend({
selector: '#totp_wizard_view + * .fa.fa-trash.text-danger',
events: {
click: '_onClick'
},
async _onClick(e){
e.preventDefault();
await handleCheckIdentity(
this.proxy('_rpc'),
this._rpc({
model: 'auth_totp.device',
method: 'remove',
args: [parseInt(this.target.id)]
})
);
window.location = window.location;
}
});
publicWidget.registry.RevokeAllTrustedDevicesButton = publicWidget.Widget.extend({
selector: '#auth_totp_portal_revoke_all_devices',
events: {
click: '_onClick'
},
async _onClick(e){
e.preventDefault();
await handleCheckIdentity(
this.proxy('_rpc'),
this._rpc({
model: 'res.users',
method: 'revoke_all_devices',
args: [this.getSession().user_id]
})
);
window.location = window.location;
}
});
});

View file

@ -9,7 +9,7 @@
width: 100% !important;
border-radius: 5px;
border: 1px solid $primary;
font-size: $font-size-sm;
@include font-size($font-size-sm);
text-transform: uppercase;
color: $o-brand-primary;
font-weight: $badge-font-weight;

View file

@ -1,124 +1,136 @@
odoo.define('auth_totp_portal.tours', function(require) {
"use strict";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
const tour = require('web_tour.tour');
const ajax = require('web.ajax');
tour.register('totportal_tour_setup', {
test: true,
url: '/my/security'
}, [{
registry.category("web_tour.tours").add('totportal_tour_setup', {
url: '/my/security',
steps: () => [{
content: "Open totp wizard",
trigger: 'button#auth_totp_portal_enable',
run: "click",
}, {
content: "Check that we have to enter enhanced security mode",
trigger: 'div:contains("enter your password")',
run: () => {},
trigger: ".modal div:contains(enter your password)",
}, {
content: "Input password",
trigger: '[name=password]',
run: 'text portal', // FIXME: better way to do this?
run: "edit portal", // FIXME: better way to do this?
}, {
content: "Confirm",
trigger: "button:contains(Confirm Password)",
run: "click",
}, {
content: "Check the wizard has opened",
trigger: 'li:contains("scan the barcode below")',
run: () => {}
trigger: '.o_auth_totp_enable_2FA',
}, {
content: "Get secret from collapsed div",
trigger: 'a:contains("Cannot scan it?")',
run: async function(helpers) {
const secret = this.$anchor.closest('div').find('span[name="secret"]').text();
const token = await ajax.jsonRpc('/totphook', 'call', {
secret
},
{
trigger: `span[name="secret"]:hidden`,
async run(helpers) {
const secret = this.anchor.textContent;
const token = await rpc("/totphook", {
secret,
offset: 0,
});
helpers._text(helpers._get_action_values('input[name=code]'), token);
helpers._click(helpers._get_action_values('button.btn-primary:contains(Activate)'));
await helpers.edit(token, 'input[name="code"]');
}
}, {
trigger: "button.btn-primary:contains(Activate)",
run: "click",
expectUnloadPage: true,
}, {
content: "Check that the button has changed",
trigger: 'button:contains(Disable two-factor authentication)',
run: () => {}
}]);
}]});
tour.register('totportal_login_enabled', {
test: true,
url: '/'
}, [{
registry.category("web_tour.tours").add('totportal_login_enabled', {
url: '/',
steps: () => [{
content: "check that we're on the login page or go to it",
trigger: 'input#login, a:contains(Sign in)'
isActive: ["body:not(:has(input#login))"],
trigger: "a:contains(Sign in)",
run: "click",
expectUnloadPage: true,
}, {
content: "input login",
trigger: 'input#login',
run: 'text portal',
run: "edit portal",
}, {
content: 'input password',
trigger: 'input#password',
run: 'text portal',
run: "edit portal",
}, {
content: "click da button",
trigger: 'button:contains("Log in")',
run: "click",
expectUnloadPage: true,
}, {
content: "expect totp screen",
trigger: 'label:contains(Authentication Code)',
run: "click",
}, {
content: "input code",
trigger: 'input[name=totp_token]',
run: async function (helpers) {
const token = await ajax.jsonRpc('/totphook', 'call', {});
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("Log in")'));
const token = await rpc('/totphook', { offset: 1 });
await helpers.edit(token);
}
}, {
trigger: "button:contains(Log in)",
run: "click",
expectUnloadPage: true,
}, {
content: "check we're logged in",
trigger: "h3:contains(Documents)",
run: () => {}
trigger: "h3:contains(My account)",
}, {
content: "go back to security",
trigger: "a:contains(Security)",
run: "click",
expectUnloadPage: true,
},{
content: "Open totp wizard",
trigger: 'button#auth_totp_portal_disable',
run: "click",
}, {
content: "Check that we have to enter enhanced security mode",
trigger: 'div:contains("enter your password")',
run: () => {},
trigger: ".modal div:contains(enter your password)",
}, {
content: "Input password",
trigger: '[name=password]',
run: 'text portal', // FIXME: better way to do this?
run: "edit portal",
}, {
content: "Confirm",
trigger: "button:contains(Confirm Password)",
run: "click",
expectUnloadPage: true,
}, {
content: "Check that the button has changed",
trigger: 'button:contains(Enable two-factor authentication)',
run: () => {}
}]);
}]});
tour.register('totportal_login_disabled', {
test: true,
url: '/'
}, [{
registry.category("web_tour.tours").add('totportal_login_disabled', {
url: '/',
steps: () => [{
content: "check that we're on the login page or go to it",
trigger: 'input#login, a:contains(Sign in)'
isActive: ["body:not(:has(input#login))"],
trigger: "a:contains(Sign in)",
run: "click",
expectUnloadPage: true,
}, {
content: "input login",
trigger: 'input#login',
run: 'text portal',
run: "edit portal",
}, {
content: 'input password',
trigger: 'input#password',
run: 'text portal',
run: "edit portal",
}, {
content: "click da button",
trigger: 'button:contains("Log in")',
run: "click",
expectUnloadPage: true,
}, {
content: "check we're logged in",
trigger: "h3:contains(Documents)",
run: () => {}
}]);
});
trigger: "h3:contains(My account)",
}]});