Ver Fonte

Update and clean up password-reset feature

- No need for it to be in a separate plugin
- Make all UI strings translateable
- Remove the "Are you sure?" prompt
- Add tests

Fixes #326
JC Brand há 2 anos atrás
pai
commit
5f38a914b1

+ 3 - 2
CHANGES.md

@@ -2,8 +2,9 @@
 
 ## Unreleased
 
-- #2816 Chat highlight behaves odd
-- #2925 File upload is not always enabled
+- #326: Add the ability to reset your password
+- #2816: Chat highlight behaves odd
+- #2925: File upload is not always enabled
 - Add a "Add to Contacts" button in MUC occupant modals
 
 ## 10.0.0 (2022-10-30)

+ 1 - 0
karma.conf.js

@@ -103,6 +103,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/omemo/tests/media-sharing.js", type: 'module' },
       { pattern: "src/plugins/omemo/tests/muc.js", type: 'module' },
       { pattern: "src/plugins/omemo/tests/omemo.js", type: 'module' },
+      { pattern: "src/plugins/profile/tests/password-reset.js", type: 'module' },
       { pattern: "src/plugins/push/tests/push.js", type: 'module' },
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
       { pattern: "src/plugins/roomslist/tests/roomslist.js", type: 'module' },

+ 1 - 1
src/plugins/controlbox/tests/controlbox.js

@@ -120,7 +120,7 @@ describe("The Controlbox", function () {
                 mock.initConverse([], {}, async function (_converse) {
 
             await mock.openControlBox(_converse);
-            var cbview = _converse.chatboxviews.get('controlbox');
+            const cbview = _converse.chatboxviews.get('controlbox');
             cbview.querySelector('.change-status').click()
             const modal = _converse.api.modal.get('converse-chat-status-modal');
             await u.waitUntil(() => u.isVisible(modal), 1000);

+ 2 - 1
src/plugins/omemo/profile.js

@@ -1,5 +1,6 @@
 import log from '@converse/headless/log';
 import tpl_profile from './templates/profile.js';
+import tpl_spinner from "templates/spinner.js";
 import { CustomElement } from 'shared/components/element.js';
 import { __ } from 'i18n';
 import { _converse, api, converse } from "@converse/headless/core";
@@ -27,7 +28,7 @@ export class Profile extends CustomElement {
     }
 
     render () {
-        return this.devicelist ? tpl_profile(this) : '';
+        return this.devicelist ? tpl_profile(this) : tpl_spinner();
     }
 
     selectAll (ev) {  // eslint-disable-line class-methods-use-this

+ 0 - 131
src/plugins/password-reset/index.js

@@ -1,131 +0,0 @@
-/**
- * @copyright The Converse.js contributors
- * @license Mozilla Public License (MPLv2)
- */
-import 'modals/user-details.js';
-import 'plugins/profile/index.js';
-import BaseModal from "plugins/modal/base.js";
-import { modal_close_button } from "plugins/modal/templates/buttons.js";
-import log from '@converse/headless/log';
-import { CustomElement } from 'shared/components/element.js';
-import { html } from "lit";
-import { _converse, api, converse } from '@converse/headless/core';
-const u = converse.env.utils;
-
-const { Strophe, $iq } = converse.env;
-
-
-converse.plugins.add('converse-passwordreset', {
-    enabled (_converse) {
-        return (
-            !_converse.api.settings.get('blacklisted_plugins').includes('converse-changepassword')
-        );
-    },
-
-    dependencies: [],
-
-
-    initialize () {
-    }
-});
-
-const password_match_error = (el) => {
-	return html`
-		<span class='error'>The two passwords entered must match.</span>
-	`
-}
-
-const confirm_sure = (el) => {
-	return html`
-		<span>Are you sure?</span>
-	`
-}
-
-class Profile extends CustomElement {
-
-	async initialize () {
-		this.confirmation_active = false;
-		this.passwords_mismatched = false;
-	}
-
-
-        render () {
-                return ((el) => {
-                        return html`
-                        <form class="converse-form passwordreset-form" @submit=${el.onSubmit}>
-			    <fieldset class="form-group">
-				<label for="converse_passwordreset_password">Password</label>
-				<input class="form-control" type="password" value="" name="password" required="required" id="converse_passwordreset_password">
-				<label for="converse_passwordreset_password_check">Re-type Password</label>
-				<input class="form-control" type="password" value="" name="password_check" @input=${el.checkPasswordsMatch} required="required" id="converse_passwordreset_password_check">
-                                ${(el.passwords_mismatched) ? password_match_error(el) : ''}
-			    </fieldset>
-			    ${(el.confirmation_active) ? confirm_sure(el) : ''}
-			    ${modal_close_button}
-		            <input class="save-form btn btn-primary" type="submit" value=${(this.confirmation_active) ? "I'm sure." : "Submit"}>
-			</form>`;
-		})(this);
-
-	}
-
-	async checkPasswordsMatch (ev) {
-		let form_data = new FormData(ev.target.form);
-		let password = form_data.get('password');
-		let password_check = form_data.get("password_check");
-
-		if (password != password_check) {
-			this.passwords_mismatched = true;
-			this.confirmation_active = false;
-		} else {
-			this.passwords_mismatched = false;
-		}
-		this.requestUpdate();
-	}
-
-	async onSubmit (ev) {
-		ev.preventDefault();
-
-		let password = new FormData(ev.target).get('password');
-		let password_check = new FormData(ev.target).get("password_check");
-
-		if (password === password_check) {
-			if (this.confirmation_active) {
-				await this.postNewInfo(password);
-				this.confirmation_active = false;
-			} else {
-				this.confirmation_active = true;
-			}
-		} else {
-			this.passwords_mismatched = true;
-			this.confirmation_active = false;
-		}
-		this.requestUpdate();
-
-	}
-
-	async postNewInfo (password) {
-		let domain = Strophe.getDomainFromJid(_converse.bare_jid);
-		let iq = $iq({ 'type': 'get', 'to': domain })
-			    .c('query', { 'xmlns': 'jabber:iq:register' });
-		let response = await _converse.api.sendIQ(iq);
-		let username = response.querySelector("username").innerHTML;
-
-		let resetiq = $iq({ 'type': 'set', 'to': domain })
-				 .c('query', { 'xmlns': 'jabber:iq:register' })
-				 .c('username', {}, username)
-				 .c('password', {}, password)
-
-		let iq_result = await _converse.api.sendIQ(resetiq);
-                if (iq_result  === null) {
-		        api.alert('info', "Password reset failed.", ["Timeout on password reset. Check your connection?"]);
-                } else if (u.isErrorStanza(iq_result)) {
-                        api.alert('info', "Permission Denied.", ["Either your former password was incorrect, or you may not change your password."]);
-                } else {
-		        api.alert('info', "Password reset.", ["Your password has been reset."]);
-                }
-	}
-
-}
-
-api.elements.define("converse-changepassword-profile", Profile);
-

+ 1 - 3
src/plugins/profile/index.js

@@ -21,8 +21,6 @@ converse.plugins.add('converse-profile', {
     ],
 
     initialize () {
-        api.settings.extend({
-            'show_client_info': true,
-        });
+        api.settings.extend({ 'show_client_info': true });
     },
 });

+ 1 - 0
src/plugins/profile/modals/profile.js

@@ -4,6 +4,7 @@ import tpl_profile_modal from "../templates/profile_modal.js";
 import Compress from 'client-compress';
 import { __ } from 'i18n';
 import { _converse, api } from "@converse/headless/core";
+import '../password-reset.js';
 
 const compress = new Compress({
     targetSize: 0.1,

+ 80 - 0
src/plugins/profile/password-reset.js

@@ -0,0 +1,80 @@
+import tpl_password_reset from './templates/password-reset.js';
+import { CustomElement } from 'shared/components/element.js';
+import { __ } from 'i18n';
+import { _converse, api, converse } from '@converse/headless/core';
+
+const { Strophe, $iq, sizzle, u } = converse.env;
+
+
+class PasswordReset extends CustomElement {
+
+    static get properties () {
+        return {
+            passwords_mismatched: { type: Boolean },
+            alert_message: { type: String }
+        }
+    }
+
+    initialize () {
+        this.passwords_mismatched = false;
+        this.alert_message = '';
+    }
+
+    render () {
+        return tpl_password_reset(this);
+    }
+
+    checkPasswordsMatch (ev) {
+        const form_data = new FormData(ev.target.form ?? ev.target);
+        const password = form_data.get('password');
+        const password_check = form_data.get('password_check');
+
+        this.passwords_mismatched = password && password !== password_check;
+        return this.passwords_mismatched
+    }
+
+    async onSubmit (ev) {
+        ev.preventDefault();
+
+        if (this.checkPasswordsMatch(ev)) return;
+
+        const iq = $iq({ 'type': 'get', 'to': _converse.domain }).c('query', { 'xmlns': Strophe.NS.REGISTER });
+        const iq_response = await api.sendIQ(iq);
+
+        if (iq_response === null) {
+            this.alert_message = __('Timeout error');
+            return;
+        } else if (sizzle(`error service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, iq_response).length) {
+            this.alert_message = __('Your server does not support in-band password reset');
+            return;
+        } else if (u.isErrorStanza(iq_response)) {
+            this.alert_message = __('Your server responded with an unknown error');
+            return;
+        }
+
+        const username = iq_response.querySelector('username').textContent;
+
+        const data = new FormData(ev.target);
+        const password = data.get('password');
+
+        const reset_iq = $iq({ 'type': 'set', 'to': _converse.domain })
+            .c('query', { 'xmlns': Strophe.NS.REGISTER })
+                .c('username', {}, username)
+                .c('password', {}, password);
+
+        const iq_result = await api.sendIQ(reset_iq);
+        if (iq_result === null) {
+            this.alert_message = __('Timeout error while trying to set your password');
+        } else if (sizzle(`error not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, iq_result).length) {
+            this.alert_message = __('Your server does not allow in-band password reset');
+        } else if (sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, iq_result).length) {
+            this.alert_message = __('You are not allowed to change your password');
+        } else if (u.isErrorStanza(iq_result)) {
+            this.alert_message = __('You are not allowed to change your password');
+        } else {
+            api.alert('info', __('Successful'), [__('Your new password has been set')]);
+        }
+    }
+}
+
+api.elements.define('converse-change-password-form', PasswordReset);

+ 49 - 0
src/plugins/profile/templates/password-reset.js

@@ -0,0 +1,49 @@
+import { __ } from 'i18n';
+import { html } from 'lit';
+
+export default el => {
+    const i18n_submit = __('Submit');
+    const i18n_passwords_must_match = __('The new passwords must match');
+    const i18n_new_password = __('New password');
+    const i18n_confirm_password = __('Confirm new password');
+
+    return html`<form class="converse-form passwordreset-form" method="POST" @submit=${ev => el.onSubmit(ev)}>
+        ${el.alert_message ? html`<div class="alert alert-danger" role="alert">${el.alert_message}</div>` : ''}
+
+        <div class="form-group">
+            <label for="converse_password_reset_new">${i18n_new_password}</label>
+            <input
+                class="form-control ${el.passwords_mismatched ? 'error' : ''}"
+                type="password"
+                value=""
+                name="password"
+                required="required"
+                id="converse_password_reset_new"
+                autocomplete="new-password"
+                minlength="8"
+                ?disabled="${el.alert_message}"
+            />
+        </div>
+        <div class="form-group">
+            <label for="converse_password_reset_check">${i18n_confirm_password}</label>
+            <input
+                class="form-control ${el.passwords_mismatched ? 'error' : ''}"
+                type="password"
+                value=""
+                name="password_check"
+                required="required"
+                id="converse_password_reset_check"
+                autocomplete="new-password"
+                minlength="8"
+                ?disabled="${el.alert_message}"
+                @input=${ev => el.checkPasswordsMatch(ev)}
+            />
+            ${el.passwords_mismatched ? html`<span class="error">${i18n_passwords_must_match}</span>` : ''}
+        </div>
+
+        <input class="save-form btn btn-primary"
+               type="submit"
+               value=${i18n_submit}
+               ?disabled="${el.alert_message}" />
+    </form>`;
+};

+ 31 - 29
src/plugins/profile/templates/profile_modal.js

@@ -4,14 +4,9 @@ import { _converse } from  "@converse/headless/core";
 import { html } from "lit";
 
 
-const passwordreset_page = () => html`
-    <div class="tab-pane" id="passwordreset-tabpanel" role="tabpanel" aria-labelledby="passwordreset-tab">
-        <converse-changepassword-profile></converse-changepassword-profile>
-    </div>`;
-
-const omemo_page = (el) => html`
+const tpl_omemo_page = (el) => html`
     <div class="tab-pane ${ el.tab === 'omemo' ? 'active' : ''}" id="omemo-tabpanel" role="tabpanel" aria-labelledby="omemo-tab">
-        <converse-omemo-profile></converse-omemo-profile>
+        ${ el.tab === 'omemo' ? html`<converse-omemo-profile></converse-omemo-profile>` : '' }
     </div>`;
 
 
@@ -28,48 +23,51 @@ export default (el) => {
 
     const i18n_omemo = __('OMEMO');
     const i18n_profile = __('Profile');
+    const ii18n_reset_password = __('Reset Password');
 
     const navigation_tabs = [
         html`<li role="presentation" class="nav-item">
-            <a class="nav-link active"
+            <a class="nav-link ${el.tab === "profile" ? "active" : ""}"
                id="profile-tab"
                href="#profile-tabpanel"
                aria-controls="profile-tabpanel"
                role="tab"
-               data-toggle="tab">${i18n_profile}</a>
+               @click=${ev => el.switchTab(ev)}
+               data-name="profile"
+               data-toggle="tab">${ i18n_profile }</a>
             </li>`
     ];
 
-    if (_converse.pluggable.plugins['converse-passwordreset']?.enabled(_converse)) {
-        navigation_tabs.push(
-            html`<li role="presentation" class="nav-item">
-                <a class="nav-link"
-                   id="passwordreset-tab"
-                   href="#passwordreset-tabpanel"
-                   aria-controls="passwordreset-tabpanel"
-                   role="tab"
-                   data-toggle="tab">Reset Password</a>
-            </li>`
-        );
-    }
+    navigation_tabs.push(
+        html`<li role="presentation" class="nav-item">
+                <a class="nav-link ${el.tab === "passwordreset" ? "active" : ""}"
+                id="passwordreset-tab"
+                href="#passwordreset-tabpanel"
+                aria-controls="passwordreset-tabpanel"
+                role="tab"
+                @click=${ev => el.switchTab(ev)}
+                data-name="passwordreset"
+                data-toggle="tab">${ ii18n_reset_password }</a>
+        </li>`
+    );
 
     if (_converse.pluggable.plugins['converse-omemo']?.enabled(_converse)) {
         navigation_tabs.push(
             html`<li role="presentation" class="nav-item">
-                <a class="nav-link"
+                <a class="nav-link ${el.tab === "omemo" ? "active" : ""}"
                    id="omemo-tab"
                    href="#omemo-tabpanel"
                    aria-controls="omemo-tabpanel"
-                   role="tab" data-toggle="tab">${i18n_omemo}</a>
+                   role="tab"
+                   @click=${ev => el.switchTab(ev)}
+                   data-name="omemo"
+                   data-toggle="tab">${ i18n_omemo }</a>
             </li>`
         );
     }
 
-    // Don't display any navigation tabs if only the profile tab is available
-    const navigation = ((navigation_tabs.length == 1) ? html`` : html`<ul class="nav nav-pills justify-content-center">${navigation_tabs}</ul>`);
-
     return html`
-        ${navigation}
+        <ul class="nav nav-pills justify-content-center">${navigation_tabs}</ul>
         <div class="tab-content">
             <div class="tab-pane ${ el.tab === 'profile' ? 'active' : ''}" id="profile-tabpanel" role="tabpanel" aria-labelledby="profile-tab">
                 <form class="converse-form converse-form--modal profile-form" action="#" @submit=${ev => el.onFormSubmitted(ev)}>
@@ -111,8 +109,12 @@ export default (el) => {
                     </div>
                 </form>
             </div>
-            ${ _converse.pluggable.plugins['converse-passwordreset']?.enabled(_converse) ? passwordreset_page() : '' }
-            ${ _converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? omemo_page() : '' }
+
+            <div class="tab-pane ${ el.tab === 'passwordreset' ? 'active' : ''}" id="passwordreset-tabpanel" role="tabpanel" aria-labelledby="passwordreset-tab">
+                ${ el.tab === 'passwordreset' ? html`<converse-change-password-form></converse-change-password-form>` : '' }
+            </div>
+
+            ${ _converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? tpl_omemo_page(el) : '' }
         </div>
     </div>`;
 }

+ 158 - 0
src/plugins/profile/tests/password-reset.js

@@ -0,0 +1,158 @@
+/*global mock, converse */
+
+const { Strophe, u } = converse.env;
+
+async function submitPasswordResetForm (_converse) {
+    await mock.openControlBox(_converse);
+    const cbview = _converse.chatboxviews.get('controlbox');
+    cbview.querySelector('a.show-profile')?.click();
+    const modal = _converse.api.modal.get('converse-profile-modal');
+    await u.waitUntil(() => u.isVisible(modal));
+
+    modal.querySelector('#passwordreset-tab').click();
+    const form = await u.waitUntil(() => modal.querySelector('.passwordreset-form'));
+
+    const pw_input = form.querySelector('input[name="password"]');
+    pw_input.value = 'secret-password';
+    const pw_check_input = form.querySelector('input[name="password_check"]');
+    pw_check_input.value = 'secret-password';
+    form.querySelector('input[type="submit"]').click();
+
+    return modal;
+}
+
+
+describe('The profile modal', function () {
+    it(
+        'allows you to reset your password',
+        mock.initConverse([], {}, async function (_converse) {
+            await submitPasswordResetForm(_converse);
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const query_iq = await u.waitUntil(() =>
+                sent_IQs.filter(iq => iq.querySelector('iq[type="get"] query[xmlns="jabber:iq:register"]')).pop()
+            );
+            expect(Strophe.serialize(query_iq)).toBe(
+                `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` +
+                    `<query xmlns="jabber:iq:register"/>` +
+                    `</iq>`
+            );
+
+            _converse.connection._dataRecv(
+                mock.createRequest(
+                    u.toStanza(`
+                    <iq type='result' id='${query_iq.getAttribute('id')}'>
+                        <query xmlns='jabber:iq:register'>
+                            <username>romeo@montague.lit</username>
+                            <password/>
+                        </query>
+                    </iq>`)
+                )
+            );
+
+            const set_iq = await u.waitUntil(() =>
+                sent_IQs.filter(iq => iq.querySelector('iq[type="set"] query[xmlns="jabber:iq:register"]')).pop()
+            );
+            expect(Strophe.serialize(set_iq)).toBe(
+                `<iq id="${set_iq.getAttribute('id')}" to="${_converse.domain}" type="set" xmlns="jabber:client">` +
+                    `<query xmlns="jabber:iq:register">` +
+                    `<username>romeo@montague.lit</username>` +
+                    `<password>secret-password</password>` +
+                    `</query>` +
+                    `</iq>`
+            );
+
+            _converse.connection._dataRecv(
+                mock.createRequest(u.toStanza(`<iq type='result' id='${set_iq.getAttribute('id')}'></iq>`))
+            );
+
+            const alert = await u.waitUntil(() => document.querySelector('converse-alert-modal'));
+            await u.waitUntil(() => u.isVisible(alert));
+            expect(alert.querySelector('.modal-title').textContent).toBe('Successful');
+        })
+    );
+
+    it(
+        'informs you if you cannot reset your password due to in-band registration not being supported',
+        mock.initConverse([], {}, async function (_converse) {
+            const modal = await submitPasswordResetForm(_converse);
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const query_iq = await u.waitUntil(() =>
+                sent_IQs.filter(iq => iq.querySelector('query[xmlns="jabber:iq:register"]')).pop()
+            );
+
+            expect(Strophe.serialize(query_iq)).toBe(
+                `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` +
+                    `<query xmlns="jabber:iq:register"/>` +
+                    `</iq>`
+            );
+
+            _converse.connection._dataRecv(
+                mock.createRequest(
+                    u.toStanza(`
+                <iq type='result' id="${query_iq.getAttribute('id')}">
+                    <error type="cancel"><service-unavailable xmlns="${Strophe.NS.STANZAS}"/></error>
+                </iq>`)
+                )
+            );
+
+            const alert = await u.waitUntil(() => modal.querySelector('.alert-danger'));
+            expect(alert.textContent).toBe('Your server does not support in-band password reset');
+        })
+    );
+
+    it(
+        'informs you if you\'re not allowed to reset your password',
+        mock.initConverse([], {}, async function (_converse) {
+            const modal = await submitPasswordResetForm(_converse);
+
+            const sent_IQs = _converse.connection.IQ_stanzas;
+            const query_iq = await u.waitUntil(() =>
+                sent_IQs.filter(iq => iq.querySelector('query[xmlns="jabber:iq:register"]')).pop()
+            );
+
+            expect(Strophe.serialize(query_iq)).toBe(
+                `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` +
+                    `<query xmlns="jabber:iq:register"/>` +
+                    `</iq>`
+            );
+
+            _converse.connection._dataRecv(
+                mock.createRequest(
+                    u.toStanza(`
+                    <iq type='result' id='${query_iq.getAttribute('id')}'>
+                        <query xmlns='jabber:iq:register'>
+                            <username>romeo@montague.lit</username>
+                            <password/>
+                        </query>
+                    </iq>`)
+                )
+            );
+
+            const set_iq = await u.waitUntil(() =>
+                sent_IQs.filter(iq => iq.querySelector('iq[type="set"] query[xmlns="jabber:iq:register"]')).pop()
+            );
+            expect(Strophe.serialize(set_iq)).toBe(
+                `<iq id="${set_iq.getAttribute('id')}" to="${_converse.domain}" type="set" xmlns="jabber:client">` +
+                    `<query xmlns="jabber:iq:register">` +
+                    `<username>romeo@montague.lit</username>` +
+                    `<password>secret-password</password>` +
+                    `</query>` +
+                    `</iq>`
+            );
+
+            _converse.connection._dataRecv(
+                mock.createRequest(
+                    u.toStanza(`
+                <iq type='result' id="${set_iq.getAttribute('id')}">
+                    <error type="modify"><forbidden xmlns="${Strophe.NS.STANZAS}"/></error>
+                </iq>`)
+                )
+            );
+
+            const alert = await u.waitUntil(() => modal.querySelector('.alert-danger'));
+            expect(alert.textContent).toBe('You are not allowed to change your password');
+        })
+    );
+});

+ 3 - 1
src/plugins/profile/utils.js

@@ -1,5 +1,7 @@
 import { __ } from 'i18n';
-import { api } from '@converse/headless/core';
+import { api, converse, _converse } from '@converse/headless/core';
+
+const { Strophe, $iq, sizzle, u } = converse.env;
 
 export function getPrettyStatus (stat) {
     if (stat === 'chat') {

+ 0 - 1
src/shared/constants.js

@@ -19,7 +19,6 @@ export const VIEW_PLUGINS = [
     'converse-notification',
     'converse-omemo',
     'converse-profile',
-    'converse-passwordreset',
     'converse-push',
     'converse-register',
     'converse-roomslist',