From 5abbf75037cc785d849af35d00721d10003edf2d Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Fri, 31 Jan 2025 14:38:37 +0100 Subject: [PATCH 1/3] GH-351: Added PasswordResetProof.js and handling of email being verified on reset. --- .../init/passwordReset/PasswordReset.js | 56 +++++++-- .../passwordReset/PasswordResetComponent.js | 79 +++++++----- .../init/passwordReset/PasswordResetProof.js | 114 ++++++++++++++++++ .../init/passwordReset/passwordReset.scss | 40 +++++- 4 files changed, 244 insertions(+), 45 deletions(-) create mode 100644 src/hub/reset/modules/init/passwordReset/PasswordResetProof.js diff --git a/src/hub/reset/modules/init/passwordReset/PasswordReset.js b/src/hub/reset/modules/init/passwordReset/PasswordReset.js index 9e33dbf0..fa1f0c01 100644 --- a/src/hub/reset/modules/init/passwordReset/PasswordReset.js +++ b/src/hub/reset/modules/init/passwordReset/PasswordReset.js @@ -42,7 +42,7 @@ class PasswordReset { this.validateCode(this.code) .then(result => { if (result.isValid) { - this._showPasswordReset(this.code); + this._showPasswordReset(this.code, result.requireProof); } else { this._showInvalidCode(); } @@ -54,7 +54,7 @@ class PasswordReset { /** * Validates the reset code. * @param {string} code Code to verify. - * @returns {Promise} Promise of the code being verified. + * @returns {Promise<{isValid: boolean, requireProof: boolean}} Promise of the code being verified. */ validateCode(code) { let formData = new FormData(); @@ -69,7 +69,7 @@ class PasswordReset { if (resp.status >= 400) { return resp.json().then(err => { throw err; - }).catch(err => { + }, () => { throw new Err('passwordReset.failedToVerifyCode', "Code verification failed with status {status}.", { status: resp.status }); }); } @@ -77,11 +77,31 @@ class PasswordReset { }); } - resetPassword(code, pass) { + /** + * Sends a resetPassword request to the server. + * @param {string} code Reset ticket code. + * @param {string} pass New password + * @param {object} [opt] Optional parameters + * @param {string} [opt.username] Username as proof. + * @param {string} [opt.realm] Realm name as proof. + * @param {string} [opt.charName] Char name as proof. + * @returns Promise + */ + resetPassword(code, pass, opt) { let formData = new FormData(); formData.append('code', code); formData.append('pass', sha256(pass.trim())); formData.append('hash', hmacsha256(pass.trim(), publicPepper)); + if (opt?.username) { + formData.append('username', opt?.username.trim()); + } else { + if (opt?.realm) { + formData.append('realm', opt?.realm.trim()); + } + if (opt?.charName) { + formData.append('charName', opt?.charName.trim()); + } + } return fetch(resetUrl, { body: formData, @@ -92,24 +112,38 @@ class PasswordReset { if (resp.status >= 400) { return resp.json().then(err => { throw err; - }).catch(err => { + }, () => { throw new Err('passwordReset.resetFailed', "Reset failed with status {status}.", { status: resp.status }); }); } - this._showResetComplete(); + return resp.json() + .then(result => this._showResetComplete(result.username, result.emailVerified)) + .catch(() => this._showResetComplete()); }); } - _showPasswordReset(code) { - this.module.screen.setComponent(new PasswordResetComponent(this.module, code, this.state, this.params)); + _showPasswordReset(code, requireProof) { + this.module.screen.setComponent(new PasswordResetComponent(this.module, code, requireProof, this.state, this.params)); } - _showResetComplete(email) { + _showResetComplete(username, emailVerified) { this.module.screen.setComponent(new ConfirmScreenDialog({ title: l10n.l('passwordReset.passwordSuccessfullyReset', "Password reset completed"), body: new Elem(n => n.elem('div', [ - n.component(new Txt(l10n.l('passwordReset.passwordUpdateInfo1', "Your password has been updated."), { tagName: 'p' })), - n.component(new Txt(l10n.l('passwordReset.passwordUpdateInfo2', "Go to the login and try it out!"), { tagName: 'p' })), + n.component(new Txt( + emailVerified + ? l10n.l('passwordReset.passwordUpdatedAndVerified', "Your password has been updated, and your email has been verified.") + : l10n.l('passwordReset.passwordUpdated', "Your password has been updated."), + { tagName: 'p' }, + )), + n.component(username + ? new Txt(l10n.l('passwordReset.goToLoginWithAccount', "Go to the login and use it together with your account name:"), { tagName: 'p' }) + : new Txt(l10n.l('passwordReset.goToLoginAndTry', "Go to the login and try it out!"), { tagName: 'p' }), + ), + n.component(username + ? new Txt(username, { tagName: 'p', className: 'passwordreset--username' }) + : null, + ), ])), onConfirm: () => this._redirect(), })); diff --git a/src/hub/reset/modules/init/passwordReset/PasswordResetComponent.js b/src/hub/reset/modules/init/passwordReset/PasswordResetComponent.js index ba06f582..20fe8151 100644 --- a/src/hub/reset/modules/init/passwordReset/PasswordResetComponent.js +++ b/src/hub/reset/modules/init/passwordReset/PasswordResetComponent.js @@ -5,35 +5,53 @@ import l10n from 'modapp-l10n'; import Collapser from 'components/Collapser'; import PasswordInput from 'components/PasswordInput'; import ScreenDialog from 'components/ScreenDialog'; +import PasswordResetProof from './PasswordResetProof'; /** * PasswordReset draws the password reset screen. */ class PasswordResetComponent { - constructor(module, code, state, opt) { + constructor(module, code, requireProof, state, opt) { opt = opt || {}; this.module = module; this.code = code; + this.requireProof = requireProof; this.state = state; state.reset = Object.assign({ + username: '', + realm: '', + charName: '', pass: '', }, state.reset); this.autoLogin = !!opt.hasOwnProperty('auto'); + this.realm = opt.realm || null; } render(el) { this.model = new Model({ data: this.state.reset, eventBus: this.module.self.app.eventBus }); this.elem = new ScreenDialog(new Elem(n => n.elem('div', { className: 'passwordreset' }, [ + n.component(this.requireProof + ? new PasswordResetProof(this.model, this.realm) + : null, + ), + n.component(new Txt( + l10n.l('resetPassword.newPasswordDisclaimer', "Enter a new password for the account."), + { tagName: 'div', className: 'passwordreset--disclaimer' }, + )), n.elem('label', { className: 'flex-1', attributes: { for: 'password' }}, [ n.elem('h3', [ n.component(new Txt(l10n.l('login.newPassword', "New password"))), n.component(new Txt(" *", { className: 'common--error' })), ]), ]), - n.component('password', new PasswordInput(this.model.pass, { + n.component(new PasswordInput(this.model.pass, { className: 'common--formmargin', - inputOpt: { attributes: { name: 'password', id: 'password' }}, + inputOpt: { attributes: { + name: 'password', + id: 'password', + autocomplete: 'new-password', + }}, onInput: c => this.model.set({ pass: c.getValue() }), })), n.component('message', new Collapser(null)), @@ -41,12 +59,12 @@ class PasswordResetComponent { this.model, new Elem(n => n.elem('button', { events: { click: () => this._onReset(this.model) }, - className: 'btn large primary passwordreset--login pad-top-xl passwordreset--btn', + className: 'btn large primary passwordreset--reset pad-top-xl passwordreset--btn', }, [ n.elem('spinner', 'div', { className: 'spinner spinner--btn fade hide' }), n.component(new Txt(l10n.l('passwordReset.resetPassword', "Reset password"))), ])), - (m, c) => c.setProperty('disabled', m && m.pass.trim().length >= 4 ? null : 'disabled'), + (m, c) => c.setProperty('disabled', this._isComplete(m) ? null : 'disabled'), )), ])), { title: l10n.l('passwordReset.resetPassword', "Reset password"), @@ -56,27 +74,12 @@ class PasswordResetComponent { unrender() { if (this.elem) { - this.state.reset = this._getState(); + this.state.reset = Object.assign({}, this.model?.props); this.elem.unrender(); this.elem = null; } } - _getState() { - let c = this.elem.getComponent(); - return { - password: c.getNode('password').getValue(), - }; - } - - _clearState() { - if (this.elem) { - let c = this.elem.getComponent(); - c.getNode('password').setValue(''); - } - this.state.reset = { password: '' }; - } - _setSpinner(show) { if (this.elem) { let cl = this.elem.getComponent().getNode('submit').getComponent().getNode('spinner').classList; @@ -84,18 +87,20 @@ class PasswordResetComponent { } } - _onReset() { - if (!this.elem || this.resetPromise) return; + _onReset(m) { + if (this.resetPromise) return; - let { password } = this._getState(); this._setSpinner(true); - this.resetPromise = this.module.self.resetPassword(this.code, password) - .catch(err => { - this.resetPromise = null; - this._setSpinner(false); - this._setMessage(l10n.l(err.code, err.message, err.data)); - }); + this.resetPromise = this.module.self.resetPassword(this.code, m.pass, this.requireProof && { + username: m.username, + realm: m.realm, + charName: m.charName, + }).catch(err => { + this.resetPromise = null; + this._setSpinner(false); + this._setMessage(l10n.l(err.code, err.message, err.data)); + }); } _setMessage(msg) { @@ -104,6 +109,20 @@ class PasswordResetComponent { n.setComponent(msg ? new Txt(msg, { className: 'passwordreset--message' }) : null); } } + + // Check if the form is filled out completely + _isComplete(m) { + return m && m.pass.trim().length >= 4 && + ( + !this.requireProof || ( + // Proof by username + m.username?.trim().length >= 4 + ) || ( + // Proof by realm and char name + m.realm?.length >= 3 && m.charName.trim().match(/^[^\s]+\s+[^\s]/) + ) + ); + } } export default PasswordResetComponent; diff --git a/src/hub/reset/modules/init/passwordReset/PasswordResetProof.js b/src/hub/reset/modules/init/passwordReset/PasswordResetProof.js new file mode 100644 index 00000000..bbb57764 --- /dev/null +++ b/src/hub/reset/modules/init/passwordReset/PasswordResetProof.js @@ -0,0 +1,114 @@ +import { Elem, Txt, Input } from 'modapp-base-component'; +import { ModelTxt } from 'modapp-resource-component'; +import l10n from 'modapp-l10n'; +import PopupTip from 'components/PopupTip'; + +/** + * PasswordReset draws the password reset proof section. + */ +class PasswordResetProof { + + /** + * Creates an instance of PasswordResetProof. + * @param {Model n.elem('div', { className: 'passwordreset-proof' }, [ + n.component(new Txt(l10n.l('resetPassword.recoverByAccountDisclaimer', "Enter the account name."), { tagName: 'div', className: 'passwordreset--disclaimer' })), + n.elem('div', { className: 'flex-row flex-baseline' }, [ + n.elem('label', { className: 'flex-1', attributes: { for: 'username' }}, [ + n.elem('h3', [ + n.component(new Txt(l10n.l('resetPassword.accountName', "Account name"))), + ]), + ]), + n.component(new PopupTip(l10n.l('resetPassword.accountNameInfo', "Account name is your login name for the account that we are resetting the password for."), { className: 'popuptip--width-m flex-auto' })), + ]), + n.component('player', new Input(this.model.username, { + className: 'common--formmargin autocomplete', + attributes: { spellcheck: 'false', name: 'username', id: 'username', autocomplete: 'username' }, + events: { + input: c => this.model.set({ username: c.getValue() }), + keydown: (c, e) => { + if (e.keyCode == 13 && this.elem) { + this._onRecover(this.model);; + } + }, + }, + })), + n.elem('div', { className: 'passwordreset--divider small-margin' }, [ + n.component(new Txt(l10n.l('resetPassword.or', 'or'), { tagName: 'h3' })), + ]), + n.component(this.realm + ? new Elem(n => n.elem('div', [ + n.elem('div', { className: 'passwordreset--disclaimer' }, [ + n.component(new Txt(l10n.l('resetPassword.recoverByCharacterDisclaimer', "Enter full name of your character in realm:"), { tagName: 'div' })), + n.component(new ModelTxt(this.realm, m => { + this.model.set({ realm: m.key }); + return m.name; + }, { tagName: 'div', className: 'passwordreset-proof--realmname' })), + ]), + ])) + : new Elem(n => n.elem('div', [ + n.component(new Txt( + l10n.l('resetPassword.recoverByRealmAndCharacterDisclaimer', "Enter a realm and character by full name."), + { tagName: 'div', className: 'passwordreset--disclaimer' }, + )), + n.elem('div', { className: 'flex-row flex-baseline' }, [ + n.elem('label', { className: 'flex-1', attributes: { for: 'realm' }}, [ + n.elem('h3', [ + n.component(new Txt(l10n.l('resetPassword.realm', "Realm"))), + ]), + ]), + n.component(new PopupTip(l10n.l('resetPassword.realmInfo', "The name of a realm where you have at least one character."), { className: 'popuptip--width-m flex-auto' })), + ]), + n.component('realm', new Input(this.model.realm, { + className: 'common--formmargin', + attributes: { spellcheck: 'false', name: 'realm', id: 'realm' }, + events: { + input: c => this.model.set({ realm: c.getValue() }), + }, + })), + ])), + ), + n.elem('div', { className: 'flex-row flex-baseline' }, [ + n.elem('label', { className: 'flex-1', attributes: { for: 'username' }}, [ + n.elem('h3', [ + n.component(new Txt(l10n.l('resetPassword.characterName', "Character name"))), + ]), + ]), + n.component(new PopupTip(l10n.l('resetPassword.characterNameInfo', "Full name of one of your characters from the realm."), { className: 'popuptip--width-m flex-auto' })), + ]), + n.component('charName', new Input(this.model.charName, { + className: 'common--formmargin', + attributes: { spellcheck: 'false', name: 'charname', id: 'charname' }, + events: { + input: c => this.model.set({ charName: c.getValue() }), + keydown: (c, e) => { + if (e.keyCode == 13 && this.elem) { + this._onRecover(this.model); + } + }, + }, + })), + n.elem('div', { className: 'passwordreset--divider' }, [ + n.component(new Txt(l10n.l('resetPassword.and', 'and'), { tagName: 'h3' })), + ]), + ])); + this.elem.render(el); + } + + unrender() { + if (this.elem) { + this.elem.unrender(); + this.elem = null; + } + } +} + +export default PasswordResetProof; diff --git a/src/hub/reset/modules/init/passwordReset/passwordReset.scss b/src/hub/reset/modules/init/passwordReset/passwordReset.scss index 48b50590..dba72b16 100644 --- a/src/hub/reset/modules/init/passwordReset/passwordReset.scss +++ b/src/hub/reset/modules/init/passwordReset/passwordReset.scss @@ -2,7 +2,7 @@ .passwordreset { - &--login { + &--reset { margin-top: 22px; position: relative; } @@ -16,9 +16,41 @@ } &--disclaimer { - display: block; - margin-bottom: 20px; - font-size: 16px; + margin-bottom: 12px; + font-size: 13px; font-style: italic; } + + &--username { + margin-top: 20px; + color: $color3; + } + + &--divider { + width: 100%; + height: 2px; + background: $color2; + margin: 32px 0; + position: relative; + + > * { + position: absolute; + display: block; + padding: 0 10px; + background: $color1; + left: 50%; + margin: 0 -50% 0 0; + transform: translate(-50%, -50%); + } + + &.small-margin { + margin: 12px 0 16px 0; + } + } +} + +.passwordreset-proof { + &--realmname { + color: $color3; + } } From 4e775e915edc216b73b24364941f1b36e1c8747f Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Fri, 31 Jan 2025 14:40:07 +0100 Subject: [PATCH 2/3] GH-351: Deleted redundant scss. --- src/hub/login/modules/init/login/login.scss | 6 ------ .../modules/init/loginVerify/loginVerify.scss | 18 ------------------ 2 files changed, 24 deletions(-) diff --git a/src/hub/login/modules/init/login/login.scss b/src/hub/login/modules/init/login/login.scss index 62730858..ccb17944 100644 --- a/src/hub/login/modules/init/login/login.scss +++ b/src/hub/login/modules/init/login/login.scss @@ -37,12 +37,6 @@ } } - &--disclaimer { - margin-top: 12px; - font-size: 13px; - font-style: italic; - } - &--forgotpass { margin: -8px 0 6px 0; font-family: $font-text; diff --git a/src/hub/verify/modules/init/loginVerify/loginVerify.scss b/src/hub/verify/modules/init/loginVerify/loginVerify.scss index dbdea110..0fef4b78 100644 --- a/src/hub/verify/modules/init/loginVerify/loginVerify.scss +++ b/src/hub/verify/modules/init/loginVerify/loginVerify.scss @@ -15,24 +15,6 @@ color: $color5; } - &--divider { - width: 100%; - height: 2px; - background: $color2; - margin: 32px 0; - position: relative; - - > * { - position: absolute; - display: block; - padding: 0 10px; - background: $color1; - left: 50%; - margin: 0 -50% 0 0; - transform: translate(-50%, -50%); - } - } - &--disclaimer { display: block; margin-bottom: 20px; From 5ef9f703a20778c1c8ee6faea44002835ea1164e Mon Sep 17 00:00:00 2001 From: Accipiter Nisus Date: Fri, 31 Jan 2025 15:41:00 +0100 Subject: [PATCH 3/3] GH-351: Improved error handling on fetch. --- src/common/modules/auth/Auth.js | 2 +- src/common/utils/responseParseError.js | 21 +++++++++++++++++++ src/hub/login/modules/init/login/Login.js | 20 ++++++++---------- .../init/passwordReset/PasswordReset.js | 11 ++++------ .../modules/init/loginVerify/LoginVerify.js | 13 ++++++------ 5 files changed, 41 insertions(+), 26 deletions(-) create mode 100644 src/common/utils/responseParseError.js diff --git a/src/common/modules/auth/Auth.js b/src/common/modules/auth/Auth.js index 8942a268..8b19e1e2 100644 --- a/src/common/modules/auth/Auth.js +++ b/src/common/modules/auth/Auth.js @@ -91,7 +91,7 @@ class Auth { return null; } throw err; - }).catch(err => resp.text().then( + }, err => resp.text().then( text => { throw new Err('auth.failedToAuthenticateMsg', "Failed to authenticate: {text}", resp.text()); }, diff --git a/src/common/utils/responseParseError.js b/src/common/utils/responseParseError.js new file mode 100644 index 00000000..c104b004 --- /dev/null +++ b/src/common/utils/responseParseError.js @@ -0,0 +1,21 @@ +import Err from 'classes/Err'; + +/** + * Creates a response parse error callback function for when calling + * response.json() on a fetch response. + * + * Depending on status code being => 400, a different error message is shown. + * @param {Response} resp Response object. + * @returns {() => Promise} Promise that rejects with a value of type Err. + */ +export default function responseParseError(resp) { + return () => resp.status >= 400 + ? Promise.reject(new Err('responseParseError.errorStatus', "Failed with status {status} {statusText}.", { status: resp.status, statusText: resp.statusText })) + : resp.text().then( + text => Promise.reject(text + ? new Err('responseParseError.unexptedResponse', "Unexpected response: {text}", { text }) + : new Err('responseParseError.missingResponse', "Unexpected empty response.", { text }), + ), + () => Promise.reject(new Err('responseParseError.unexpectedError', "Unexpected response {status} {statusText}.", { status: resp.status, statusText: resp.statusText })), + ); +} diff --git a/src/hub/login/modules/init/login/Login.js b/src/hub/login/modules/init/login/Login.js index 957085dd..20e11055 100644 --- a/src/hub/login/modules/init/login/Login.js +++ b/src/hub/login/modules/init/login/Login.js @@ -1,7 +1,7 @@ -import Err from 'classes/Err'; import sha256, { hmacsha256, publicPepper } from 'utils/sha256'; import reload, { redirect } from 'utils/reload'; import { uri } from 'modapp-utils'; +import responseParseError from 'utils/responseParseError'; import LoginComponent from './LoginComponent'; import LoginAgreeTerms from './LoginAgreeTerms'; import './login.scss'; @@ -70,7 +70,7 @@ class Login { return; } throw err; - }); + }, responseParseError(resp)); } return resp.json().then(user => { @@ -79,7 +79,7 @@ class Login { } else { this._redirect(true); } - }); + }, responseParseError(resp)); }); } @@ -98,7 +98,7 @@ class Login { if (resp.status >= 400) { return resp.json().then(err => { throw err; - }); + }, responseParseError(resp)); } this._redirect(); }); @@ -145,7 +145,7 @@ class Login { if (resp.status >= 400) { return resp.json().then(err => { throw err; - }); + }, responseParseError(resp)); } this._redirect(); }); @@ -168,7 +168,7 @@ class Login { return; } throw err; - }); + }, responseParseError(resp)); } return this._redirect(); }); @@ -196,11 +196,9 @@ class Login { credentials: crossOrigin ? 'include' : 'same-origin', }).then(resp => { if (resp.status >= 400) { - return resp.json() - .catch(() => new Err('login.errorResponse', "Request returned with status {status}.", { status: resp.status })) - .then(err => { - throw err; - }); + return resp.json().then(err => { + throw err; + }, responseParseError(resp)); } }); } diff --git a/src/hub/reset/modules/init/passwordReset/PasswordReset.js b/src/hub/reset/modules/init/passwordReset/PasswordReset.js index fa1f0c01..5da5e6b3 100644 --- a/src/hub/reset/modules/init/passwordReset/PasswordReset.js +++ b/src/hub/reset/modules/init/passwordReset/PasswordReset.js @@ -6,6 +6,7 @@ import sha256, { hmacsha256, publicPepper } from 'utils/sha256'; import { redirect } from 'utils/reload'; import ErrorScreenDialog from 'components/ErrorScreenDialog'; import ConfirmScreenDialog from 'components/ConfirmScreenDialog'; +import responseParseError from 'utils/responseParseError'; import PasswordResetComponent from './PasswordResetComponent'; import './passwordReset.scss'; @@ -69,11 +70,9 @@ class PasswordReset { if (resp.status >= 400) { return resp.json().then(err => { throw err; - }, () => { - throw new Err('passwordReset.failedToVerifyCode', "Code verification failed with status {status}.", { status: resp.status }); - }); + }, responseParseError(resp)); } - return resp.json(); + return resp.json().catch(responseParseError(resp)); }); } @@ -112,9 +111,7 @@ class PasswordReset { if (resp.status >= 400) { return resp.json().then(err => { throw err; - }, () => { - throw new Err('passwordReset.resetFailed', "Reset failed with status {status}.", { status: resp.status }); - }); + }, responseParseError(resp)); } return resp.json() .then(result => this._showResetComplete(result.username, result.emailVerified)) diff --git a/src/hub/verify/modules/init/loginVerify/LoginVerify.js b/src/hub/verify/modules/init/loginVerify/LoginVerify.js index c94dc081..16f914d1 100644 --- a/src/hub/verify/modules/init/loginVerify/LoginVerify.js +++ b/src/hub/verify/modules/init/loginVerify/LoginVerify.js @@ -7,6 +7,7 @@ import sha256, { hmacsha256, publicPepper } from 'utils/sha256'; import reload, { redirect } from 'utils/reload'; import ErrorScreenDialog from 'components/ErrorScreenDialog'; import ConfirmScreenDialog from 'components/ConfirmScreenDialog'; +import responseParseError from 'utils/responseParseError'; import LoginVerifyComponent from './LoginVerifyComponent'; import './loginVerify.scss'; @@ -72,7 +73,7 @@ class LoginVerify { } return resp.json().then(err => { throw err; - }); + }, responseParseError(resp)); } return resp.json().then(user => { @@ -81,7 +82,7 @@ class LoginVerify { } else { this._verifyCode(); } - }); + }, responseParseError(resp)); }).catch(err => { this._showError(err); }); @@ -115,7 +116,7 @@ class LoginVerify { if (resp.status >= 400) { return resp.json().then(err => { throw err; - }); + }, responseParseError(resp)); } this._verifyCode(); }); @@ -183,9 +184,7 @@ class LoginVerify { if (resp.status >= 400) { return resp.json().then(err => { throw err; - }).catch(err => { - throw new Err('loginVerify.verificationFailedWithStatus', "Verification failed with status {status}.", { status: resp.status }); - }); + }, responseParseError(resp)); } return resp.json().then(result => { if (result.emailVerified) { @@ -193,7 +192,7 @@ class LoginVerify { } else { this._showNotVerified(); } - }); + }, responseParseError(resp)); }).catch(err => this._showError(err)); }