Commit 19dc6690 authored by JC Brand's avatar JC Brand

Split the `trusted` setting into two new ones:

- `allow_user_trust_override`
- `clear_cache_on_logout`

The `persistent_store` setting can now also be set to `sessionStorage`

The `trusted` settings was in effect playing the role of two separate settings
and implicitly affecting a third ('persistent_store').

By breaking it up, we make things more explicit and allow for new
configurations. For example, clearing the cache on logout, while using
some kind of persistent store.
parent 5341a1ea
......@@ -36,6 +36,10 @@ Soon we'll deprecate the latter, so prepare now.
- #2220: fix rendering of emojis in case `use_system_emojis == false` (again).
- #2092: fixes room list update loop when having the `locked_muc_domain` truthy or `'hidden'`
- #2285: Rename config option `muc_hats_from_vcard` to [muc_hats](https://conversejs.org/docs/html/configuration.html#muc-hats). Now accepts a list instead of a boolean and allows for more flexible choices regarding user badges.
- The `trusted` configuration setting has been removed in favor of two new settings:
[allow_user_trust_override](https://conversejs.org/docs/html/configuration.html#allow-user-trust-override)
[clear_cache_on_logout](https://conversejs.org/docs/html/configuration.html#clear-cache-on-logout)
- The `persistent_store` setting can now also be set to `sessionStorage`
- The `api.archive.query` method no longer accepts an RSM instance as argument.
- The plugin `converse-uniview` has been removed and its functionality merged into `converse-chatboxviews`
- Removed the mockups from the project. Recommended to use tests instead.
......
......@@ -35,8 +35,8 @@ login
~~~~~
The default means is ``login``, which means that the user either logs in manually with their
username and password, or automatically if used together with ``auto_login=true``
and ``jid`` and ``password`` values. See `auto_login`_.
username and password, or automatically if used together with `auto_login`_ set to ``true``
and ``jid`` and ``password`` values.
external
~~~~~~~~
......@@ -83,7 +83,7 @@ A JID (jabber ID), SID (session ID) and RID (Request ID).
Converse needs these tokens in order to attach to that same session.
In addition to setting ``authentication`` to ``prebind``, you'll also need to
In addition to setting `authentication`_ to ``prebind``, you'll also need to
set the `prebind_url`_ and `bosh-service-url`_.
Here's an example of Converse being initialized with these options:
......@@ -243,6 +243,35 @@ Support for `XEP-0077: In band registration <https://xmpp.org/extensions/xep-007
Allow XMPP account registration showing the corresponding UI register form interface.
allow_user_trust_override
-------------------------
* Default: ``true``
* Allowed values: ``true``, ``false``, ``off``
This setting determines whether a user may decide whether
Converse is ``trusted`` or not (e.g. in the particular browser).
This is done via a *This is a trusted device* checkbox in the login form.
If this setting is set to ``true`` or ``off``, the checkbox will be shown to the user, otherwise not.
When this setting is set to ``true``, the checkbox will be checked by default.
To not have it checked by default, set this setting to ``off``.
If the user indicates that this device/browser is not trusted, then effectively
it's the same as setting `clear_cache_on_logout`_ to ``true``
and `persistent_store`_ to ``sessionStorage``.
``sessionStorage`` only persists while the current tab or window containing a Converse instance is open.
As soon as it's closed, the data is cleared.
The data that is cached (or cleared) includes your sent and received messages, which chats you had
open, what features the XMPP server supports and what your online status was.
Clearing the cache makes Converse much slower when the user logs in again, because all data needs to be fetch anew.
archived_messages_page_size
---------------------------
......@@ -312,15 +341,15 @@ auto_login
This option can be used to let Converse automatically log the user in as
soon as the page loads.
If ``authentication`` is set to ``login``, then you will also need to provide a
If `authentication`_ is set to ``login``, then you will also need to provide a
valid ``jid`` and ``password`` values, either manually by passing them in, or
by the `credentials_url`_ setting. Setting a ``credentials_url`` is preferable
to manually passing in ``jid`` and ``password`` values, because it allows
better reconnection with ``auto_reconnect``. When the connection drops,
better reconnection with `auto_reconnect`_. When the connection drops,
Converse will automatically fetch new login credentials from the
``credentials_url`` and reconnect.
If ``authentication`` is set to ``anonymous``, then you will also need to provide the
If `authentication`_ is set to ``anonymous``, then you will also need to provide the
server's domain via the `jid`_ setting.
This is a useful setting if you'd like to create a custom login form in your
......@@ -332,19 +361,19 @@ in to their XMPP account.
.. note::
The interaction between ``keepalive`` and ``auto_login`` is unfortunately
inconsistent depending on the ``authentication`` method used.
inconsistent depending on the `authentication`_ method used.
If ``auto_login`` is set to ``false`` and ``authentication`` is set to
If ``auto_login`` is set to ``false`` and `authentication`_ is set to
``anonymous``, ``external`` or ``prebind``, then Converse won't automatically
log the user in.
If ``authentication`` set to ``login`` the situation is much more
If `authentication`_ set to ``login`` the situation is much more
ambiguous, since we don't have a way to distinguish between wether we're
restoring a previous session (``keepalive``) or whether we're
automatically setting up a new session (``auto_login``).
So currently if EITHER ``keepalive`` or ``auto_login`` is ``true`` and
``authentication`` is set to ``login``, then Converse will try to log the user in.
`authentication`_ is set to ``login``, then Converse will try to log the user in.
auto_away
......@@ -377,13 +406,13 @@ auto_reconnect
Automatically reconnect to the XMPP server if the connection drops
unexpectedly.
This option works best when you have ``authentication`` set to ``prebind`` and have
This option works best when you have `authentication`_ set to ``prebind`` and have
also specified a ``prebind_url`` URL, from where Converse can fetch the BOSH
tokens. In this case, Converse will automaticallly reconnect when the
connection drops but also reestablish earlier lost connections (due to
network outages, closing your laptop etc.).
When ``authentication`` is set to `login`, then this option will only work when
When `authentication`_ is set to `login`, then this option will only work when
the page hasn't been reloaded yet, because then the user's password has been
wiped from memory. This configuration can however still be useful when using
Converse in desktop apps, for example those based on `CEF <https://bitbucket.org/chromiumembedded/cef>`_
......@@ -528,6 +557,23 @@ A more modern alternative to BOSH is to use `websockets <https://developer.mozil
Please see the :ref:`websocket-url` configuration setting.
clear_cache_on_logout
---------------------
* Default: ``false``
If set to ``true``, all locally cached data will be cleared when the user logs out,
regardless of the `persistent_store`_ being used (``localStorage``, ``IndexedDB`` or ``sessionStorage``).
*Note*: If `allow_user_trust_override`_ is set to ``true`` and the user
indicates that this device/browser is **not** trusted, then the cache will be
cleared on logout, even if this setting is set to ``true``.
*Note*: If this setting is set to ``true``, then OMEMO will be disabled, since
otherwise it won't be possible to decrypt archived messages that were
already decrypted previously (due to forward security).
clear_messages_on_reconnection
------------------------------
......@@ -623,13 +669,13 @@ credentials_url
* Default: ``null``
* Type: URL
This setting should be used in conjunction with ``authentication`` set to ``login``.
This setting should be used in conjunction with `authentication`_ set to ``login``.
It allows you to specify a URL which Converse will call when it needs to get
the username and password (or authentication token) which Converse will use
to automatically log the user in.
If ``auto_reconnect`` is also set to ``true``, then Converse will automatically
If `auto_reconnect`_ is also set to ``true``, then Converse will automatically
fetch new credentials from the ``credentials_url`` whenever the connection or
session drops, and then attempt to reconnect and establish a new session.
......@@ -874,7 +920,7 @@ If set to ``true``, then offline users aren't shown in the roster.
hide_open_bookmarks
-------------------
* Default: ``false`` (``true`` when the :ref:`view_mode` is set to ``fullscreen``).
* Default: ``false`` (``true`` when the `view_mode`_ is set to ``fullscreen``).
This setting applies to the ``converse-bookmarks`` plugin and specfically the
list of bookmarks shown in the ``Rooms`` tab of the control box.
......@@ -1468,7 +1514,7 @@ prebind_url
See also: :ref:`session-support`
This setting should be used in conjunction with ``authentication`` set to `prebind`.
This setting should be used in conjunction with `authentication`_ set to `prebind`.
It allows you to specify a URL which Converse will call when it needs to get
the RID and SID (Request ID and Session ID) tokens of a BOSH connection, which
......@@ -1533,7 +1579,7 @@ persistent_store
----------------
* Default: ``localStorage``
* Valid options: ``localStorage``, ``IndexedDB``
* Valid options: ``localStorage``, ``IndexedDB``, ``sessionStorage``
Determines which store is used for storing persistent data.
......@@ -1676,7 +1722,7 @@ Specifies whether the info icon is shown on the controlbox which when clicked op
show_controlbox_by_default
--------------------------
* Default: ``false`` (``true`` when the ``view_mode`` is set to ``fullscreen``)
* Default: ``false`` (``true`` when the `view_mode`_ is set to ``fullscreen``)
The "controlbox" refers to the special chatbox containing your contacts roster,
status widget, chatrooms and other controls.
......@@ -1776,8 +1822,6 @@ Alternatively you could use it with `view_mode`_ set to ``overlayed`` to create
a single helpdesk-type chat.
smacks_max_unacked_stanzas
--------------------------
......@@ -1865,42 +1909,6 @@ theme
Let's you set a color theme for Converse.
trusted
-------
* Default: ``true``
This setting determines whether the default value of the "This is a trusted device"
checkbox in the login form.
When the current device is not trusted, then the cache will be cleared when
the user logs out.
Additionally, it determines the type of `browser storage <https://developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Storage>`_
(``localStorage`` or ``sessionStorage``) used by Converse to cache user data.
If ``trusted`` is set to ``false``, then ``sessionStorage`` is used instead of
``localStorage``.
The main difference between the two is that ``sessionStorage`` only persists while
the current tab or window containing a Converse instance is open. As soon as
it's closed, the data is cleared (as long as there aren't any other tabs with
the same domain open).
Data in ``localStorage`` on the other hand is kept indefinitely.
The data that is cached includes your sent and received messages, which chats you had
open, what features the XMPP server supports and what your online status was.
Clearing the cache makes Converse much slower when the user logs
in again, because all data needs to be fetch anew.
If ``trusted`` is set to ``on`` or ``off`` the "This is a trusted device"
checkbox in the login form will not appear at all and cannot be changed by the user.
``on`` means to trust the device as stated above and use ``localStorage``. ``off``
means to not trust the device (cache is cleared when the user logs out) and to use
``sessionStorage``.
time_format
-----------
......
/*global mock */
/*global mock, converse */
const u = converse.env.utils;
......@@ -11,8 +11,8 @@ describe("The Login Form", function () {
allow_registration: false },
async function (done, _converse) {
mock.openControlBox(_converse);
const cbview = await u.waitUntil(() => _converse.chatboxviews.get('controlbox'));
mock.toggleControlBox();
const checkboxes = cbview.el.querySelectorAll('input[type="checkbox"]');
expect(checkboxes.length).toBe(1);
......@@ -24,17 +24,16 @@ describe("The Login Form", function () {
cbview.el.querySelector('input[name="jid"]').value = 'romeo@montague.lit';
cbview.el.querySelector('input[name="password"]').value = 'secret';
spyOn(cbview.loginpanel, 'connect');
cbview.delegateEvents();
expect(_converse.config.get('storage')).toBe('persistent');
expect(_converse.config.get('trusted')).toBe(true);
expect(_converse.getDefaultStore()).toBe('persistent');
cbview.el.querySelector('input[type="submit"]').click();
expect(_converse.config.get('storage')).toBe('persistent');
expect(cbview.loginpanel.connect).toHaveBeenCalled();
expect(_converse.config.get('trusted')).toBe(true);
expect(_converse.getDefaultStore()).toBe('persistent');
checkbox.click();
cbview.el.querySelector('input[type="submit"]').click();
expect(_converse.config.get('storage')).toBe('session');
expect(_converse.config.get('trusted')).toBe(false);
expect(_converse.getDefaultStore()).toBe('session');
done();
}));
......@@ -42,36 +41,32 @@ describe("The Login Form", function () {
mock.initConverse(
['chatBoxesInitialized'],
{ auto_login: false,
trusted: false,
allow_user_trust_override: 'off',
allow_registration: false },
function (done, _converse) {
u.waitUntil(() => _converse.chatboxviews.get('controlbox'))
.then(() => {
var cbview = _converse.chatboxviews.get('controlbox');
mock.openControlBox(_converse);
const checkboxes = cbview.el.querySelectorAll('input[type="checkbox"]');
expect(checkboxes.length).toBe(1);
async function (done, _converse) {
const checkbox = checkboxes[0];
const label = cbview.el.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
expect(label.textContent).toBe('This is a trusted device');
expect(checkbox.checked).toBe(false);
await u.waitUntil(() => _converse.chatboxviews.get('controlbox'))
const cbview = _converse.chatboxviews.get('controlbox');
mock.toggleControlBox();
const checkboxes = cbview.el.querySelectorAll('input[type="checkbox"]');
expect(checkboxes.length).toBe(1);
cbview.el.querySelector('input[name="jid"]').value = 'romeo@montague.lit';
cbview.el.querySelector('input[name="password"]').value = 'secret';
const checkbox = checkboxes[0];
const label = cbview.el.querySelector(`label[for="${checkbox.getAttribute('id')}"]`);
expect(label.textContent).toBe('This is a trusted device');
expect(checkbox.checked).toBe(false);
spyOn(cbview.loginpanel, 'connect');
cbview.el.querySelector('input[name="jid"]').value = 'romeo@montague.lit';
cbview.el.querySelector('input[name="password"]').value = 'secret';
expect(_converse.config.get('storage')).toBe('session');
cbview.el.querySelector('input[type="submit"]').click();
expect(_converse.config.get('storage')).toBe('session');
expect(cbview.loginpanel.connect).toHaveBeenCalled();
cbview.el.querySelector('input[type="submit"]').click();
expect(_converse.config.get('trusted')).toBe(false);
expect(_converse.getDefaultStore()).toBe('session');
checkbox.click();
cbview.el.querySelector('input[type="submit"]').click();
expect(_converse.config.get('storage')).toBe('persistent');
done();
});
checkbox.click();
cbview.el.querySelector('input[type="submit"]').click();
expect(_converse.config.get('trusted')).toBe(true);
expect(_converse.getDefaultStore()).toBe('persistent');
done();
}));
});
......@@ -75,16 +75,20 @@ window.addEventListener('converse-loaded', () => {
return Promise.all(_converse.chatboxviews.map(view => view.close()));
};
mock.openControlBox = async function (_converse) {
const model = await _converse.api.controlbox.open();
await u.waitUntil(() => model.get('connected'));
var toggle = document.querySelector(".toggle-controlbox");
mock.toggleControlBox = function () {
const toggle = document.querySelector(".toggle-controlbox");
if (!u.isVisible(document.querySelector("#controlbox"))) {
if (!u.isVisible(toggle)) {
u.removeClass('hidden', toggle);
}
toggle.click();
}
}
mock.openControlBox = async function (_converse) {
const model = await _converse.api.controlbox.open();
await u.waitUntil(() => model.get('connected'));
mock.toggleControlBox();
return this;
};
......
......@@ -102,6 +102,7 @@ converse.plugins.add('converse-controlbox', {
*/
api.settings.extend({
allow_logout: true,
allow_user_trust_override: true,
default_domain: undefined,
locked_domain: undefined,
show_controlbox_by_default: false,
......@@ -378,7 +379,7 @@ converse.plugins.add('converse-controlbox', {
'conn_feedback_message': _converse.connfeedback.get('message'),
'placeholder_username': (api.settings.get('locked_domain') || api.settings.get('default_domain')) &&
__('Username') || __('user@domain'),
'show_trust_checkbox': _converse.trusted !== 'on' && _converse.trusted !== 'off'
'show_trust_checkbox': api.settings.get('allow_user_trust_override')
})
);
},
......@@ -407,9 +408,11 @@ converse.plugins.add('converse-controlbox', {
return true;
},
/**
* Authenticate the user based on a form submission event.
* @param { Event } ev
*/
authenticate (ev) {
/* Authenticate the user based on a form submission event.
*/
if (ev && ev.preventDefault) { ev.preventDefault(); }
if (api.settings.get("authentication") === _converse.ANONYMOUS) {
return this.connect(_converse.jid, null);
......@@ -417,18 +420,7 @@ converse.plugins.add('converse-controlbox', {
if (!this.validate()) { return; }
const form_data = new FormData(ev.target);
if (_converse.trusted === 'on' || _converse.trusted === 'off') {
_converse.config.save({
'trusted': _converse.trusted === 'on',
'storage': _converse.trusted === 'on' ? 'persistent' : 'session'
});
} else {
_converse.config.save({
'trusted': form_data.get('trusted') && true || false,
'storage': form_data.get('trusted') ? 'persistent' : 'session'
});
}
_converse.config.save({ 'trusted': form_data.get('trusted') && true || false });
let jid = form_data.get('jid');
if (api.settings.get('locked_domain')) {
......
......@@ -405,7 +405,8 @@ async function fetchOwnDevices () {
}
async function initOMEMO () {
if (!_converse.config.get('trusted')) {
if (!_converse.config.get('trusted') || api.settings.get('clear_cache_on_logout')) {
log.warn("Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true");
return;
}
_converse.devicelists = new _converse.DeviceLists();
......@@ -513,7 +514,9 @@ function getOMEMOToolbarButton (toolbar_el, buttons) {
converse.plugins.add('converse-omemo', {
enabled (_converse) {
return window.libsignal && !_converse.api.settings.get("blacklisted_plugins").includes('converse-omemo') && _converse.config.get('trusted');
return window.libsignal &&
!_converse.api.settings.get("blacklisted_plugins").includes('converse-omemo') &&
(_converse.config.get('trusted') || !api.settings.get('clear_cache_on_logout'));
},
dependencies: ["converse-chatview", "converse-pubsub", "converse-profile"],
......@@ -1358,4 +1361,3 @@ converse.plugins.add('converse-omemo', {
});
}
});
......@@ -404,4 +404,3 @@ export class MockConnection extends Connection {
}
}
}
......@@ -95,13 +95,14 @@ const DEFAULT_SETTINGS = {
auto_login: false, // Currently only used in connection with anonymous login
auto_reconnect: true,
blacklisted_plugins: [],
clear_cache_on_logout: false,
connection_options: {},
credentials_url: null, // URL from where login credentials can be fetched
discover_connection_methods: true,
geouri_regex: /https\:\/\/www.openstreetmap.org\/.*#map=[0-9]+\/([\-0-9.]+)\/([\-0-9.]+)\S*/g,
geouri_replacement: 'https://www.openstreetmap.org/?mlat=$1&mlon=$2#map=18/$1/$2',
idle_presence_timeout: 300, // Seconds after which an idle presence is sent
i18n: 'en',
idle_presence_timeout: 300, // Seconds after which an idle presence is sent
jid: undefined,
keepalive: true,
loglevel: 'info',
......@@ -118,7 +119,6 @@ const DEFAULT_SETTINGS = {
sid: undefined,
singleton: false,
strict_plugin_dependencies: false,
trusted: true,
view_mode: 'overlayed', // Choices are 'overlayed', 'fullscreen', 'mobile'
websocket_url: undefined,
whitelisted_plugins: []
......@@ -585,7 +585,7 @@ export const api = _converse.api = {
/**
* Get the value of a particular user setting.
* @method _converse.api.user.settings.get
* @param {String} key - hello world
* @param {String} key - The setting name
* @param {*} fallback - An optional fallback value if the user setting is undefined
* @returns {Promise} Promise which resolves with the value of the particular configuration setting.
* @example _converse.api.user.settings.get("foo");
......@@ -688,6 +688,7 @@ export const api = _converse.api = {
return _converse[key];
}
},
/**
* Set one or many configuration settings.
*
......@@ -973,7 +974,7 @@ async function initSessionStorage () {
function initPersistentStorage () {
if (_converse.config.get('storage') !== 'persistent') {
if (api.settings.get('persistent_store') === 'sessionStorage') {
return;
}
const config = {
......@@ -991,8 +992,18 @@ function initPersistentStorage () {
}
_converse.getDefaultStore = function () {
if (_converse.config.get('trusted')) {
const is_non_persistent = api.settings.get('persistent_store') === 'sessionStorage';
return is_non_persistent ? 'session': 'persistent';
} else {
return 'session';
}
}
function createStore (id, storage) {
const s = _converse.storage[storage ? storage : _converse.config.get('storage')];
const s = _converse.storage[storage || _converse.getDefaultStore()];
return new Storage(id, s);
}
......@@ -1049,11 +1060,7 @@ function initClientConfig () {
* user sessions.
*/
const id = 'converse.client-config';
_converse.config = new Model({
'id': id,
'trusted': _converse.api.settings.get("trusted") && true || false,
'storage': _converse.api.settings.get("trusted") ? 'persistent' : 'session'
});
_converse.config = new Model({ id, 'trusted': true });
_converse.config.browserStorage = createStore(id, "session");
_converse.config.fetch();
/**
......@@ -1141,7 +1148,11 @@ function connect (credentials) {
}
_converse.shouldClearCache = () => (!_converse.config.get('trusted') || _converse.isTestEnv());
_converse.shouldClearCache = () => (
!_converse.config.get('trusted') ||
api.settings.get('clear_cache_on_logout') ||
_converse.isTestEnv()
);
export function clearSession () {
......
......@@ -51,7 +51,7 @@ function getCorrectionAttributes (stanza, original_stanza) {
function getEncryptionAttributes (stanza, _converse) {
const encrypted = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop();
const attrs = { 'is_encrypted': !!encrypted };
if (!encrypted || !_converse.config.get('trusted')) {
if (!encrypted || api.settings.get('clear_cache_on_logout')) {
return attrs;
}
const header = encrypted.querySelector('header');
......
import tpl_spinner from './spinner.js';
import { __ } from '../i18n';
import { api } from "@converse/headless/converse-core";
import { _converse, api } from "@converse/headless/converse-core";
import { html } from "lit-html";
const trust_checkbox = (o) => {
const trust_checkbox = (checked) => {
const i18n_hint_trusted = __(
'To improve performance, we cache your data in this browser. '+
'Uncheck this box if this is a public computer or if you want your data to be deleted when you log out. '+
......@@ -13,7 +13,7 @@ const trust_checkbox = (o) => {
const i18n_trusted = __('This is a trusted device');
return html`
<div class="form-group form-check login-trusted">
<input id="converse-login-trusted" type="checkbox" class="form-check-input" name="trusted" ?checked=${o._converse.config.get('trusted')}>
<input id="converse-login-trusted" type="checkbox" class="form-check-input" name="trusted" ?checked=${checked}>
<label for="converse-login-trusted" class="form-check-label login-trusted__desc">${i18n_trusted}</label>
<i class="fa fa-info-circle" data-toggle="popover"
data-title="Trusted device?"
......@@ -43,8 +43,7 @@ const register_link = () => {
`;
}
const show_register_link = (o) => {
const _converse = o._converse;
const show_register_link = () => {
return _converse.allow_registration &&
!api.settings.get("auto_login") &&
_converse.pluggable.plugins['converse-register'].enabled(_converse);
......@@ -66,11 +65,11 @@ const auth_fields = (o) => {
placeholder="${o.placeholder_username}"/>
</div>
${ (o.authentication !== o.EXTERNAL) ? password_input() : '' }
${ o.show_trust_checkbox ? trust_checkbox(o) : '' }
${ o.show_trust_checkbox ? trust_checkbox(o.show_trust_checkbox === 'off' ? false : true) : '' }
<fieldset class="buttons">
<input class="btn btn-primary" type="submit" value="${i18n_login}"/>
</fieldset>
${ show_register_link(o) ? register_link(o) : '' }
${ show_register_link() ? register_link(o) : '' }
`;
}
......@@ -93,6 +92,6 @@ export default (o) => html`
<p class="feedback-subject">${ o.conn_feedback_subject }</p>
<p class="feedback-message ${ !o.conn_feedback_message ? 'hidden' : '' }">${o.conn_feedback_message}</p>
</div>
${ (o._converse.CONNECTION_STATUS[o.connection_status] === 'CONNECTING') ? tpl_spinner({'classes': 'hor_centered'}) : form_fields(o) }
${ (_converse.CONNECTION_STATUS[o.connection_status] === 'CONNECTING') ? tpl_spinner({'classes': 'hor_centered'}) : form_fields(o) }
</form>
`;
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment