Browse Source

Move converse-register plugin to folder

JC Brand 4 years ago
parent
commit
e31fa1f7dd

+ 1 - 1
src/converse.js

@@ -27,7 +27,7 @@ import "./plugins/notifications.js";
 import "./plugins/omemo.js";
 import "./plugins/profile.js";
 import "./plugins/push.js";                 // XEP-0357 Push Notifications
-import "./plugins/register.js";             // XEP-0077 In-band registration
+import "./plugins/register/index.js";       // XEP-0077 In-band registration
 import "./plugins/roomslist/index.js";      // Show currently open chat rooms
 import "./plugins/rosterview/index.js";
 import "./plugins/singleton.js";

+ 0 - 644
src/plugins/register.js

@@ -1,644 +0,0 @@
-/**
- * @module converse-register
- * @description
- * This is a Converse.js plugin which add support for in-band registration
- * as specified in XEP-0077.
- * @copyright 2020, the Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import "./controlbox/index.js";
-import log from "@converse/headless/log";
-import tpl_form_input from "../templates/form_input.js";
-import tpl_form_url from "../templates/form_url.js";
-import tpl_form_username from "../templates/form_username.js";
-import tpl_register_panel from "../templates/register_panel.html";
-import tpl_registration_form from "../templates/registration_form.js";
-import tpl_registration_request from "../templates/registration_request.html";
-import tpl_spinner from "../templates/spinner.js";
-import utils from "@converse/headless/utils/form";
-import { View } from "@converse/skeletor/src/view";
-import { __ } from 'i18n';
-import { _converse, api, converse } from "@converse/headless/core";
-import { pick } from "lodash-es";
-import { render } from 'lit-html';
-
-// Strophe methods for building stanzas
-const { Strophe, sizzle, $iq } = converse.env;
-const u = converse.env.utils;
-
-// Add Strophe Namespaces
-Strophe.addNamespace('REGISTER', 'jabber:iq:register');
-
-// Add Strophe Statuses
-const i = Object.keys(Strophe.Status).reduce((max, k) => Math.max(max, Strophe.Status[k]), 0);
-Strophe.Status.REGIFAIL        = i + 1;
-Strophe.Status.REGISTERED      = i + 2;
-Strophe.Status.CONFLICT        = i + 3;
-Strophe.Status.NOTACCEPTABLE   = i + 5;
-
-
-converse.plugins.add('converse-register', {
-
-    enabled () {
-        return true;
-    },
-
-    overrides: {
-        // Overrides mentioned here will be picked up by converse.js's
-        // plugin architecture they will replace existing methods on the
-        // relevant objects or classes.
-        //
-        // New functions which don't exist yet can also be added.
-
-        ControlBoxView: {
-            renderLoginPanel () {
-                /* Also render a registration panel, when rendering the
-                 * login panel.
-                 */
-                this.__super__.renderLoginPanel.apply(this, arguments);
-                this.renderRegistrationPanel();
-                return this;
-            }
-        }
-    },
-
-    initialize () {
-        /* The initialize function gets called as soon as the plugin is
-         * loaded by converse.js's plugin machinery.
-         */
-        _converse.CONNECTION_STATUS[Strophe.Status.REGIFAIL] = 'REGIFAIL';
-        _converse.CONNECTION_STATUS[Strophe.Status.REGISTERED] = 'REGISTERED';
-        _converse.CONNECTION_STATUS[Strophe.Status.CONFLICT] = 'CONFLICT';
-        _converse.CONNECTION_STATUS[Strophe.Status.NOTACCEPTABLE] = 'NOTACCEPTABLE';
-
-        api.settings.extend({
-            'allow_registration': true,
-            'domain_placeholder': __(" e.g. conversejs.org"),  // Placeholder text shown in the domain input on the registration form
-            'providers_link': 'https://compliance.conversations.im/', // Link to XMPP providers shown on registration page
-            'registration_domain': ''
-        });
-
-
-        Object.assign(_converse.ControlBoxView.prototype, {
-
-            showLoginOrRegisterForm () {
-                if (!this.registerpanel) {
-                    return;
-                }
-                if (this.model.get('active-form') == "register") {
-                    this.loginpanel.el.classList.add('hidden');
-                    this.registerpanel.el.classList.remove('hidden');
-                } else {
-                    this.loginpanel.el.classList.remove('hidden');
-                    this.registerpanel.el.classList.add('hidden');
-                }
-            },
-
-            renderRegistrationPanel () {
-                if (api.settings.get('allow_registration')) {
-                    this.registerpanel = new _converse.RegisterPanel({
-                        'model': this.model
-                    });
-                    this.registerpanel.render();
-                    this.registerpanel.el.classList.add('hidden');
-                    const login_panel = this.el.querySelector('#converse-login-panel');
-                    if (login_panel) {
-                        login_panel.insertAdjacentElement('afterend', this.registerpanel.el);
-                    }
-                    this.showLoginOrRegisterForm();
-                }
-                return this;
-            }
-        });
-
-
-        function setActiveForm (value) {
-            api.waitUntil('controlBoxInitialized').then(() => {
-                const controlbox = _converse.chatboxes.get('controlbox')
-                controlbox.set({'active-form': value});
-            }).catch(e => log.fatal(e));
-        }
-        _converse.router.route('converse/login', () => setActiveForm('login'));
-        _converse.router.route('converse/register', () => setActiveForm('register'));
-
-
-        /**
-         * @class
-         * @namespace _converse.RegisterPanel
-         * @memberOf _converse
-         */
-        _converse.RegisterPanel = View.extend({
-            tagName: 'div',
-            id: "converse-register-panel",
-            className: 'controlbox-pane fade-in',
-            events: {
-                'submit form#converse-register': 'onFormSubmission',
-                'click .button-cancel': 'renderProviderChoiceForm',
-            },
-
-            initialize () {
-                this.reset();
-                api.listen.on('connectionInitialized', () => this.registerHooks());
-            },
-
-            render () {
-                this.model.set('registration_form_rendered', false);
-                this.el.innerHTML = tpl_register_panel({
-                    '__': __,
-                    'default_domain': api.settings.get('registration_domain'),
-                    'label_register': __('Fetch registration form'),
-                    'help_providers': __('Tip: A list of public XMPP providers is available'),
-                    'help_providers_link': __('here'),
-                    'href_providers': api.settings.get('providers_link'),
-                    'domain_placeholder': api.settings.get('domain_placeholder')
-                });
-                if (api.settings.get('registration_domain')) {
-                    this.fetchRegistrationForm(api.settings.get('registration_domain'));
-                }
-                return this;
-            },
-
-            registerHooks () {
-                /* Hook into Strophe's _connect_cb, so that we can send an IQ
-                 * requesting the registration fields.
-                 */
-                const conn = _converse.connection;
-                const connect_cb = conn._connect_cb.bind(conn);
-                conn._connect_cb = (req, callback, raw) => {
-                    if (!this._registering) {
-                        connect_cb(req, callback, raw);
-                    } else {
-                        if (this.getRegistrationFields(req, callback)) {
-                            this._registering = false;
-                        }
-                    }
-                };
-            },
-
-            /**
-             * Send an IQ stanza to the XMPP server asking for the registration fields.
-             * @private
-             * @method _converse.RegisterPanel#getRegistrationFields
-             * @param { Strophe.Request } req - The current request
-             * @param { Function } callback - The callback function
-             */
-            getRegistrationFields (req, _callback) {
-                const conn = _converse.connection;
-                conn.connected = true;
-
-                const body = conn._proto._reqToData(req);
-                if (!body) { return; }
-                if (conn._proto._connect_cb(body) === Strophe.Status.CONNFAIL) {
-                    this.showValidationError(
-                        __("Sorry, we're unable to connect to your chosen provider.")
-                    );
-                    return false;
-                }
-                const register = body.getElementsByTagName("register");
-                const mechanisms = body.getElementsByTagName("mechanism");
-                if (register.length === 0 && mechanisms.length === 0) {
-                    conn._proto._no_auth_received(_callback);
-                    return false;
-                }
-                if (register.length === 0) {
-                    conn._changeConnectStatus(Strophe.Status.REGIFAIL);
-                    this.showValidationError(
-                        __("Sorry, the given provider does not support in "+
-                           "band account registration. Please try with a "+
-                           "different provider."))
-                    return true;
-                }
-                // Send an IQ stanza to get all required data fields
-                conn._addSysHandler(this.onRegistrationFields.bind(this), null, "iq", null, null);
-                const stanza = $iq({type: "get"}).c("query", {xmlns: Strophe.NS.REGISTER}).tree();
-                stanza.setAttribute("id", conn.getUniqueId("sendIQ"));
-                conn.send(stanza);
-                conn.connected = false;
-                return true;
-            },
-
-            /**
-             * Handler for {@link _converse.RegisterPanel#getRegistrationFields}
-             * @private
-             * @method _converse.RegisterPanel#onRegistrationFields
-             * @param { XMLElement } stanza - The query stanza.
-             */
-            onRegistrationFields (stanza) {
-                if (stanza.getAttribute("type") === "error") {
-                    _converse.connection._changeConnectStatus(
-                        Strophe.Status.REGIFAIL,
-                        __('Something went wrong while establishing a connection with "%1$s". '+
-                           'Are you sure it exists?', this.domain)
-                    );
-                    return false;
-                }
-                if (stanza.getElementsByTagName("query").length !== 1) {
-                    _converse.connection._changeConnectStatus(
-                        Strophe.Status.REGIFAIL,
-                        "unknown"
-                    );
-                    return false;
-                }
-                this.setFields(stanza);
-                if (!this.model.get('registration_form_rendered')) {
-                    this.renderRegistrationForm(stanza);
-                }
-                return false;
-            },
-
-            reset (settings) {
-                const defaults = {
-                    fields: {},
-                    urls: [],
-                    title: "",
-                    instructions: "",
-                    registered: false,
-                    _registering: false,
-                    domain: null,
-                    form_type: null
-                };
-                Object.assign(this, defaults);
-                if (settings) {
-                    Object.assign(this, pick(settings, Object.keys(defaults)));
-                }
-            },
-
-            onFormSubmission (ev) {
-                /* Event handler when the #converse-register form is
-                 * submitted.
-                 *
-                 * Depending on the available input fields, we delegate to
-                 * other methods.
-                 */
-                if (ev && ev.preventDefault) { ev.preventDefault(); }
-                if (ev.target.querySelector('input[name=domain]') === null) {
-                    this.submitRegistrationForm(ev.target);
-                } else {
-                    this.onProviderChosen(ev.target);
-                }
-
-            },
-
-            /**
-             * Callback method that gets called when the user has chosen an XMPP provider
-             * @private
-             * @method _converse.RegisterPanel#onProviderChosen
-             * @param { HTMLElement } form - The form that was submitted
-             */
-            onProviderChosen (form) {
-                const domain_input = form.querySelector('input[name=domain]'),
-                    domain = domain_input?.value;
-                if (!domain) {
-                    // TODO: add validation message
-                    domain_input.classList.add('error');
-                    return;
-                }
-                form.querySelector('input[type=submit]').classList.add('hidden');
-                this.fetchRegistrationForm(domain.trim());
-            },
-
-            /**
-             * Fetch a registration form from the requested domain
-             * @private
-             * @method _converse.RegisterPanel#fetchRegistrationForm
-             * @param { String } domain_name - XMPP server domain
-             */
-            async fetchRegistrationForm (domain_name) {
-                if (!this.model.get('registration_form_rendered')) {
-                    this.renderRegistrationRequest();
-                }
-                this.reset({
-                    'domain': Strophe.getDomainFromJid(domain_name),
-                    '_registering': true
-                });
-                await _converse.initConnection(this.domain);
-                // When testing, the test tears down before the async function
-                // above finishes. So we use optional chaining here
-                _converse.connection?.connect(this.domain, "", status => this.onConnectStatusChanged(status));
-                return false;
-            },
-
-            renderRegistrationRequest () {
-                /* Clear the form and inform the user that the registration
-                 * form is being fetched.
-                 */
-                this.clearRegistrationForm().insertAdjacentHTML(
-                    'beforeend',
-                    tpl_registration_request({
-                        '__': _converse.__,
-                        'cancel': api.settings.get('registration_domain'),
-                    })
-                );
-            },
-
-            giveFeedback (message, klass) {
-                let feedback = this.el.querySelector('.reg-feedback');
-                if (feedback !== null) {
-                    feedback.parentNode.removeChild(feedback);
-                }
-                const form = this.el.querySelector('form');
-                form.insertAdjacentHTML('afterbegin', '<span class="reg-feedback"></span>');
-                feedback = form.querySelector('.reg-feedback');
-                feedback.textContent = message;
-                if (klass) {
-                    feedback.classList.add(klass);
-                }
-            },
-
-            clearRegistrationForm () {
-                const form = this.el.querySelector('form');
-                form.innerHTML = '';
-                this.model.set('registration_form_rendered', false);
-                return form;
-            },
-
-            showSpinner () {
-                const form = this.el.querySelector('form');
-                render(tpl_spinner(), form);
-                this.model.set('registration_form_rendered', false);
-                return this;
-            },
-
-            /**
-             * Callback function called by Strophe whenever the connection status changes.
-             * Passed to Strophe specifically during a registration attempt.
-             * @private
-             * @method _converse.RegisterPanel#onConnectStatusChanged
-             * @param { integer } status_code - The Strophe.Status status code
-             */
-            onConnectStatusChanged(status_code) {
-                log.debug('converse-register: onConnectStatusChanged');
-                if ([Strophe.Status.DISCONNECTED,
-                     Strophe.Status.CONNFAIL,
-                     Strophe.Status.REGIFAIL,
-                     Strophe.Status.NOTACCEPTABLE,
-                     Strophe.Status.CONFLICT
-                    ].includes(status_code)) {
-
-                    log.error(
-                        `Problem during registration: Strophe.Status is ${_converse.CONNECTION_STATUS[status_code]}`
-                    );
-                    this.abortRegistration();
-                } else if (status_code === Strophe.Status.REGISTERED) {
-                    log.debug("Registered successfully.");
-                    _converse.connection.reset();
-                    this.showSpinner();
-
-                    if (["converse/login", "converse/register"].includes(_converse.router.history.getFragment())) {
-                        _converse.router.navigate('', {'replace': true});
-                    }
-
-                    if (this.fields.password && this.fields.username) {
-                        // automatically log the user in
-                        _converse.connection.connect(
-                            this.fields.username.toLowerCase()+'@'+this.domain.toLowerCase(),
-                            this.fields.password,
-                            _converse.onConnectStatusChanged
-                        );
-                        this.giveFeedback(__('Now logging you in'), 'info');
-                    } else {
-                        _converse.chatboxviews.get('controlbox').renderLoginPanel();
-                        _converse.giveFeedback(__('Registered successfully'));
-                    }
-                    this.reset();
-                }
-            },
-
-            getLegacyFormFields () {
-                const input_fields = Object.keys(this.fields).map(key => {
-                    if (key === "username") {
-                        return tpl_form_username({
-                            'domain': ` @${this.domain}`,
-                            'name': key,
-                            'type': "text",
-                            'label': key,
-                            'value': '',
-                            'required': true
-                        });
-                    } else {
-                        return tpl_form_input({
-                            'label': key,
-                            'name': key,
-                            'placeholder': key,
-                            'required': true,
-                            'type': (key === 'password' || key === 'email') ? key : "text",
-                            'value': ''
-                        })
-                    }
-                });
-                const urls = this.urls.map(u => tpl_form_url({'label': '', 'value': u}));
-                return [...input_fields, ...urls];
-            },
-
-            getFormFields (stanza) {
-                if (this.form_type === 'xform') {
-                    return Array.from(stanza.querySelectorAll('field')).map(field =>
-                        utils.xForm2TemplateResult(field, stanza, {'domain': this.domain})
-                    );
-                } else {
-                    return this.getLegacyFormFields();
-                }
-            },
-
-            /**
-             * Renders the registration form based on the XForm fields
-             * received from the XMPP server.
-             * @private
-             * @method _converse.RegisterPanel#renderRegistrationForm
-             * @param { XMLElement } stanza - The IQ stanza received from the XMPP server.
-             */
-            renderRegistrationForm (stanza) {
-                const form = this.el.querySelector('form');
-                const tpl = tpl_registration_form({
-                    'domain': this.domain,
-                    'title': this.title,
-                    'instructions': this.instructions,
-                    'fields': this.fields,
-                    'form_fields': this.getFormFields(stanza)
-                });
-                render(tpl, form);
-                form.classList.remove('hidden');
-                this.model.set('registration_form_rendered', true);
-            },
-
-            showValidationError (message) {
-                const form = this.el.querySelector('form');
-                let flash = form.querySelector('.form-errors');
-                if (flash === null) {
-                    flash = '<div class="form-errors hidden"></div>';
-                    const instructions = form.querySelector('p.instructions');
-                    if (instructions === null) {
-                        form.insertAdjacentHTML('afterbegin', flash);
-                    } else {
-                        instructions.insertAdjacentHTML('afterend', flash);
-                    }
-                    flash = form.querySelector('.form-errors');
-                } else {
-                    flash.innerHTML = '';
-                }
-                flash.insertAdjacentHTML(
-                    'beforeend',
-                    '<p class="form-help error">'+message+'</p>'
-                );
-                flash.classList.remove('hidden');
-            },
-
-            /**
-             * Report back to the user any error messages received from the
-             * XMPP server after attempted registration.
-             * @private
-             * @method _converse.RegisterPanel#reportErrors
-             * @param { XMLElement } stanza - The IQ stanza received from the XMPP server
-             */
-            reportErrors (stanza) {
-                const errors = stanza.querySelectorAll('error');
-                errors.forEach(e => this.showValidationError(e.textContent));
-                if (!errors.length) {
-                    const message = __('The provider rejected your registration attempt. '+
-                        'Please check the values you entered for correctness.');
-                    this.showValidationError(message);
-                }
-            },
-
-            renderProviderChoiceForm (ev) {
-                if (ev && ev.preventDefault) { ev.preventDefault(); }
-                _converse.connection._proto._abortAllRequests();
-                _converse.connection.reset();
-                this.render();
-            },
-
-            abortRegistration () {
-                _converse.connection._proto._abortAllRequests();
-                _converse.connection.reset();
-                if (this.model.get('registration_form_rendered')) {
-                    if (api.settings.get('registration_domain') && this.model.get('registration_form_rendered')) {
-                        this.fetchRegistrationForm(
-                            api.settings.get('registration_domain')
-                        );
-                    }
-                } else {
-                    this.render();
-                }
-            },
-
-            /**
-             * Handler, when the user submits the registration form.
-             * Provides form error feedback or starts the registration process.
-             * @private
-             * @method _converse.RegisterPanel#submitRegistrationForm
-             * @param { HTMLElement } form - The HTML form that was submitted
-             */
-            submitRegistrationForm (form) {
-                const has_empty_inputs = Array.from(this.el.querySelectorAll('input.required'))
-                    .reduce((result, input) => {
-                        if (input.value === '') {
-                            input.classList.add('error');
-                            return result + 1;
-                        }
-                        return result;
-                    }, 0);
-                if (has_empty_inputs) { return; }
-
-                const inputs = sizzle(':input:not([type=button]):not([type=submit])', form);
-                const iq = $iq({'type': 'set', 'id': u.getUniqueId()})
-                            .c("query", {xmlns:Strophe.NS.REGISTER});
-
-                if (this.form_type === 'xform') {
-                    iq.c("x", {xmlns: Strophe.NS.XFORM, type: 'submit'});
-
-                    const xml_nodes = inputs.map(i => utils.webForm2xForm(i)).filter(n => n);
-                    xml_nodes.forEach(n => iq.cnode(n).up());
-                } else {
-                    inputs.forEach(input => iq.c(input.getAttribute('name'), {}, input.value));
-                }
-                _converse.connection._addSysHandler(this._onRegisterIQ.bind(this), null, "iq", null, null);
-                _converse.connection.send(iq);
-                this.setFields(iq.tree());
-            },
-
-            /* Stores the values that will be sent to the XMPP server during attempted registration.
-             * @private
-             * @method _converse.RegisterPanel#setFields
-             * @param { XMLElement } stanza - the IQ stanza that will be sent to the XMPP server.
-             */
-            setFields (stanza) {
-                const query = stanza.querySelector('query');
-                const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query);
-                if (xform.length > 0) {
-                    this._setFieldsFromXForm(xform.pop());
-                } else {
-                    this._setFieldsFromLegacy(query);
-                }
-            },
-
-            _setFieldsFromLegacy (query) {
-                [].forEach.call(query.children, field => {
-                    if (field.tagName.toLowerCase() === 'instructions') {
-                        this.instructions = Strophe.getText(field);
-                        return;
-                    } else if (field.tagName.toLowerCase() === 'x') {
-                        if (field.getAttribute('xmlns') === 'jabber:x:oob') {
-                            this.urls.concat(sizzle('url', field).map(u => u.textContent));
-                        }
-                        return;
-                    }
-                    this.fields[field.tagName.toLowerCase()] = Strophe.getText(field);
-                });
-                this.form_type = 'legacy';
-            },
-
-            _setFieldsFromXForm (xform) {
-                this.title = xform.querySelector('title')?.textContent;
-                this.instructions = xform.querySelector('instructions')?.textContent;
-                xform.querySelectorAll('field').forEach(field => {
-                    const _var = field.getAttribute('var');
-                    if (_var) {
-                        this.fields[_var.toLowerCase()] = field.querySelector('value')?.textContent ?? '';
-                    } else {
-                        // TODO: other option seems to be type="fixed"
-                        log.warn("Found field we couldn't parse");
-                    }
-                });
-                this.form_type = 'xform';
-            },
-
-            /**
-             * Callback method that gets called when a return IQ stanza
-             * is received from the XMPP server, after attempting to
-             * register a new user.
-             * @private
-             * @method _converse.RegisterPanel#reportErrors
-             * @param { XMLElement } stanza - The IQ stanza.
-             */
-            _onRegisterIQ (stanza) {
-                if (stanza.getAttribute("type") === "error") {
-                    log.error("Registration failed.");
-                    this.reportErrors(stanza);
-
-                    let error = stanza.getElementsByTagName("error");
-                    if (error.length !== 1) {
-                        _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, "unknown");
-                        return false;
-                    }
-                    error = error[0].firstElementChild.tagName.toLowerCase();
-                    if (error === 'conflict') {
-                        _converse.connection._changeConnectStatus(Strophe.Status.CONFLICT, error);
-                    } else if (error === 'not-acceptable') {
-                        _converse.connection._changeConnectStatus(Strophe.Status.NOTACCEPTABLE, error);
-                    } else {
-                        _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, error);
-                    }
-                } else {
-                    _converse.connection._changeConnectStatus(Strophe.Status.REGISTERED, null);
-                }
-                return false;
-            }
-        });
-
-        /************************ BEGIN Event Handlers ************************/
-        api.listen.on('controlBoxInitialized', view => {
-            view.model.on('change:active-form', view.showLoginOrRegisterForm, view);
-        });
-        /************************ END Event Handlers ************************/
-    }
-});

+ 34 - 0
src/plugins/register/controlbox-mixin.js

@@ -0,0 +1,34 @@
+import { _converse, api } from '@converse/headless/core';
+
+const ControlBoxRegistrationMixin = {
+    showLoginOrRegisterForm () {
+        if (!this.registerpanel) {
+            return;
+        }
+        if (this.model.get('active-form') == 'register') {
+            this.loginpanel.el.classList.add('hidden');
+            this.registerpanel.el.classList.remove('hidden');
+        } else {
+            this.loginpanel.el.classList.remove('hidden');
+            this.registerpanel.el.classList.add('hidden');
+        }
+    },
+
+    renderRegistrationPanel () {
+        if (api.settings.get('allow_registration')) {
+            this.registerpanel = new _converse.RegisterPanel({
+                'model': this.model
+            });
+            this.registerpanel.render();
+            this.registerpanel.el.classList.add('hidden');
+            const login_panel = this.el.querySelector('#converse-login-panel');
+            if (login_panel) {
+                login_panel.insertAdjacentElement('afterend', this.registerpanel.el);
+            }
+            this.showLoginOrRegisterForm();
+        }
+        return this;
+    }
+};
+
+export default ControlBoxRegistrationMixin;

+ 82 - 0
src/plugins/register/index.js

@@ -0,0 +1,82 @@
+/**
+ * @module converse-register
+ * @description
+ * This is a Converse.js plugin which add support for in-band registration
+ * as specified in XEP-0077.
+ * @copyright 2020, the Converse.js contributors
+ * @license Mozilla Public License (MPLv2)
+ */
+import '../controlbox/index.js';
+import ControlBoxRegistrationMixin from './controlbox-mixin.js';
+import RegisterPanel from './panel.js';
+import log from '@converse/headless/log';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+
+// Strophe methods for building stanzas
+const { Strophe } = converse.env;
+
+// Add Strophe Namespaces
+Strophe.addNamespace('REGISTER', 'jabber:iq:register');
+
+// Add Strophe Statuses
+const i = Object.keys(Strophe.Status).reduce((max, k) => Math.max(max, Strophe.Status[k]), 0);
+Strophe.Status.REGIFAIL = i + 1;
+Strophe.Status.REGISTERED = i + 2;
+Strophe.Status.CONFLICT = i + 3;
+Strophe.Status.NOTACCEPTABLE = i + 5;
+
+converse.plugins.add('converse-register', {
+    enabled () {
+        return true;
+    },
+
+    overrides: {
+        // Overrides mentioned here will be picked up by converse.js's
+        // plugin architecture they will replace existing methods on the
+        // relevant objects or classes.
+
+        ControlBoxView: {
+            renderLoginPanel () {
+                // Also render a registration panel, when rendering the login panel.
+                this.__super__.renderLoginPanel.apply(this, arguments);
+                this.renderRegistrationPanel();
+                return this;
+            }
+        }
+    },
+
+    initialize () {
+        _converse.CONNECTION_STATUS[Strophe.Status.REGIFAIL] = 'REGIFAIL';
+        _converse.CONNECTION_STATUS[Strophe.Status.REGISTERED] = 'REGISTERED';
+        _converse.CONNECTION_STATUS[Strophe.Status.CONFLICT] = 'CONFLICT';
+        _converse.CONNECTION_STATUS[Strophe.Status.NOTACCEPTABLE] = 'NOTACCEPTABLE';
+
+        api.settings.extend({
+            'allow_registration': true,
+            'domain_placeholder': __(' e.g. conversejs.org'), // Placeholder text shown in the domain input on the registration form
+            'providers_link': 'https://compliance.conversations.im/', // Link to XMPP providers shown on registration page
+            'registration_domain': ''
+        });
+
+        Object.assign(_converse.ControlBoxView.prototype, ControlBoxRegistrationMixin);
+
+        _converse.RegisterPanel = RegisterPanel;
+
+        function setActiveForm (value) {
+            api.waitUntil('controlBoxInitialized')
+                .then(() => {
+                    const controlbox = _converse.chatboxes.get('controlbox');
+                    controlbox.set({ 'active-form': value });
+                })
+                .catch(e => log.fatal(e));
+        }
+        _converse.router.route('converse/login', () => setActiveForm('login'));
+        _converse.router.route('converse/register', () => setActiveForm('register'));
+
+
+        api.listen.on('controlBoxInitialized', view => {
+            view.model.on('change:active-form', view.showLoginOrRegisterForm, view);
+        });
+    }
+});

