summaryrefslogtreecommitdiff
path: root/addons/portal/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/portal/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/portal/static/src')
-rw-r--r--addons/portal/static/src/js/portal.js245
-rw-r--r--addons/portal/static/src/js/portal_chatter.js311
-rw-r--r--addons/portal/static/src/js/portal_composer.js173
-rw-r--r--addons/portal/static/src/js/portal_sidebar.js75
-rw-r--r--addons/portal/static/src/js/portal_signature.js197
-rw-r--r--addons/portal/static/src/scss/bootstrap.extend.scss13
-rw-r--r--addons/portal/static/src/scss/bootstrap_overridden.scss34
-rw-r--r--addons/portal/static/src/scss/portal.scss563
-rw-r--r--addons/portal/static/src/scss/primary_variables.scss5
-rw-r--r--addons/portal/static/src/xml/portal_chatter.xml162
-rw-r--r--addons/portal/static/src/xml/portal_security.xml13
-rw-r--r--addons/portal/static/src/xml/portal_signature.xml35
12 files changed, 1826 insertions, 0 deletions
diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js
new file mode 100644
index 00000000..610e58b5
--- /dev/null
+++ b/addons/portal/static/src/js/portal.js
@@ -0,0 +1,245 @@
+odoo.define('portal.portal', function (require) {
+'use strict';
+
+var publicWidget = require('web.public.widget');
+const Dialog = require('web.Dialog');
+const {_t, qweb} = require('web.core');
+const ajax = require('web.ajax');
+
+publicWidget.registry.portalDetails = publicWidget.Widget.extend({
+ selector: '.o_portal_details',
+ events: {
+ 'change select[name="country_id"]': '_onCountryChange',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+
+ this.$state = this.$('select[name="state_id"]');
+ this.$stateOptions = this.$state.filter(':enabled').find('option:not(:first)');
+ this._adaptAddressForm();
+
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptAddressForm: function () {
+ var $country = this.$('select[name="country_id"]');
+ var countryID = ($country.val() || 0);
+ this.$stateOptions.detach();
+ var $displayedState = this.$stateOptions.filter('[data-country_id=' + countryID + ']');
+ var nb = $displayedState.appendTo(this.$state).show().length;
+ this.$state.parent().toggle(nb >= 1);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCountryChange: function () {
+ this._adaptAddressForm();
+ },
+});
+
+publicWidget.registry.PortalHomeCounters = publicWidget.Widget.extend({
+ selector: '.o_portal_my_home',
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ this._updateCounters();
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _updateCounters(elem) {
+ const numberRpc = 3;
+ const needed = this.$('[data-placeholder_count]')
+ .map((i, o) => $(o).data('placeholder_count'))
+ .toArray();
+ const counterByRpc = Math.ceil(needed.length / numberRpc); // max counter, last can be less
+
+ const proms = [...Array(Math.min(numberRpc, needed.length)).keys()].map(async i => {
+ await this._rpc({
+ route: "/my/counters",
+ params: {
+ counters: needed.slice(i * counterByRpc, (i + 1) * counterByRpc)
+ },
+ }).then(data => {
+ Object.keys(data).map(k => this.$("[data-placeholder_count='" + k + "']").text(data[k]));
+ });
+ });
+ return Promise.all(proms);
+ },
+});
+
+publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({
+ selector: '.o_portal_search_panel',
+ events: {
+ 'click .search-submit': '_onSearchSubmitClick',
+ 'click .dropdown-item': '_onDropdownItemClick',
+ 'keyup input[name="search"]': '_onSearchInputKeyup',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+ this._adaptSearchLabel(this.$('.dropdown-item.active'));
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _adaptSearchLabel: function (elem) {
+ var $label = $(elem).clone();
+ $label.find('span.nolabel').remove();
+ this.$('input[name="search"]').attr('placeholder', $label.text().trim());
+ },
+ /**
+ * @private
+ */
+ _search: function () {
+ var search = $.deparam(window.location.search.substring(1));
+ search['search_in'] = this.$('.dropdown-item.active').attr('href').replace('#', '');
+ search['search'] = this.$('input[name="search"]').val();
+ window.location.search = $.param(search);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onSearchSubmitClick: function () {
+ this._search();
+ },
+ /**
+ * @private
+ */
+ _onDropdownItemClick: function (ev) {
+ ev.preventDefault();
+ var $item = $(ev.currentTarget);
+ $item.closest('.dropdown-menu').find('.dropdown-item').removeClass('active');
+ $item.addClass('active');
+
+ this._adaptSearchLabel(ev.currentTarget);
+ },
+ /**
+ * @private
+ */
+ _onSearchInputKeyup: function (ev) {
+ if (ev.keyCode === $.ui.keyCode.ENTER) {
+ this._search();
+ }
+ },
+});
+
+/**
+ * Wraps an RPC call in a check for the result being an identity check action
+ * descriptor. If no such result is found, just returns the wrapped promise's
+ * result as-is; otherwise shows an identity check dialog and resumes the call
+ * on success.
+ *
+ * Warning: does not in and of itself trigger an identity check, a promise which
+ * never triggers and identity check internally will do nothing of use.
+ *
+ * @param {Function} rpc Widget#_rpc bound do the widget
+ * @param {Promise} wrapped promise to check for an identity check request
+ * @returns {Promise} result of the original call
+ */
+function handleCheckIdentity(rpc, wrapped) {
+ return wrapped.then((r) => {
+ if (!_.isMatch(r, {type: 'ir.actions.act_window', res_model: 'res.users.identitycheck'})) {
+ return r;
+ }
+ const check_id = r.res_id;
+ return ajax.loadXML('/portal/static/src/xml/portal_security.xml', qweb).then(() => new Promise((resolve, reject) => {
+ const d = new Dialog(null, {
+ title: _t("Security Control"),
+ $content: qweb.render('portal.identitycheck'),
+ buttons: [{
+ text: _t("Confirm Password"), classes: 'btn btn-primary',
+ // nb: if click & close, waits for click to resolve before closing
+ click() {
+ const password_input = this.el.querySelector('[name=password]');
+ if (!password_input.reportValidity()) {
+ password_input.classList.add('is-invalid');
+ return;
+ }
+ return rpc({
+ model: 'res.users.identitycheck',
+ method: 'write',
+ args: [check_id, {password: password_input.value}]
+ }).then(() => rpc({
+ model: 'res.users.identitycheck',
+ method: 'run_check',
+ args: [check_id]
+ })).then((r) => {
+ this.close();
+ resolve(r);
+ }, (err) => {
+ err.event.preventDefault(); // suppress crashmanager
+ password_input.classList.add('is-invalid');
+ password_input.setCustomValidity(_t("Check failed"));
+ password_input.reportValidity();
+ });
+ }
+ }, {
+ text: _t('Cancel'), close: true
+ }, {
+ text: _t('Forgot password?'), classes: 'btn btn-link',
+ click() { window.location.href = "/web/reset_password/"; }
+ }]
+ }).on('close', null, () => {
+ // unlink wizard object?
+ reject();
+ });
+ d.opened(() => {
+ const pw = d.el.querySelector('[name="password"]');
+ pw.focus();
+ pw.addEventListener('input', () => {
+ pw.classList.remove('is-invalid');
+ pw.setCustomValidity('');
+ });
+ d.el.addEventListener('submit', (e) => {
+ e.preventDefault();
+ d.$footer.find('.btn-primary').click();
+ });
+ });
+ d.open();
+ }));
+ });
+}
+return {
+ handleCheckIdentity,
+}
+});
diff --git a/addons/portal/static/src/js/portal_chatter.js b/addons/portal/static/src/js/portal_chatter.js
new file mode 100644
index 00000000..134efbf6
--- /dev/null
+++ b/addons/portal/static/src/js/portal_chatter.js
@@ -0,0 +1,311 @@
+odoo.define('portal.chatter', function (require) {
+'use strict';
+
+var core = require('web.core');
+const dom = require('web.dom');
+var publicWidget = require('web.public.widget');
+var time = require('web.time');
+var portalComposer = require('portal.composer');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+/**
+ * Widget PortalChatter
+ *
+ * - Fetch message fron controller
+ * - Display chatter: pager, total message, composer (according to access right)
+ * - Provider API to filter displayed messages
+ */
+var PortalChatter = publicWidget.Widget.extend({
+ template: 'portal.Chatter',
+ xmlDependencies: ['/portal/static/src/xml/portal_chatter.xml'],
+ events: {
+ 'click .o_portal_chatter_pager_btn': '_onClickPager',
+ 'click .o_portal_chatter_js_is_internal': 'async _onClickUpdateIsInternal',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options) {
+ var self = this;
+ this.options = {};
+ this._super.apply(this, arguments);
+
+ // underscorize the camelcased option keys
+ _.each(options, function (val, key) {
+ self.options[_.str.underscored(key)] = val;
+ });
+ // set default options
+ this.options = _.defaults(this.options, {
+ 'allow_composer': true,
+ 'display_composer': false,
+ 'csrf_token': odoo.csrf_token,
+ 'message_count': 0,
+ 'pager_step': 10,
+ 'pager_scope': 5,
+ 'pager_start': 1,
+ 'is_user_public': true,
+ 'is_user_employee': false,
+ 'is_user_publisher': false,
+ 'hash': false,
+ 'pid': false,
+ 'domain': [],
+ });
+
+ this.set('messages', []);
+ this.set('message_count', this.options['message_count']);
+ this.set('pager', {});
+ this.set('domain', this.options['domain']);
+ this._currentPage = this.options['pager_start'];
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ return Promise.all([
+ this._super.apply(this, arguments),
+ this._chatterInit()
+ ]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ // bind events
+ this.on("change:messages", this, this._renderMessages);
+ this.on("change:message_count", this, function () {
+ this._renderMessageCount();
+ this.set('pager', this._pager(this._currentPage));
+ });
+ this.on("change:pager", this, this._renderPager);
+ this.on("change:domain", this, this._onChangeDomain);
+ // set options and parameters
+ this.set('message_count', this.options['message_count']);
+ this.set('messages', this.preprocessMessages(this.result['messages']));
+
+
+ var defs = [];
+ defs.push(this._super.apply(this, arguments));
+
+ // instanciate and insert composer widget
+ if (this.options['display_composer']) {
+ this._composer = new portalComposer.PortalComposer(this, this.options);
+ defs.push(this._composer.replace(this.$('.o_portal_chatter_composer')));
+ }
+
+ return Promise.all(defs);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Fetch the messages and the message count from the server for the
+ * current page and current domain.
+ *
+ * @param {Array} domain
+ * @returns {Promise}
+ */
+ messageFetch: function (domain) {
+ var self = this;
+ return this._rpc({
+ route: '/mail/chatter_fetch',
+ params: self._messageFetchPrepareParams(),
+ }).then(function (result) {
+ self.set('messages', self.preprocessMessages(result['messages']));
+ self.set('message_count', result['message_count']);
+ });
+ },
+ /**
+ * Update the messages format
+ *
+ * @param {Array<Object>}
+ * @returns {Array}
+ */
+ preprocessMessages: function (messages) {
+ _.each(messages, function (m) {
+ m['author_avatar_url'] = _.str.sprintf('/web/image/%s/%s/author_avatar/50x50', 'mail.message', m.id);
+ m['published_date_str'] = _.str.sprintf(_t('Published on %s'), moment(time.str_to_datetime(m.date)).format('MMMM Do YYYY, h:mm:ss a'));
+ });
+ return messages;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @returns {Deferred}
+ */
+ _chatterInit: function () {
+ var self = this;
+ return this._rpc({
+ route: '/mail/chatter_init',
+ params: this._messageFetchPrepareParams()
+ }).then(function (result) {
+ self.result = result;
+ self.options = _.extend(self.options, self.result['options'] || {});
+ return result;
+ });
+ },
+ /**
+ * Change the current page by refreshing current domain
+ *
+ * @private
+ * @param {Number} page
+ * @param {Array} domain
+ */
+ _changeCurrentPage: function (page, domain) {
+ this._currentPage = page;
+ var d = domain ? domain : _.clone(this.get('domain'));
+ this.set('domain', d); // trigger fetch message
+ },
+ _messageFetchPrepareParams: function () {
+ var self = this;
+ var data = {
+ 'res_model': this.options['res_model'],
+ 'res_id': this.options['res_id'],
+ 'limit': this.options['pager_step'],
+ 'offset': (this._currentPage - 1) * this.options['pager_step'],
+ 'allow_composer': this.options['allow_composer'],
+ };
+ // add token field to allow to post comment without being logged
+ if (self.options['token']) {
+ data['token'] = self.options['token'];
+ }
+ // add domain
+ if (this.get('domain')) {
+ data['domain'] = this.get('domain');
+ }
+ return data;
+ },
+ /**
+ * Generate the pager data for the given page number
+ *
+ * @private
+ * @param {Number} page
+ * @returns {Object}
+ */
+ _pager: function (page) {
+ page = page || 1;
+ var total = this.get('message_count');
+ var scope = this.options['pager_scope'];
+ var step = this.options['pager_step'];
+
+ // Compute Pager
+ var pageCount = Math.ceil(parseFloat(total) / step);
+
+ page = Math.max(1, Math.min(parseInt(page), pageCount));
+ scope -= 1;
+
+ var pmin = Math.max(page - parseInt(Math.floor(scope / 2)), 1);
+ var pmax = Math.min(pmin + scope, pageCount);
+
+ if (pmax - scope > 0) {
+ pmin = pmax - scope;
+ } else {
+ pmin = 1;
+ }
+
+ var pages = [];
+ _.each(_.range(pmin, pmax + 1), function (index) {
+ pages.push(index);
+ });
+
+ return {
+ "page_count": pageCount,
+ "offset": (page - 1) * step,
+ "page": page,
+ "page_start": pmin,
+ "page_previous": Math.max(pmin, page - 1),
+ "page_next": Math.min(pmax, page + 1),
+ "page_end": pmax,
+ "pages": pages
+ };
+ },
+ _renderMessages: function () {
+ this.$('.o_portal_chatter_messages').html(qweb.render("portal.chatter_messages", {widget: this}));
+ },
+ _renderMessageCount: function () {
+ this.$('.o_message_counter').replaceWith(qweb.render("portal.chatter_message_count", {widget: this}));
+ },
+ _renderPager: function () {
+ this.$('.o_portal_chatter_pager').replaceWith(qweb.render("portal.pager", {widget: this}));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ _onChangeDomain: function () {
+ var self = this;
+ this.messageFetch().then(function () {
+ var p = self._currentPage;
+ self.set('pager', self._pager(p));
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onClickPager: function (ev) {
+ ev.preventDefault();
+ var page = $(ev.currentTarget).data('page');
+ this._changeCurrentPage(page);
+ },
+
+ /**
+ * Toggle is_internal state of message. Update both node data and
+ * classes to ensure DOM is updated accordingly to RPC call result.
+ * @private
+ * @returns {Promise}
+ */
+ _onClickUpdateIsInternal: function (ev) {
+ ev.preventDefault();
+
+ var $elem = $(ev.currentTarget);
+ return this._rpc({
+ route: '/mail/update_is_internal',
+ params: {
+ message_id: $elem.data('message-id'),
+ is_internal: ! $elem.data('is-internal'),
+ },
+ }).then(function (result) {
+ $elem.data('is-internal', result);
+ if (result === true) {
+ $elem.addClass('o_portal_message_internal_on');
+ $elem.removeClass('o_portal_message_internal_off');
+ } else {
+ $elem.addClass('o_portal_message_internal_off');
+ $elem.removeClass('o_portal_message_internal_on');
+ }
+ });
+ },
+});
+
+publicWidget.registry.portalChatter = publicWidget.Widget.extend({
+ selector: '.o_portal_chatter',
+
+ /**
+ * @override
+ */
+ async start() {
+ const proms = [this._super.apply(this, arguments)];
+ const chatter = new PortalChatter(this, this.$el.data());
+ proms.push(chatter.appendTo(this.$el));
+ await Promise.all(proms);
+ // scroll to the right place after chatter loaded
+ if (window.location.hash === `#${this.el.id}`) {
+ dom.scrollTo(this.el, {duration: 0});
+ }
+ },
+});
+
+return {
+ PortalChatter: PortalChatter,
+};
+});
diff --git a/addons/portal/static/src/js/portal_composer.js b/addons/portal/static/src/js/portal_composer.js
new file mode 100644
index 00000000..68fb9189
--- /dev/null
+++ b/addons/portal/static/src/js/portal_composer.js
@@ -0,0 +1,173 @@
+odoo.define('portal.composer', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+/**
+ * Widget PortalComposer
+ *
+ * Display the composer (according to access right)
+ *
+ */
+var PortalComposer = publicWidget.Widget.extend({
+ template: 'portal.Composer',
+ xmlDependencies: ['/portal/static/src/xml/portal_chatter.xml'],
+ events: {
+ 'change .o_portal_chatter_file_input': '_onFileInputChange',
+ 'click .o_portal_chatter_attachment_btn': '_onAttachmentButtonClick',
+ 'click .o_portal_chatter_attachment_delete': 'async _onAttachmentDeleteClick',
+ 'click .o_portal_chatter_composer_btn': 'async _onSubmitButtonClick',
+ },
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.options = _.defaults(options || {}, {
+ 'allow_composer': true,
+ 'display_composer': false,
+ 'csrf_token': odoo.csrf_token,
+ 'token': false,
+ 'res_model': false,
+ 'res_id': false,
+ });
+ this.attachments = [];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$attachmentButton = this.$('.o_portal_chatter_attachment_btn');
+ this.$fileInput = this.$('.o_portal_chatter_file_input');
+ this.$sendButton = this.$('.o_portal_chatter_composer_btn');
+ this.$attachments = this.$('.o_portal_chatter_composer_form .o_portal_chatter_attachments');
+ this.$attachmentIds = this.$('.o_portal_chatter_attachment_ids');
+ this.$attachmentTokens = this.$('.o_portal_chatter_attachment_tokens');
+
+ return this._super.apply(this, arguments).then(function () {
+ if (self.options.default_attachment_ids) {
+ self.attachments = self.options.default_attachment_ids || [];
+ _.each(self.attachments, function(attachment) {
+ attachment.state = 'done';
+ });
+ self._updateAttachments();
+ }
+ return Promise.resolve();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAttachmentButtonClick: function () {
+ this.$fileInput.click();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ * @returns {Promise}
+ */
+ _onAttachmentDeleteClick: function (ev) {
+ var self = this;
+ var attachmentId = $(ev.currentTarget).closest('.o_portal_chatter_attachment').data('id');
+ var accessToken = _.find(this.attachments, {'id': attachmentId}).access_token;
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.$sendButton.prop('disabled', true);
+
+ return this._rpc({
+ route: '/portal/attachment/remove',
+ params: {
+ 'attachment_id': attachmentId,
+ 'access_token': accessToken,
+ },
+ }).then(function () {
+ self.attachments = _.reject(self.attachments, {'id': attachmentId});
+ self._updateAttachments();
+ self.$sendButton.prop('disabled', false);
+ });
+ },
+ /**
+ * @private
+ * @returns {Promise}
+ */
+ _onFileInputChange: function () {
+ var self = this;
+
+ this.$sendButton.prop('disabled', true);
+
+ return Promise.all(_.map(this.$fileInput[0].files, function (file) {
+ return new Promise(function (resolve, reject) {
+ var data = {
+ 'name': file.name,
+ 'file': file,
+ 'res_id': self.options.res_id,
+ 'res_model': self.options.res_model,
+ 'access_token': self.options.token,
+ };
+ ajax.post('/portal/attachment/add', data).then(function (attachment) {
+ attachment.state = 'pending';
+ self.attachments.push(attachment);
+ self._updateAttachments();
+ resolve();
+ }).guardedCatch(function (error) {
+ self.displayNotification({
+ message: _.str.sprintf(_t("Could not save file <strong>%s</strong>"),
+ _.escape(file.name)),
+ type: 'warning',
+ sticky: true,
+ });
+ resolve();
+ });
+ });
+ })).then(function () {
+ // ensures any selection triggers a change, even if the same files are selected again
+ self.$fileInput[0].value = null;
+ self.$sendButton.prop('disabled', false);
+ });
+ },
+ /**
+ * Returns a Promise that is never resolved to prevent sending the form
+ * twice when clicking twice on the button, in combination with the `async`
+ * in the event definition.
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _onSubmitButtonClick: function () {
+ return new Promise(function (resolve, reject) {});
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _updateAttachments: function () {
+ this.$attachmentIds.val(_.pluck(this.attachments, 'id'));
+ this.$attachmentTokens.val(_.pluck(this.attachments, 'access_token'));
+ this.$attachments.html(qweb.render('portal.Chatter.Attachments', {
+ attachments: this.attachments,
+ showDelete: true,
+ }));
+ },
+});
+
+return {
+ PortalComposer: PortalComposer,
+};
+});
diff --git a/addons/portal/static/src/js/portal_sidebar.js b/addons/portal/static/src/js/portal_sidebar.js
new file mode 100644
index 00000000..d081ddec
--- /dev/null
+++ b/addons/portal/static/src/js/portal_sidebar.js
@@ -0,0 +1,75 @@
+odoo.define('portal.PortalSidebar', function (require) {
+'use strict';
+
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+var time = require('web.time');
+var session = require('web.session');
+
+var _t = core._t;
+
+var PortalSidebar = publicWidget.Widget.extend({
+ /**
+ * @override
+ */
+ start: function () {
+ this._setDelayLabel();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------------
+
+ /**
+ * Set the due/delay information according to the given date
+ * like : <span class="o_portal_sidebar_timeago" t-att-datetime="invoice.date_due"/>
+ *
+ * @private
+ */
+ _setDelayLabel: function () {
+ var $sidebarTimeago = this.$el.find('.o_portal_sidebar_timeago');
+ _.each($sidebarTimeago, function (el) {
+ var dateTime = moment(time.auto_str_to_date($(el).attr('datetime'))),
+ today = moment().startOf('day'),
+ diff = dateTime.diff(today, 'days', true),
+ displayStr;
+
+ session.is_bound.then(function (){
+ if (diff === 0) {
+ displayStr = _t('Due today');
+ } else if (diff > 0) {
+ // Workaround: force uniqueness of these two translations. We use %1d because the string
+ // with %d is already used in mail and mail's translations are not sent to the frontend.
+ displayStr = _.str.sprintf(_t('Due in %1d days'), Math.abs(diff));
+ } else {
+ displayStr = _.str.sprintf(_t('%1d days overdue'), Math.abs(diff));
+ }
+ $(el).text(displayStr);
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {string} href
+ */
+ _printIframeContent: function (href) {
+ // due to this issue : https://bugzilla.mozilla.org/show_bug.cgi?id=911444
+ // open a new window with pdf for print in Firefox (in other system: http://printjs.crabbly.com)
+ if ($.browser.mozilla) {
+ window.open(href, '_blank');
+ return;
+ }
+ if (!this.printContent) {
+ this.printContent = $('<iframe id="print_iframe_content" src="' + href + '" style="display:none"></iframe>');
+ this.$el.append(this.printContent);
+ this.printContent.on('load', function () {
+ $(this).get(0).contentWindow.print();
+ });
+ } else {
+ this.printContent.get(0).contentWindow.print();
+ }
+ },
+});
+return PortalSidebar;
+});
diff --git a/addons/portal/static/src/js/portal_signature.js b/addons/portal/static/src/js/portal_signature.js
new file mode 100644
index 00000000..eac46519
--- /dev/null
+++ b/addons/portal/static/src/js/portal_signature.js
@@ -0,0 +1,197 @@
+odoo.define('portal.signature_form', function (require) {
+'use strict';
+
+var core = require('web.core');
+var publicWidget = require('web.public.widget');
+var NameAndSignature = require('web.name_and_signature').NameAndSignature;
+var qweb = core.qweb;
+
+var _t = core._t;
+
+/**
+ * This widget is a signature request form. It uses
+ * @see NameAndSignature for the input fields, adds a submit
+ * button, and handles the RPC to save the result.
+ */
+var SignatureForm = publicWidget.Widget.extend({
+ template: 'portal.portal_signature',
+ xmlDependencies: ['/portal/static/src/xml/portal_signature.xml'],
+ events: {
+ 'click .o_portal_sign_submit': 'async _onClickSignSubmit',
+ },
+ custom_events: {
+ 'signature_changed': '_onChangeSignature',
+ },
+
+ /**
+ * Overridden to allow options.
+ *
+ * @constructor
+ * @param {Widget} parent
+ * @param {Object} options
+ * @param {string} options.callUrl - make RPC to this url
+ * @param {string} [options.sendLabel='Accept & Sign'] - label of the
+ * send button
+ * @param {Object} [options.rpcParams={}] - params for the RPC
+ * @param {Object} [options.nameAndSignatureOptions={}] - options for
+ * @see NameAndSignature.init()
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+
+ this.csrf_token = odoo.csrf_token;
+
+ this.callUrl = options.callUrl || '';
+ this.rpcParams = options.rpcParams || {};
+ this.sendLabel = options.sendLabel || _t("Accept & Sign");
+
+ this.nameAndSignature = new NameAndSignature(this,
+ options.nameAndSignatureOptions || {});
+ },
+ /**
+ * Overridden to get the DOM elements
+ * and to insert the name and signature.
+ *
+ * @override
+ */
+ start: function () {
+ var self = this;
+ this.$confirm_btn = this.$('.o_portal_sign_submit');
+ this.$controls = this.$('.o_portal_sign_controls');
+ var subWidgetStart = this.nameAndSignature.replace(this.$('.o_web_sign_name_and_signature'));
+ return Promise.all([subWidgetStart, this._super.apply(this, arguments)]).then(function () {
+ self.nameAndSignature.resetSignature();
+ });
+ },
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * Focuses the name.
+ *
+ * @see NameAndSignature.focusName();
+ */
+ focusName: function () {
+ this.nameAndSignature.focusName();
+ },
+ /**
+ * Resets the signature.
+ *
+ * @see NameAndSignature.resetSignature();
+ */
+ resetSignature: function () {
+ return this.nameAndSignature.resetSignature();
+ },
+
+ //----------------------------------------------------------------------
+ // Handlers
+ //----------------------------------------------------------------------
+
+ /**
+ * Handles click on the submit button.
+ *
+ * This will get the current name and signature and validate them.
+ * If they are valid, they are sent to the server, and the reponse is
+ * handled. If they are invalid, it will display the errors to the user.
+ *
+ * @private
+ * @param {Event} ev
+ * @returns {Deferred}
+ */
+ _onClickSignSubmit: function (ev) {
+ var self = this;
+ ev.preventDefault();
+
+ if (!this.nameAndSignature.validateSignature()) {
+ return;
+ }
+
+ var name = this.nameAndSignature.getName();
+ var signature = this.nameAndSignature.getSignatureImage()[1];
+
+ return this._rpc({
+ route: this.callUrl,
+ params: _.extend(this.rpcParams, {
+ 'name': name,
+ 'signature': signature,
+ }),
+ }).then(function (data) {
+ if (data.error) {
+ self.$('.o_portal_sign_error_msg').remove();
+ self.$controls.prepend(qweb.render('portal.portal_signature_error', {widget: data}));
+ } else if (data.success) {
+ var $success = qweb.render('portal.portal_signature_success', {widget: data});
+ self.$el.empty().append($success);
+ }
+ if (data.force_refresh) {
+ if (data.redirect_url) {
+ window.location = data.redirect_url;
+ } else {
+ window.location.reload();
+ }
+ // no resolve if we reload the page
+ return new Promise(function () { });
+ }
+ });
+ },
+ /**
+ * Toggles the submit button depending on the signature state.
+ *
+ * @private
+ */
+ _onChangeSignature: function () {
+ var isEmpty = this.nameAndSignature.isSignatureEmpty();
+ this.$confirm_btn.prop('disabled', isEmpty);
+ },
+});
+
+publicWidget.registry.SignatureForm = publicWidget.Widget.extend({
+ selector: '.o_portal_signature_form',
+
+ /**
+ * @private
+ */
+ start: function () {
+ var hasBeenReset = false;
+
+ var callUrl = this.$el.data('call-url');
+ var nameAndSignatureOptions = {
+ defaultName: this.$el.data('default-name'),
+ mode: this.$el.data('mode'),
+ displaySignatureRatio: this.$el.data('signature-ratio'),
+ signatureType: this.$el.data('signature-type'),
+ fontColor: this.$el.data('font-color') || 'black',
+ };
+ var sendLabel = this.$el.data('send-label');
+
+ var form = new SignatureForm(this, {
+ callUrl: callUrl,
+ nameAndSignatureOptions: nameAndSignatureOptions,
+ sendLabel: sendLabel,
+ });
+
+ // Correctly set up the signature area if it is inside a modal
+ this.$el.closest('.modal').on('shown.bs.modal', function (ev) {
+ if (!hasBeenReset) {
+ // Reset it only the first time it is open to get correct
+ // size. After we want to keep its content on reopen.
+ hasBeenReset = true;
+ form.resetSignature();
+ } else {
+ form.focusName();
+ }
+ });
+
+ return Promise.all([
+ this._super.apply(this, arguments),
+ form.appendTo(this.$el)
+ ]);
+ },
+});
+
+return {
+ SignatureForm: SignatureForm,
+};
+});
diff --git a/addons/portal/static/src/scss/bootstrap.extend.scss b/addons/portal/static/src/scss/bootstrap.extend.scss
new file mode 100644
index 00000000..21eacb47
--- /dev/null
+++ b/addons/portal/static/src/scss/bootstrap.extend.scss
@@ -0,0 +1,13 @@
+@each $breakpoint in map-keys($grid-breakpoints) {
+ @include media-breakpoint-up($breakpoint) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+
+ @if $infix != '' { // Standard ones are already created by bootstrap
+ @each $prop, $abbrev in (width: w, height: h) {
+ @each $size, $length in $sizes {
+ .#{$abbrev}#{$infix}-#{$size} { #{$prop}: $length !important; }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/portal/static/src/scss/bootstrap_overridden.scss b/addons/portal/static/src/scss/bootstrap_overridden.scss
new file mode 100644
index 00000000..243cff2d
--- /dev/null
+++ b/addons/portal/static/src/scss/bootstrap_overridden.scss
@@ -0,0 +1,34 @@
+// This variable affects the `.h-*` and `.w-*` classes.
+$sizes: () !default;
+$sizes: map-merge((
+ 0: 0,
+), $sizes);
+
+// Body
+//
+// Settings for the `<body>` element.
+
+$body-bg: $o-portal-default-body-bg !default;
+
+// Fonts
+//
+// Font, line-height, and color for body text, headings, and more.
+
+$font-size-sm: (12 / 16) * 1rem !default;
+
+// Buttons
+//
+// For each of Bootstrap's buttons, define text, background, and border color.
+
+$btn-padding-y-sm: (1 / 16) * 1rem !default;
+$btn-padding-x-sm: (5 / 16) * 1rem !default;
+
+// Navbar
+
+$navbar-dark-toggler-border-color: transparent;
+$navbar-light-toggler-border-color: transparent;
+
+// Modals
+
+$modal-lg: $o-modal-lg;
+$modal-md: $o-modal-md;
diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss
new file mode 100644
index 00000000..3aa87818
--- /dev/null
+++ b/addons/portal/static/src/scss/portal.scss
@@ -0,0 +1,563 @@
+///
+/// This file regroups the frontend general design rules and portal design
+/// rules.
+///
+
+// ====== Variables =========
+$o-theme-navbar-logo-height: $nav-link-height !default;
+$o-theme-navbar-fixed-logo-height: $nav-link-height !default;
+
+// Portal toolbar (filters, search bar)
+$o-portal-mobile-toolbar: true; // Enable/Disable custom design
+$o-portal-mobile-toolbar-border: $border-color;
+$o-portal-mobile-toolbar-bg: $gray-200;
+
+// Portal Tables
+$o-portal-table-th-pt: map-get($spacers, 2) !default; // bts4 pt-2
+$o-portal-table-th-pb: map-get($spacers, 2) !default; // bts4 pb-2
+$o-portal-table-td-pt: map-get($spacers, 1) !default; // bts4 pt-1
+$o-portal-table-td-pb: map-get($spacers, 1) !default; // bts4 pb-1
+
+// Portal custom bg color
+$o-portal-bg-color: desaturate($gray-200, 100%);
+
+// Check if portal uses default colors
+$o-portal-use-default-colors: $body-bg == $o-portal-default-body-bg;
+
+// Frontend general
+body {
+ // Set frontend direction that will be flipped with
+ // rtlcss for right-to-left text direction.
+ direction: ltr;
+}
+
+header {
+ .navbar-brand {
+ flex: 0 0 auto;
+ max-width: 75%;
+
+ &.logo {
+ padding-top: 0;
+ padding-bottom: 0;
+
+ img {
+ // object-fit does not work on IE but is only used as a fallback
+ object-fit: contain;
+ display: block;
+ width: auto;
+ height: $o-theme-navbar-logo-height;
+
+ @include media-breakpoint-down(sm) {
+ max-height: min($o-theme-navbar-logo-height, 5rem);
+ }
+ }
+ }
+ }
+ .nav-link {
+ white-space: nowrap;
+ }
+}
+.navbar {
+ margin-bottom: 0;
+ .nav.navbar-nav.float-right {
+ @include media-breakpoint-down(sm) {
+ float: none!important;
+ }
+ }
+}
+@include media-breakpoint-up(md) {
+ .navbar-expand-md ul.nav > li.divider {
+ display: list-item;
+ }
+}
+ul.flex-column > li > a {
+ padding: 2px 15px;
+}
+
+// Link without text but an icon
+a, .btn-link {
+ &.fa:hover {
+ text-decoration: $o-theme-btn-icon-hover-decoration;
+ }
+}
+
+// Odoo options classes
+.jumbotron {
+ margin-bottom: 0;
+}
+
+// Typography
+ul {
+ list-style-type: disc;
+}
+ul ul {
+ list-style-type: circle;
+}
+ul ul ul {
+ list-style-type: square;
+}
+ul ul ul ul {
+ list-style-type: disc;
+}
+ul ul ul ul ul {
+ list-style-type: circle;
+}
+ul ul ul ul ul ul {
+ list-style-type: square;
+}
+ul ul ul ul ul ul ul {
+ list-style-type: disc;
+}
+ol {
+ list-style-type: decimal;
+}
+ol ol {
+ list-style-type: lower-alpha;
+}
+ol ol ol {
+ list-style-type: lower-greek;
+}
+ol ol ol ol {
+ list-style-type: decimal;
+}
+ol ol ol ol ol {
+ list-style-type: lower-alpha;
+}
+ol ol ol ol ol ol {
+ list-style-type: lower-greek;
+}
+ol ol ol ol ol ol ol {
+ list-style-type: decimal;
+}
+li > p {
+ margin: 0;
+}
+
+// Bootstrap hacks
+%o-double-container-no-padding {
+ padding-right: 0;
+ padding-left: 0;
+}
+.container {
+ .container, .container-fluid {
+ @extend %o-double-container-no-padding;
+ }
+}
+.container-fluid .container-fluid {
+ @extend %o-double-container-no-padding;
+}
+#wrap {
+ .container, .container-fluid {
+ // BS3 used to do this on all containers so that margins and floats are
+ // cleared inside containers. As lots of current odoo layouts may rely
+ // on this for some alignments, this is restored (at least for a while)
+ // here only for main containers of the frontend.
+ &::before, &::after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+ }
+}
+[class^="col-lg-"] {
+ min-height: 24px;
+}
+.input-group {
+ flex-flow: row nowrap;
+}
+.list-group-item:not([class*="list-group-item-"]):not(.active) {
+ color: color-yiq($list-group-bg);
+}
+
+%o-portal-breadcrumbs {
+ background-color: inherit;
+}
+
+// Replaces old BS3 page-header class
+%o-page-header {
+ margin-bottom: $headings-margin-bottom * 2;
+ padding-bottom: $headings-margin-bottom;
+ border-bottom-width: $border-width;
+ border-bottom-style: solid;
+ border-bottom-color: $border-color;
+ line-height: 2.1rem;
+}
+.o_page_header {
+ @extend %o-page-header;
+}
+
+// Images spacing
+img, .media_iframe_video, .o_image {
+ &.float-right {
+ margin-left: $grid-gutter-width / 2;
+ }
+ &.float-left {
+ margin-right: $grid-gutter-width / 2;
+ }
+}
+
+// Others
+::-moz-selection {
+ background: rgba(150, 150, 220, 0.3);
+}
+::selection {
+ background: rgba(150, 150, 220, 0.3);
+}
+.oe_search_box {
+ padding-right: 23px;
+}
+
+// Kept for (up to) saas-12 compatibility
+.para_large {
+ font-size: 120%;
+}
+.jumbotron .para_large p {
+ font-size: 150%;
+}
+.readable {
+ font-size: 120%;
+ max-width: 700px;
+ margin-left: auto;
+ margin-right: auto;
+
+ .container {
+ padding-left: 0;
+ padding-right: 0;
+ width: auto;
+ }
+}
+
+// Background (kept for 8.0 compatibility) (! some are still used by website_blog)
+.oe_dark {
+ background-color: rgba(200, 200, 200, 0.14);
+}
+.oe_black {
+ background-color: rgba(0, 0, 0, 0.9);
+ color: white;
+}
+.oe_green {
+ background-color: #169C78;
+ color: white;
+ .text-muted {
+ color: #ddd !important;
+ }
+}
+.oe_blue_light {
+ background-color: #41b6ab;
+ color: white;
+ .text-muted {
+ color: #ddd !important;
+ }
+}
+.oe_blue {
+ background-color: #34495e;
+ color: white;
+}
+.oe_orange {
+ background-color: #f05442;
+ color: white;
+ .text-muted {
+ color: #ddd !important;
+ }
+}
+.oe_purple {
+ background-color: #b163a3;
+ color: white;
+ .text-muted {
+ color: #ddd !important;
+ }
+}
+.oe_red {
+ background-color: #9C1b31;
+ color: white;
+ .text-muted {
+ color: #ddd !important;
+ }
+}
+.oe_none {
+ background-color: #FFFFFF;
+}
+.oe_yellow {
+ background-color: #A2A51B;
+}
+.oe_green {
+ background-color: #149F2C;
+}
+
+// Portal specific
+// === Page custom bg ===
+// To be applied to all portal pages if bg-color is white (default).
+@if ($o-portal-use-default-colors) {
+ #wrapwrap.o_portal {
+ @include o-bg-color($o-portal-bg-color, $with-extras: false);
+ }
+}
+
+.o_portal {
+ .breadcrumb {
+ @extend %o-portal-breadcrumbs;
+ }
+
+ > tbody.o_portal_report_tbody {
+ vertical-align: middle;
+ }
+}
+
+.o_portal_wrap {
+ .o_portal_my_home > .o_page_header > a:hover {
+ text-decoration: none;
+ }
+
+ .o_portal_navbar {
+ .breadcrumb {
+ padding-left: 0;
+ padding-right: 0;
+ @extend %o-portal-breadcrumbs;
+ }
+
+ @if ($o-portal-use-default-colors) {
+ background-color: white!important;
+ }
+ }
+
+ .o_portal_my_doc_table {
+ th {
+ padding-top: $o-portal-table-th-pt;
+ padding-bottom: $o-portal-table-th-pb;
+ max-width: 500px;
+ }
+
+ td {
+ padding-top: $o-portal-table-td-pt;
+ padding-bottom: $o-portal-table-td-pb;
+ max-width: 10rem;
+
+ }
+
+ tr:last-child td {
+ padding-bottom: $o-portal-table-td-pb * 1.5;
+ }
+
+ td, th {
+ vertical-align: middle;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ address {
+ span[itemprop="name"] {
+ margin-bottom: 0.3em;
+ }
+
+ div[itemprop="address"] > div {
+ position: relative;
+
+ span[itemprop="streetAddress"] {
+ line-height: 1.2;
+ margin-bottom: 0.3em;
+ }
+
+ .fa {
+ @include o-position-absolute(0, $left: 0);
+ line-height: $line-height-base;
+
+ + span, + div {
+ display: block;
+ // FontAwesome '.fa-fw' fixed-with + margin
+ padding-left: 1.28571429em + 0.5em;
+ }
+ }
+ }
+ }
+
+ .o_my_sidebar div[itemprop="address"] > div {
+ margin-top: 0.5em;
+ }
+
+ @if ($o-portal-mobile-toolbar) {
+ #o_portal_navbar_content {
+ @include media-breakpoint-down(md) {
+ margin: $navbar-padding-y (-$navbar-padding-x) 0;
+ padding: $navbar-padding-y $navbar-padding-x ;
+ border-top: $border-width solid $o-portal-mobile-toolbar-border;
+ background-color: $o-portal-mobile-toolbar-bg;
+ }
+ }
+ }
+
+ table.table tr {
+ word-wrap: break-word;
+ }
+}
+
+.oe_attachments .o_image_small {
+ height: 40px;
+ width: 50px;
+ background-repeat: no-repeat;
+}
+
+form label {
+ font-weight: $font-weight-bold;
+ &.label-optional {
+ font-weight: $font-weight-normal;
+ }
+}
+
+.o_portal_contact_img {
+ width: 2.3em;
+ height: 2.3em;
+ object-fit: cover;
+}
+
+.o_portal_sidebar {
+ #sidebar_content.card {
+ border-left: 0;
+ border-bottom: 0;
+
+ > div.card-body {
+ border-left: $border-width solid $border-color;
+ }
+
+ > ul > li {
+ border-left: $border-width solid $border-color;
+ margin-bottom: -1px;
+ }
+
+ > div.card-footer {
+ border-left: $border-width solid $border-color;
+ border-bottom: $border-width solid $border-color;
+ }
+ }
+
+ .o_portal_html_view {
+ overflow: hidden;
+ background: white;
+ position: relative;
+
+ .o_portal_html_loader {
+ @include o-position-absolute(45%, 0, auto, 0);
+ }
+
+ iframe {
+ position: relative;
+ }
+ }
+}
+
+// ------------------------------------------------------------
+// Frontend Discuss widget
+// ------------------------------------------------------------
+
+// Readonly display
+.o_portal_chatter {
+ padding: 10px;
+
+ .o_portal_chatter_avatar {
+ width: 45px;
+ height: 45px;
+ margin-right: 1rem;
+ }
+
+ .o_portal_chatter_header {
+ margin-bottom: 15px;
+ }
+
+ .o_portal_chatter_composer {
+ margin-bottom: 15px;
+ }
+
+ .o_portal_chatter_messages {
+ margin-bottom: 15px;
+
+ .o_portal_chatter_message {
+ div.media-body > p:not(.o_portal_chatter_puslished_date):last-of-type {
+ margin-bottom: 5px;
+ }
+ }
+
+ .o_portal_chatter_message_title {
+ p {
+ font-size:85%;
+ color:$o-main-color-muted;
+ margin: 0px;
+ }
+ }
+ }
+
+ .o_portal_chatter_pager {
+ text-align: center;
+ }
+
+
+}
+
+// Readonly / Composer mix display
+.o_portal_chatter,
+.o_portal_chatter_composer {
+ .o_portal_chatter_attachment {
+ .o_portal_chatter_attachment_name {
+ word-wrap: break-word;
+ }
+
+ .o_portal_chatter_attachment_delete {
+ @include o-position-absolute($top: 0, $right: 0);
+ opacity: 0;
+ }
+
+ &:hover .o_portal_chatter_attachment_delete {
+ opacity: 1;
+ }
+ }
+
+ .o_portal_message_internal_off {
+ .btn-danger {
+ display: none;
+ }
+ }
+
+ .o_portal_message_internal_on {
+ .btn-success {
+ display: none;
+ }
+ }
+}
+
+.o_portal_security_body {
+ @extend .mx-auto;
+ max-width: map-get($container-max-widths, sm);
+ section {
+ @extend .mt-4;
+ form.oe_reset_password_form {
+ max-width: initial;
+ margin: initial;
+ }
+ .form-group {
+ // for the absolutely positioned meter on new password
+ position: relative;
+ }
+ label, button {
+ @extend .text-nowrap;
+ }
+ }
+}
+
+// Copyright
+.o_footer_copyright {
+ .o_footer_copyright_name {
+ vertical-align: middle;
+ }
+ .js_language_selector {
+ display: inline-block;
+ }
+ @include media-breakpoint-up(md) {
+ .row {
+ display: flex;
+ > div {
+ margin: auto 0;
+ }
+ }
+ }
+}
+
diff --git a/addons/portal/static/src/scss/primary_variables.scss b/addons/portal/static/src/scss/primary_variables.scss
new file mode 100644
index 00000000..57a12f4c
--- /dev/null
+++ b/addons/portal/static/src/scss/primary_variables.scss
@@ -0,0 +1,5 @@
+$o-portal-default-body-bg: white;
+
+$o-theme-navbar-logo-height: null;
+
+$o-theme-btn-icon-hover-decoration: none;
diff --git a/addons/portal/static/src/xml/portal_chatter.xml b/addons/portal/static/src/xml/portal_chatter.xml
new file mode 100644
index 00000000..ae3a2374
--- /dev/null
+++ b/addons/portal/static/src/xml/portal_chatter.xml
@@ -0,0 +1,162 @@
+<templates id="template" xml:space="preserve">
+
+ <t t-name="portal.chatter_message_count">
+ <t t-set="count" t-value="widget.get('message_count')"/>
+ <div class="o_message_counter">
+ <t t-if="count">
+ <span class="fa fa-comments" />
+ <span class="o_message_count"> <t t-esc="count"/></span>
+ <t t-if="count == 1">comment</t>
+ <t t-else="">comments</t>
+ </t>
+ <t t-else="">
+ There are no comments for now.
+ </t>
+ </div>
+ </t>
+
+ <!--
+ Widget PortalComposer (standalone)
+
+ required many options: token, res_model, res_id, ...
+ -->
+ <t t-name="portal.Composer">
+ <div class="o_portal_chatter_composer" t-if="widget.options['allow_composer']">
+ <t t-set="discussion_url" t-value="window.encodeURI(window.location.href.split('#')[0] + '#discussion')"/>
+ <t t-if="!widget.options['display_composer']">
+ <h4>Leave a comment</h4>
+ <p>You must be <a t-attf-href="/web/login?redirect=#{discussion_url}">logged in</a> to post a comment.</p>
+ </t>
+ <t t-if="widget.options['display_composer']">
+ <div class="media">
+ <img alt="Avatar" class="o_portal_chatter_avatar" t-attf-src="/web/image/res.partner/#{widget.options['partner_id']}/image_128/50x50"
+ t-if="!widget.options['is_user_public'] or !widget.options['token']"/>
+ <div class="media-body">
+ <form class="o_portal_chatter_composer_form" t-attf-action="/mail/chatter_post" method="POST">
+ <input type="hidden" name="csrf_token" t-att-value="widget.options['csrf_token']"/>
+ <div class="mb32">
+ <textarea rows="4" name="message" class="form-control" placeholder="Write a message..."></textarea>
+ <input type="hidden" name="res_model" t-att-value="widget.options['res_model']"/>
+ <input type="hidden" name="res_id" t-att-value="widget.options['res_id']"/>
+ <input type="hidden" name="token" t-att-value="widget.options['token']" t-if="widget.options['token']"/>
+ <input type='hidden' name="pid" t-att-value="widget.options['pid']" t-if="widget.options['pid']"/>
+ <input type='hidden' name="hash" t-att-value="widget.options['hash']" t-if="widget.options['hash']"/>
+ <input type="hidden" name="sha_in" t-att-value="widget.options['sha_in']" t-if="widget.options['sha_in']"/>
+ <input type="hidden" name="sha_time" t-att-value="widget.options['sha_time']" t-if="widget.options['sha_time']"/>
+ <input type="hidden" name="redirect" t-att-value="discussion_url"/>
+ <input type="hidden" name="attachment_ids" class="o_portal_chatter_attachment_ids"/>
+ <input type="hidden" name="attachment_tokens" class="o_portal_chatter_attachment_tokens"/>
+ <div class="alert alert-danger mt8 mb0 o_portal_chatter_composer_error" style="display:none;" role="alert">
+ Oops! Something went wrong. Try to reload the page and log in.
+ </div>
+ <div class="o_portal_chatter_attachments mt-3"/>
+ <div class="mt8">
+ <button t-attf-class="o_portal_chatter_composer_btn btn btn-primary" type="submit">Send</button>
+ <button class="o_portal_chatter_attachment_btn btn btn-secondary" type="button" title="Add attachment">
+ <i class="fa fa-paperclip"/>
+ </button>
+ </div>
+ </div>
+ </form>
+ <form class="d-none">
+ <input type="file" class="o_portal_chatter_file_input" multiple="multiple"/>
+ </form>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="portal.Chatter.Attachments">
+ <div t-if="attachments.length" class="row">
+ <div t-foreach="attachments" t-as="attachment" class="col-lg-2 col-md-3 col-sm-6">
+ <div class="o_portal_chatter_attachment mb-2 position-relative text-center" t-att-data-id="attachment.id">
+ <button t-if="showDelete and attachment.state == 'pending'" class="o_portal_chatter_attachment_delete btn btn-sm btn-outline-danger" title="Delete">
+ <i class="fa fa-times"/>
+ </button>
+ <a t-attf-href="/web/content/#{attachment.id}?download=true&amp;access_token=#{attachment.access_token}" target="_blank">
+ <div class='oe_attachment_embedded o_image' t-att-title="attachment.name" t-att-data-mimetype="attachment.mimetype"/>
+ <div class='o_portal_chatter_attachment_name'><t t-esc='attachment.name'/></div>
+ </a>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <!--
+ Widget PortalChatter, and subtemplates
+ -->
+
+ <t t-name="portal.chatter_messages">
+ <div class="o_portal_chatter_messages">
+ <t t-foreach="widget.get('messages') || []" t-as="message">
+ <div class="media o_portal_chatter_message" t-att-id="'message-' + message.id">
+ <img class="o_portal_chatter_avatar" t-att-src="message.author_avatar_url" alt="avatar"/>
+ <div class="media-body">
+ <t t-call="portal.chatter_internal_toggle" t-if="widget.options['is_user_employee']"/>
+
+ <div class="o_portal_chatter_message_title">
+ <h5 class='mb-1'><t t-esc="message.author_id[1]"/></h5>
+ <p class="o_portal_chatter_puslished_date"><t t-esc="message.published_date_str"/></p>
+ </div>
+ <t t-raw="message.body"/>
+
+ <div class="o_portal_chatter_attachments">
+ <t t-call="portal.Chatter.Attachments">
+ <t t-set="attachments" t-value="message.attachment_ids"/>
+ </t>
+ </div>
+ </div>
+ </div>
+ </t>
+ </div>
+ </t>
+
+ <!-- Chatter: internal toggle widget -->
+ <t t-name="portal.chatter_internal_toggle">
+ <div t-attf-class="float-right o_portal_chatter_js_is_internal #{message.is_internal and 'o_portal_message_internal_on' or 'o_portal_message_internal_off'}"
+ t-att-data-message-id="message.id"
+ t-att-data-is-internal="message.is_internal">
+ <button class="btn btn-danger"
+ title="Currently restricted to internal employees, click to make it available to everyone viewing this document.">Employees Only</button>
+ <button class="btn btn-success"
+ title="Currently available to everyone viewing this document, click to restrict to internal employees.">Visible</button>
+ </div>
+ </t>
+
+ <t t-name="portal.pager">
+ <div class="o_portal_chatter_pager">
+ <t t-if="!_.isEmpty(widget.get('pager'))">
+ <ul class="pagination" t-if="widget.get('pager')['pages'].length &gt; 1">
+ <li t-if="widget.get('pager')['page'] != widget.get('pager')['page_previous']" t-att-data-page="widget.get('pager')['page_previous']" class="page-item o_portal_chatter_pager_btn">
+ <a href="#" class="page-link"><i class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/></a>
+ </li>
+ <t t-foreach="widget.get('pager')['pages']" t-as="page">
+ <li t-att-data-page="page" t-attf-class="page-item #{page == widget.get('pager')['page'] ? 'o_portal_chatter_pager_btn active' : 'o_portal_chatter_pager_btn'}">
+ <a href="#" class="page-link"><t t-esc="page"/></a>
+ </li>
+ </t>
+ <li t-if="widget.get('pager')['page'] != widget.get('pager')['page_next']" t-att-data-page="widget.get('pager')['page_next']" class="page-item o_portal_chatter_pager_btn">
+ <a href="#" class="page-link"><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/></a>
+ </li>
+ </ul>
+ </t>
+ </div>
+ </t>
+
+ <t t-name="portal.Chatter">
+ <div class="o_portal_chatter p-0">
+ <div class="o_portal_chatter_header">
+ <t t-call="portal.chatter_message_count"/>
+ </div>
+ <hr t-if="widget.options['allow_composer']"/>
+ <div class="o_portal_chatter_composer"/>
+ <hr/>
+ <t t-call="portal.chatter_messages"/>
+ <div class="o_portal_chatter_footer">
+ <t t-call="portal.pager"/>
+ </div>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml
new file mode 100644
index 00000000..ff5e62e8
--- /dev/null
+++ b/addons/portal/static/src/xml/portal_security.xml
@@ -0,0 +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>
+ <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>
+ </form>
+ </t>
+</templates>
diff --git a/addons/portal/static/src/xml/portal_signature.xml b/addons/portal/static/src/xml/portal_signature.xml
new file mode 100644
index 00000000..43456d9c
--- /dev/null
+++ b/addons/portal/static/src/xml/portal_signature.xml
@@ -0,0 +1,35 @@
+<templates id="template" xml:space="preserve">
+
+ <!-- Template for the widget SignatureForm. -->
+ <t t-name="portal.portal_signature">
+ <form method="POST">
+ <input type="hidden" name="csrf_token" t-att-value="widget.csrf_token"/>
+ <div class="o_web_sign_name_and_signature"/>
+ <div class="o_portal_sign_controls my-3">
+ <div class="text-right my-3">
+ <button type="submit" class="o_portal_sign_submit btn btn-primary" disabled="disabled">
+ <i class="fa fa-check"/>
+ <t t-esc="widget.sendLabel"/>
+ </button>
+ </div>
+ </div>
+ </form>
+ </t>
+ <!-- Template when the sign rpc is successful. -->
+ <t t-name="portal.portal_signature_success">
+ <div class="alert alert-success" role="status">
+ <span t-if="widget.message" t-esc="widget.message"/>
+ <span t-else="">Thank You!</span>
+ <a t-if="widget.redirect_url" t-att-href="widget.redirect_url">
+ <t t-if="widget.redirect_message" t-esc="widget.redirect_message"/>
+ <t t-else="">Click here to see your document.</t>
+ </a>
+ </div>
+ </t>
+ <!-- Template when the sign rpc returns an error. -->
+ <t t-name="portal.portal_signature_error">
+ <div class="o_portal_sign_error_msg alert alert-danger" role="status">
+ <t t-esc="widget.error"/>
+ </div>
+ </t>
+</templates>