+ 536 - 0
src/plugins/register/panel.js

@@ -0,0 +1,536 @@
+import log from "@converse/headless/log";
+import tpl_form_input from "templates/form_input.js";
+import tpl_form_url from "templates/form_url.js";
+import tpl_form_username from "templates/form_username.js";
+import tpl_register_panel from "./templates/register_panel.html";
+import tpl_registration_form from "./templates/registration_form.js";
+import tpl_registration_request from "./templates/registration_request.html";
+import tpl_spinner from "templates/spinner.js";
+import utils from "@converse/headless/utils/form";
+import { View } from "@converse/skeletor/src/view";
+import { __ } from 'i18n';
+import { _converse, api, converse } from "@converse/headless/core";
+import { pick } from "lodash-es";
+import { render } from 'lit-html';
+
+// Strophe methods for building stanzas
+const { Strophe, sizzle, $iq } = converse.env;
+const u = converse.env.utils;
+
+
+/**
+ * @class
+ * @namespace _converse.RegisterPanel
+ * @memberOf _converse
+ */
+const RegisterPanel = View.extend({
+    tagName: 'div',
+    id: "converse-register-panel",
+    className: 'controlbox-pane fade-in',
+    events: {
+        'submit form#converse-register': 'onFormSubmission',
+        'click .button-cancel': 'renderProviderChoiceForm',
+    },
+
+    initialize () {
+        this.reset();
+        api.listen.on('connectionInitialized', () => this.registerHooks());
+    },
+
+    render () {
+        this.model.set('registration_form_rendered', false);
+        this.el.innerHTML = tpl_register_panel({
+            '__': __,
+            'default_domain': api.settings.get('registration_domain'),
+            'label_register': __('Fetch registration form'),
+            'help_providers': __('Tip: A list of public XMPP providers is available'),
+            'help_providers_link': __('here'),
+            'href_providers': api.settings.get('providers_link'),
+            'domain_placeholder': api.settings.get('domain_placeholder')
+        });
+        if (api.settings.get('registration_domain')) {
+            this.fetchRegistrationForm(api.settings.get('registration_domain'));
+        }
+        return this;
+    },
+
+    registerHooks () {
+        /* Hook into Strophe's _connect_cb, so that we can send an IQ
+         * requesting the registration fields.
+         */
+        const conn = _converse.connection;
+        const connect_cb = conn._connect_cb.bind(conn);
+        conn._connect_cb = (req, callback, raw) => {
+            if (!this._registering) {
+                connect_cb(req, callback, raw);
+            } else {
+                if (this.getRegistrationFields(req, callback)) {
+                    this._registering = false;
+                }
+            }
+        };
+    },
+
+    /**
+     * Send an IQ stanza to the XMPP server asking for the registration fields.
+     * @private
+     * @method _converse.RegisterPanel#getRegistrationFields
+     * @param { Strophe.Request } req - The current request
+     * @param { Function } callback - The callback function
+     */
+    getRegistrationFields (req, _callback) {
+        const conn = _converse.connection;
+        conn.connected = true;
+
+        const body = conn._proto._reqToData(req);
+        if (!body) { return; }
+        if (conn._proto._connect_cb(body) === Strophe.Status.CONNFAIL) {
+            this.showValidationError(
+                __("Sorry, we're unable to connect to your chosen provider.")
+            );
+            return false;
+        }
+        const register = body.getElementsByTagName("register");
+        const mechanisms = body.getElementsByTagName("mechanism");
+        if (register.length === 0 && mechanisms.length === 0) {
+            conn._proto._no_auth_received(_callback);
+            return false;
+        }
+        if (register.length === 0) {
+            conn._changeConnectStatus(Strophe.Status.REGIFAIL);
+            this.showValidationError(
+                __("Sorry, the given provider does not support in "+
+                   "band account registration. Please try with a "+
+                   "different provider."))
+            return true;
+        }
+        // Send an IQ stanza to get all required data fields
+        conn._addSysHandler(this.onRegistrationFields.bind(this), null, "iq", null, null);
+        const stanza = $iq({type: "get"}).c("query", {xmlns: Strophe.NS.REGISTER}).tree();
+        stanza.setAttribute("id", conn.getUniqueId("sendIQ"));
+        conn.send(stanza);
+        conn.connected = false;
+        return true;
+    },
+
+    /**
+     * Handler for {@link _converse.RegisterPanel#getRegistrationFields}
+     * @private
+     * @method _converse.RegisterPanel#onRegistrationFields
+     * @param { XMLElement } stanza - The query stanza.
+     */
+    onRegistrationFields (stanza) {
+        if (stanza.getAttribute("type") === "error") {
+            _converse.connection._changeConnectStatus(
+                Strophe.Status.REGIFAIL,
+                __('Something went wrong while establishing a connection with "%1$s". '+
+                   'Are you sure it exists?', this.domain)
+            );
+            return false;
+        }
+        if (stanza.getElementsByTagName("query").length !== 1) {
+            _converse.connection._changeConnectStatus(
+                Strophe.Status.REGIFAIL,
+                "unknown"
+            );
+            return false;
+        }
+        this.setFields(stanza);
+        if (!this.model.get('registration_form_rendered')) {
+            this.renderRegistrationForm(stanza);
+        }
+        return false;
+    },
+
+    reset (settings) {
+        const defaults = {
+            fields: {},
+            urls: [],
+            title: "",
+            instructions: "",
+            registered: false,
+            _registering: false,
+            domain: null,
+            form_type: null
+        };
+        Object.assign(this, defaults);
+        if (settings) {
+            Object.assign(this, pick(settings, Object.keys(defaults)));
+        }
+    },
+
+    /**
+     * Event handler when the #converse-register form is submitted.
+     * Depending on the available input fields, we delegate to other methods.
+     * @private
+     * @param { Event } ev
+     */
+    onFormSubmission (ev) {
+        if (ev && ev.preventDefault) { ev.preventDefault(); }
+        if (ev.target.querySelector('input[name=domain]') === null) {
+            this.submitRegistrationForm(ev.target);
+        } else {
+            this.onProviderChosen(ev.target);
+        }
+
+    },
+
+    /**
+     * Callback method that gets called when the user has chosen an XMPP provider
+     * @private
+     * @method _converse.RegisterPanel#onProviderChosen
+     * @param { HTMLElement } form - The form that was submitted
+     */
+    onProviderChosen (form) {
+        const domain_input = form.querySelector('input[name=domain]'),
+            domain = domain_input?.value;
+        if (!domain) {
+            // TODO: add validation message
+            domain_input.classList.add('error');
+            return;
+        }
+        form.querySelector('input[type=submit]').classList.add('hidden');
+        this.fetchRegistrationForm(domain.trim());
+    },
+
+    /**
+     * Fetch a registration form from the requested domain
+     * @private
+     * @method _converse.RegisterPanel#fetchRegistrationForm
+     * @param { String } domain_name - XMPP server domain
+     */
+    async fetchRegistrationForm (domain_name) {
+        if (!this.model.get('registration_form_rendered')) {
+            this.renderRegistrationRequest();
+        }
+        this.reset({
+            'domain': Strophe.getDomainFromJid(domain_name),
+            '_registering': true
+        });
+        await _converse.initConnection(this.domain);
+        // When testing, the test tears down before the async function
+        // above finishes. So we use optional chaining here
+        _converse.connection?.connect(this.domain, "", status => this.onConnectStatusChanged(status));
+        return false;
+    },
+
+    /**
+     * Clear the form and inform the user that the registration
+     * form is being fetched.
+     * @private
+     */
+    renderRegistrationRequest () {
+        this.clearRegistrationForm().insertAdjacentHTML(
+            'beforeend',
+            tpl_registration_request({
+                '__': _converse.__,
+                'cancel': api.settings.get('registration_domain'),
+            })
+        );
+    },
+
+    giveFeedback (message, klass) {
+        let feedback = this.el.querySelector('.reg-feedback');
+        if (feedback !== null) {
+            feedback.parentNode.removeChild(feedback);
+        }
+        const form = this.el.querySelector('form');
+        form.insertAdjacentHTML('afterbegin', '<span class="reg-feedback"></span>');
+        feedback = form.querySelector('.reg-feedback');
+        feedback.textContent = message;
+        if (klass) {
+            feedback.classList.add(klass);
+        }
+    },
+
+    clearRegistrationForm () {
+        const form = this.el.querySelector('form');
+        form.innerHTML = '';
+        this.model.set('registration_form_rendered', false);
+        return form;
+    },
+
+    showSpinner () {
+        const form = this.el.querySelector('form');
+        render(tpl_spinner(), form);
+        this.model.set('registration_form_rendered', false);
+        return this;
+    },
+
+    /**
+     * Callback function called by Strophe whenever the connection status changes.
+     * Passed to Strophe specifically during a registration attempt.
+     * @private
+     * @method _converse.RegisterPanel#onConnectStatusChanged
+     * @param { integer } status_code - The Strophe.Status status code
+     */
+    onConnectStatusChanged(status_code) {
+        log.debug('converse-register: onConnectStatusChanged');
+        if ([Strophe.Status.DISCONNECTED,
+             Strophe.Status.CONNFAIL,
+             Strophe.Status.REGIFAIL,
+             Strophe.Status.NOTACCEPTABLE,
+             Strophe.Status.CONFLICT
+            ].includes(status_code)) {
+
+            log.error(
+                `Problem during registration: Strophe.Status is ${_converse.CONNECTION_STATUS[status_code]}`
+            );
+            this.abortRegistration();
+        } else if (status_code === Strophe.Status.REGISTERED) {
+            log.debug("Registered successfully.");
+            _converse.connection.reset();
+            this.showSpinner();
+
+            if (["converse/login", "converse/register"].includes(_converse.router.history.getFragment())) {
+                _converse.router.navigate('', {'replace': true});
+            }
+
+            if (this.fields.password && this.fields.username) {
+                // automatically log the user in
+                _converse.connection.connect(
+                    this.fields.username.toLowerCase()+'@'+this.domain.toLowerCase(),
+                    this.fields.password,
+                    _converse.onConnectStatusChanged
+                );
+                this.giveFeedback(__('Now logging you in'), 'info');
+            } else {
+                _converse.chatboxviews.get('controlbox').renderLoginPanel();
+                _converse.giveFeedback(__('Registered successfully'));
+            }
+            this.reset();
+        }
+    },
+
+    getLegacyFormFields () {
+        const input_fields = Object.keys(this.fields).map(key => {
+            if (key === "username") {
+                return tpl_form_username({
+                    'domain': ` @${this.domain}`,
+                    'name': key,
+                    'type': "text",
+                    'label': key,
+                    'value': '',
+                    'required': true
+                });
+            } else {
+                return tpl_form_input({
+                    'label': key,
+                    'name': key,
+                    'placeholder': key,
+                    'required': true,
+                    'type': (key === 'password' || key === 'email') ? key : "text",
+                    'value': ''
+                })
+            }
+        });
+        const urls = this.urls.map(u => tpl_form_url({'label': '', 'value': u}));
+        return [...input_fields, ...urls];
+    },
+
+    getFormFields (stanza) {
+        if (this.form_type === 'xform') {
+            return Array.from(stanza.querySelectorAll('field')).map(field =>
+                utils.xForm2TemplateResult(field, stanza, {'domain': this.domain})
+            );
+        } else {
+            return this.getLegacyFormFields();
+        }
+    },
+
+    /**
+     * Renders the registration form based on the XForm fields
+     * received from the XMPP server.
+     * @private
+     * @method _converse.RegisterPanel#renderRegistrationForm
+     * @param { XMLElement } stanza - The IQ stanza received from the XMPP server.
+     */
+    renderRegistrationForm (stanza) {
+        const form = this.el.querySelector('form');
+        const tpl = tpl_registration_form({
+            'domain': this.domain,
+            'title': this.title,
+            'instructions': this.instructions,
+            'fields': this.fields,
+            'form_fields': this.getFormFields(stanza)
+        });
+        render(tpl, form);
+        form.classList.remove('hidden');
+        this.model.set('registration_form_rendered', true);
+    },
+
+    showValidationError (message) {
+        const form = this.el.querySelector('form');
+        let flash = form.querySelector('.form-errors');
+        if (flash === null) {
+            flash = '<div class="form-errors hidden"></div>';
+            const instructions = form.querySelector('p.instructions');
+            if (instructions === null) {
+                form.insertAdjacentHTML('afterbegin', flash);
+            } else {
+                instructions.insertAdjacentHTML('afterend', flash);
+            }
+            flash = form.querySelector('.form-errors');
+        } else {
+            flash.innerHTML = '';
+        }
+        flash.insertAdjacentHTML(
+            'beforeend',
+            '<p class="form-help error">'+message+'</p>'
+        );
+        flash.classList.remove('hidden');
+    },
+
+    /**
+     * Report back to the user any error messages received from the
+     * XMPP server after attempted registration.
+     * @private
+     * @method _converse.RegisterPanel#reportErrors
+     * @param { XMLElement } stanza - The IQ stanza received from the XMPP server
+     */
+    reportErrors (stanza) {
+        const errors = stanza.querySelectorAll('error');
+        errors.forEach(e => this.showValidationError(e.textContent));
+        if (!errors.length) {
+            const message = __('The provider rejected your registration attempt. '+
+                'Please check the values you entered for correctness.');
+            this.showValidationError(message);
+        }
+    },
+
+    renderProviderChoiceForm (ev) {
+        if (ev && ev.preventDefault) { ev.preventDefault(); }
+        _converse.connection._proto._abortAllRequests();
+        _converse.connection.reset();
+        this.render();
+    },
+
+    abortRegistration () {
+        _converse.connection._proto._abortAllRequests();
+        _converse.connection.reset();
+        if (this.model.get('registration_form_rendered')) {
+            if (api.settings.get('registration_domain') && this.model.get('registration_form_rendered')) {
+                this.fetchRegistrationForm(
+                    api.settings.get('registration_domain')
+                );
+            }
+        } else {
+            this.render();
+        }
+    },
+
+    /**
+     * Handler, when the user submits the registration form.
+     * Provides form error feedback or starts the registration process.
+     * @private
+     * @method _converse.RegisterPanel#submitRegistrationForm
+     * @param { HTMLElement } form - The HTML form that was submitted
+     */
+    submitRegistrationForm (form) {
+        const has_empty_inputs = Array.from(this.el.querySelectorAll('input.required'))
+            .reduce((result, input) => {
+                if (input.value === '') {
+                    input.classList.add('error');
+                    return result + 1;
+                }
+                return result;
+            }, 0);
+        if (has_empty_inputs) { return; }
+
+        const inputs = sizzle(':input:not([type=button]):not([type=submit])', form);
+        const iq = $iq({'type': 'set', 'id': u.getUniqueId()})
+                    .c("query", {xmlns:Strophe.NS.REGISTER});
+
+        if (this.form_type === 'xform') {
+            iq.c("x", {xmlns: Strophe.NS.XFORM, type: 'submit'});
+
+            const xml_nodes = inputs.map(i => utils.webForm2xForm(i)).filter(n => n);
+            xml_nodes.forEach(n => iq.cnode(n).up());
+        } else {
+            inputs.forEach(input => iq.c(input.getAttribute('name'), {}, input.value));
+        }
+        _converse.connection._addSysHandler(this._onRegisterIQ.bind(this), null, "iq", null, null);
+        _converse.connection.send(iq);
+        this.setFields(iq.tree());
+    },
+
+    /* Stores the values that will be sent to the XMPP server during attempted registration.
+     * @private
+     * @method _converse.RegisterPanel#setFields
+     * @param { XMLElement } stanza - the IQ stanza that will be sent to the XMPP server.
+     */
+    setFields (stanza) {
+        const query = stanza.querySelector('query');
+        const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query);
+        if (xform.length > 0) {
+            this._setFieldsFromXForm(xform.pop());
+        } else {
+            this._setFieldsFromLegacy(query);
+        }
+    },
+
+    _setFieldsFromLegacy (query) {
+        [].forEach.call(query.children, field => {
+            if (field.tagName.toLowerCase() === 'instructions') {
+                this.instructions = Strophe.getText(field);
+                return;
+            } else if (field.tagName.toLowerCase() === 'x') {
+                if (field.getAttribute('xmlns') === 'jabber:x:oob') {
+                    this.urls.concat(sizzle('url', field).map(u => u.textContent));
+                }
+                return;
+            }
+            this.fields[field.tagName.toLowerCase()] = Strophe.getText(field);
+        });
+        this.form_type = 'legacy';
+    },
+
+    _setFieldsFromXForm (xform) {
+        this.title = xform.querySelector('title')?.textContent;
+        this.instructions = xform.querySelector('instructions')?.textContent;
+        xform.querySelectorAll('field').forEach(field => {
+            const _var = field.getAttribute('var');
+            if (_var) {
+                this.fields[_var.toLowerCase()] = field.querySelector('value')?.textContent ?? '';
+            } else {
+                // TODO: other option seems to be type="fixed"
+                log.warn("Found field we couldn't parse");
+            }
+        });
+        this.form_type = 'xform';
+    },
+
+    /**
+     * Callback method that gets called when a return IQ stanza
+     * is received from the XMPP server, after attempting to
+     * register a new user.
+     * @private
+     * @method _converse.RegisterPanel#reportErrors
+     * @param { XMLElement } stanza - The IQ stanza.
+     */
+    _onRegisterIQ (stanza) {
+        if (stanza.getAttribute("type") === "error") {
+            log.error("Registration failed.");
+            this.reportErrors(stanza);
+
+            let error = stanza.getElementsByTagName("error");
+            if (error.length !== 1) {
+                _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, "unknown");
+                return false;
+            }
+            error = error[0].firstElementChild.tagName.toLowerCase();
+            if (error === 'conflict') {
+                _converse.connection._changeConnectStatus(Strophe.Status.CONFLICT, error);
+            } else if (error === 'not-acceptable') {
+                _converse.connection._changeConnectStatus(Strophe.Status.NOTACCEPTABLE, error);
+            } else {
+                _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, error);
+            }
+        } else {
+            _converse.connection._changeConnectStatus(Strophe.Status.REGISTERED, null);
+        }
+        return false;
+    }
+});
+
+export default RegisterPanel;

+ 0 - 0
src/templates/register_panel.html → src/plugins/register/templates/register_panel.html


+ 0 - 0
src/templates/registration_form.js → src/plugins/register/templates/registration_form.js


+ 0 - 0
src/templates/registration_request.html → src/plugins/register/templates/registration_request.html