mirror of
https://github.com/vector-im/element-web.git
synced 2024-11-15 20:54:59 +08:00
New password reset flow (#9581)
This commit is contained in:
parent
3f74ac37e8
commit
e5ce6d7800
@ -49,6 +49,7 @@
|
|||||||
@import "./structures/_BackdropPanel.pcss";
|
@import "./structures/_BackdropPanel.pcss";
|
||||||
@import "./structures/_CompatibilityPage.pcss";
|
@import "./structures/_CompatibilityPage.pcss";
|
||||||
@import "./structures/_ContextualMenu.pcss";
|
@import "./structures/_ContextualMenu.pcss";
|
||||||
|
@import "./structures/_ErrorMessage.pcss";
|
||||||
@import "./structures/_FileDropTarget.pcss";
|
@import "./structures/_FileDropTarget.pcss";
|
||||||
@import "./structures/_FilePanel.pcss";
|
@import "./structures/_FilePanel.pcss";
|
||||||
@import "./structures/_GenericDropdownMenu.pcss";
|
@import "./structures/_GenericDropdownMenu.pcss";
|
||||||
@ -157,6 +158,7 @@
|
|||||||
@import "./views/dialogs/_UntrustedDeviceDialog.pcss";
|
@import "./views/dialogs/_UntrustedDeviceDialog.pcss";
|
||||||
@import "./views/dialogs/_UploadConfirmDialog.pcss";
|
@import "./views/dialogs/_UploadConfirmDialog.pcss";
|
||||||
@import "./views/dialogs/_UserSettingsDialog.pcss";
|
@import "./views/dialogs/_UserSettingsDialog.pcss";
|
||||||
|
@import "./views/dialogs/_VerifyEMailDialog.pcss";
|
||||||
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss";
|
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss";
|
||||||
@import "./views/dialogs/security/_AccessSecretStorageDialog.pcss";
|
@import "./views/dialogs/security/_AccessSecretStorageDialog.pcss";
|
||||||
@import "./views/dialogs/security/_CreateCrossSigningDialog.pcss";
|
@import "./views/dialogs/security/_CreateCrossSigningDialog.pcss";
|
||||||
|
@ -25,20 +25,30 @@ limitations under the License.
|
|||||||
padding: 1px;
|
padding: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_Icon_accent {
|
||||||
|
color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_Icon_8 {
|
.mx_Icon_8 {
|
||||||
height: 8px;
|
|
||||||
flex: 0 0 8px;
|
flex: 0 0 8px;
|
||||||
|
height: 8px;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_Icon_16 {
|
.mx_Icon_16 {
|
||||||
height: 16px;
|
|
||||||
flex: 0 0 16px;
|
flex: 0 0 16px;
|
||||||
|
height: 16px;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx_Icon_24 {
|
.mx_Icon_24 {
|
||||||
height: 24px;
|
|
||||||
flex: 0 0 24px;
|
flex: 0 0 24px;
|
||||||
|
height: 24px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_Icon_32 {
|
||||||
|
flex: 0 0 32px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
25
res/css/structures/_ErrorMessage.pcss
Normal file
25
res/css/structures/_ErrorMessage.pcss
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_ErrorMessage {
|
||||||
|
align-items: center;
|
||||||
|
color: $alert;
|
||||||
|
display: flex;
|
||||||
|
font-size: $font-12px;
|
||||||
|
gap: $spacing-8;
|
||||||
|
line-height: 1.2em;
|
||||||
|
min-height: 2.4em;
|
||||||
|
}
|
@ -17,6 +17,8 @@ limitations under the License.
|
|||||||
|
|
||||||
.mx_Login_submit {
|
.mx_Login_submit {
|
||||||
@mixin mx_DialogButton;
|
@mixin mx_DialogButton;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
@ -87,7 +89,7 @@ limitations under the License.
|
|||||||
|
|
||||||
div.mx_AccessibleButton_kind_link.mx_Login_forgot {
|
div.mx_AccessibleButton_kind_link.mx_Login_forgot {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 auto;
|
margin-top: 24px;
|
||||||
|
|
||||||
&.mx_AccessibleButton_disabled {
|
&.mx_AccessibleButton_disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
@ -17,12 +17,17 @@ limitations under the License.
|
|||||||
|
|
||||||
.mx_AuthBody {
|
.mx_AuthBody {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
font-size: $font-12px;
|
font-size: $font-14px;
|
||||||
color: $authpage-secondary-color;
|
color: $primary-content;
|
||||||
background-color: $background;
|
background-color: $background;
|
||||||
border-radius: 0 4px 4px 0;
|
border-radius: 0 4px 4px 0;
|
||||||
padding: 25px 60px;
|
padding: 50px 32px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
min-height: 600px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
&.mx_AuthBody_flex {
|
&.mx_AuthBody_flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -32,7 +37,8 @@ limitations under the License.
|
|||||||
h1 {
|
h1 {
|
||||||
font-size: $font-24px;
|
font-size: $font-24px;
|
||||||
font-weight: $font-semi-bold;
|
font-weight: $font-semi-bold;
|
||||||
margin-top: 8px;
|
margin-bottom: $spacing-20;
|
||||||
|
margin-top: $spacing-24;
|
||||||
color: $authpage-primary-color;
|
color: $authpage-primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +58,23 @@ limitations under the License.
|
|||||||
@mixin mx_Dialog_link;
|
@mixin mx_Dialog_link;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AuthBody_icon {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AuthBody_lockIcon {
|
||||||
|
height: 29px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AuthBody_text {
|
||||||
|
margin-bottom: $spacing-48;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="password"] {
|
input[type="password"] {
|
||||||
color: $authpage-primary-color;
|
color: $authpage-primary-color;
|
||||||
@ -76,6 +99,16 @@ limitations under the License.
|
|||||||
color: $alert;
|
color: $alert;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_Login_submit {
|
||||||
|
height: 33px;
|
||||||
|
margin-top: $spacing-16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_ErrorMessage {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_Field input {
|
.mx_Field input {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@ -101,6 +134,43 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_AuthBody_did-not-receive {
|
||||||
|
align-items: center;
|
||||||
|
color: $secondary-content;
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-8;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-top: $spacing-24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AuthBody_did-not-receive--centered {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AuthBody_resend-button {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $accent;
|
||||||
|
display: flex;
|
||||||
|
gap: $spacing-4;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $system;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AuthBody_emailPromptIcon {
|
||||||
|
width: 57px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_AuthBody_emailPromptIcon--shifted {
|
||||||
|
margin-bottom: -17px; // Prevent layout jump by relative positioning.
|
||||||
|
position: relative;
|
||||||
|
top: -17px; // This icon is higher than the other icons. Shift up to prevent icon jumping.
|
||||||
|
width: 57px;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_AuthBody_fieldRow {
|
.mx_AuthBody_fieldRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
35
res/css/views/dialogs/_VerifyEMailDialog.pcss
Normal file
35
res/css/views/dialogs/_VerifyEMailDialog.pcss
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_VerifyEMailDialog {
|
||||||
|
.mx_Dialog {
|
||||||
|
color: $primary-content;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
width: 485px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: $font-24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_VerifyEMailDialog_text-light {
|
||||||
|
color: $secondary-content;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -24,6 +24,7 @@ limitations under the License.
|
|||||||
&.mx_AccessibleButton_kind_primary_outline,
|
&.mx_AccessibleButton_kind_primary_outline,
|
||||||
&.mx_AccessibleButton_kind_primary_sm,
|
&.mx_AccessibleButton_kind_primary_sm,
|
||||||
&.mx_AccessibleButton_kind_link,
|
&.mx_AccessibleButton_kind_link,
|
||||||
|
&.mx_AccessibleButton_kind_link_accent,
|
||||||
&.mx_AccessibleButton_kind_link_inline,
|
&.mx_AccessibleButton_kind_link_inline,
|
||||||
&.mx_AccessibleButton_kind_danger_inline,
|
&.mx_AccessibleButton_kind_danger_inline,
|
||||||
&.mx_AccessibleButton_kind_content_inline,
|
&.mx_AccessibleButton_kind_content_inline,
|
||||||
|
3
res/img/element-icons/Checkbox.svg
Normal file
3
res/img/element-icons/Checkbox.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.0918 17.0489L11.6069 23.7576L26.91 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 243 B |
3
res/img/element-icons/Email-icon.svg
Normal file
3
res/img/element-icons/Email-icon.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="40" height="29" viewBox="0 0 40 29" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.260962 2.57584L18.9049 21.1615C19.5449 21.7995 20.5805 21.7995 21.2205 21.1615L39.7726 2.66735C39.9199 3.08419 40 3.53274 40 4V25C40 27.2091 38.2091 29 36 29H4C1.79086 29 0 27.2091 0 25V4C0 3.49835 0.0923447 3.01827 0.260962 2.57584ZM2.58227 0.258515C3.02293 0.0914515 3.50079 0 4 0H36C36.5334 0 37.0424 0.104395 37.5077 0.293868L20.0627 17.6843L2.58227 0.258515Z" fill="#737D8C"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 537 B |
@ -1,3 +1 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" width="21.333" height="26.667" fill="none"><path fill="#8e99a4" fill-rule="evenodd" d="M9.778 0a7.111 7.111 0 0 0-7.111 7.111v3.556H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h17.333a2 2 0 0 0 2-2v-12a2 2 0 0 0-2-2h-.666V7.11A7.111 7.111 0 0 0 11.555 0Zm5.333 10.667V7.11a3.556 3.556 0 0 0-3.556-3.555H9.778A3.556 3.556 0 0 0 6.222 7.11v3.556Z" clip-rule="evenodd"/></svg>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.1113 2.6665C11.1839 2.6665 8.00016 5.85026 8.00016 9.77762V13.3332L7.3335 13.3332C6.22893 13.3332 5.3335 14.2286 5.3335 15.3332V27.3332C5.3335 28.4377 6.22893 29.3332 7.3335 29.3332H24.6668C25.7714 29.3332 26.6668 28.4377 26.6668 27.3332V15.3332C26.6668 14.2286 25.7714 13.3332 24.6668 13.3332L24.0002 13.3332V9.77762C24.0002 5.85026 20.8164 2.6665 16.8891 2.6665H15.1113ZM20.4446 13.3332V9.77762C20.4446 7.81394 18.8527 6.22206 16.8891 6.22206H15.1113C13.1476 6.22206 11.5557 7.81394 11.5557 9.77762V13.3332H20.4446Z" fill="#8E99A4"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 692 B After Width: | Height: | Size: 401 B |
@ -1,3 +1,7 @@
|
|||||||
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="16" fill="none">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58365 3.90848C5.79757 2.94852 7.33285 2.375 9 2.375C12.6817 2.375 15.7112 5.1675 16.086 8.75H17.6314C17.9253 8.75 18.1006 9.07792 17.9376 9.32274L15.6812 12.711C15.5355 12.9297 15.2145 12.9297 15.0688 12.711L12.8124 9.32274C12.6494 9.07792 12.8247 8.75 13.1186 8.75H14.5754C14.2088 5.99798 11.8523 3.875 9 3.875C7.68247 3.875 6.4726 4.32705 5.51407 5.08504C5.45221 5.13396 5.39899 5.17326 5.36001 5.20114C5.34047 5.21513 5.32433 5.22637 5.31229 5.23463L5.29733 5.24482L5.29227 5.24821L5.29037 5.24948L5.28958 5.25L5.28923 5.25023L5.28906 5.25034L5.28898 5.2504L4.875 4.625L5.2889 5.25045C4.94347 5.47904 4.47814 5.38433 4.24955 5.0389C4.02136 4.69408 4.11534 4.22977 4.45929 4.00075L4.4633 3.99802C4.46789 3.99487 4.47605 3.9892 4.48719 3.98123C4.5096 3.9652 4.5433 3.94038 4.58365 3.90848ZM3.42456 10.25H4.88138C5.1753 10.25 5.35061 9.92208 5.18758 9.67726L2.93119 6.28905C2.78553 6.07032 2.46447 6.07032 2.31881 6.28905L0.0624241 9.67726C-0.100613 9.92207 0.0746987 10.25 0.368618 10.25H1.914C2.28878 13.8325 5.31828 16.625 9 16.625C10.7415 16.625 12.3388 15.9992 13.5764 14.9611C13.8938 14.6949 13.9353 14.2219 13.6691 13.9045C13.4029 13.5872 12.9298 13.5457 12.6125 13.8119C11.6349 14.6319 10.376 15.125 9 15.125C6.14769 15.125 3.79123 13.002 3.42456 10.25Z" fill="currentColor"/>
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4.523 2.964a6.418 6.418 0 0 1 10.36 4.369h1.29c.26 0 .416.292.272.51l-2.006 3.011a.327.327 0 0 1-.544 0l-2.006-3.012a.327.327 0 0 1 .272-.509h1.21a4.918 4.918 0 0 0-7.918-3.192 3.684 3.684 0 0 1-.184.136l-.014.01-.004.003-.002.001h-.001v.001l-.415-.625.414.625a.75.75 0 0 1-.83-1.25l.003-.001.02-.014c.02-.014.048-.035.083-.063Zm-.895 5.703H4.84a.327.327 0 0 0 .272-.51L3.106 5.146a.327.327 0 0 0-.545 0L.555 8.157c-.144.218.011.51.273.51h1.29a6.418 6.418 0 0 0 10.503 4.251.75.75 0 0 0-.963-1.15 4.918 4.918 0 0 1-8.03-3.102Z"
|
||||||
|
clip-rule="evenodd"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 703 B |
@ -19,6 +19,8 @@ import { createClient, IRequestTokenResponse, MatrixClient } from 'matrix-js-sdk
|
|||||||
|
|
||||||
import { _t } from './languageHandler';
|
import { _t } from './languageHandler';
|
||||||
|
|
||||||
|
const CHECK_EMAIL_VERIFIED_POLL_INTERVAL = 2000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allows a user to reset their password on a homeserver.
|
* Allows a user to reset their password on a homeserver.
|
||||||
*
|
*
|
||||||
@ -29,9 +31,10 @@ import { _t } from './languageHandler';
|
|||||||
export default class PasswordReset {
|
export default class PasswordReset {
|
||||||
private client: MatrixClient;
|
private client: MatrixClient;
|
||||||
private clientSecret: string;
|
private clientSecret: string;
|
||||||
private password: string;
|
private password = "";
|
||||||
private sessionId: string;
|
private sessionId = "";
|
||||||
private logoutDevices: boolean;
|
private logoutDevices = false;
|
||||||
|
private sendAttempt = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure the endpoints for password resetting.
|
* Configure the endpoints for password resetting.
|
||||||
@ -54,14 +57,40 @@ export default class PasswordReset {
|
|||||||
* @param {boolean} logoutDevices Should all devices be signed out after the reset? Defaults to `true`.
|
* @param {boolean} logoutDevices Should all devices be signed out after the reset? Defaults to `true`.
|
||||||
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
|
||||||
*/
|
*/
|
||||||
public resetPassword(
|
public async resetPassword(
|
||||||
emailAddress: string,
|
emailAddress: string,
|
||||||
newPassword: string,
|
newPassword: string,
|
||||||
logoutDevices = true,
|
logoutDevices = true,
|
||||||
): Promise<IRequestTokenResponse> {
|
): Promise<IRequestTokenResponse> {
|
||||||
this.password = newPassword;
|
this.password = newPassword;
|
||||||
this.logoutDevices = logoutDevices;
|
this.logoutDevices = logoutDevices;
|
||||||
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
|
this.sendAttempt++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.client.requestPasswordEmailToken(
|
||||||
|
emailAddress,
|
||||||
|
this.clientSecret,
|
||||||
|
this.sendAttempt,
|
||||||
|
);
|
||||||
|
this.sessionId = result.sid;
|
||||||
|
return result;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.errcode === 'M_THREEPID_NOT_FOUND') {
|
||||||
|
err.message = _t('This email address was not found');
|
||||||
|
} else if (err.httpStatus) {
|
||||||
|
err.message = err.message + ` (Status ${err.httpStatus})`;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a password reset token.
|
||||||
|
* This will trigger a side-effect of sending an email to the provided email address.
|
||||||
|
*/
|
||||||
|
public requestResetToken(emailAddress: string): Promise<IRequestTokenResponse> {
|
||||||
|
this.sendAttempt++;
|
||||||
|
return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, this.sendAttempt).then((res) => {
|
||||||
this.sessionId = res.sid;
|
this.sessionId = res.sid;
|
||||||
return res;
|
return res;
|
||||||
}, function(err) {
|
}, function(err) {
|
||||||
@ -74,6 +103,29 @@ export default class PasswordReset {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async setNewPassword(password: string): Promise<void> {
|
||||||
|
this.password = password;
|
||||||
|
await this.checkEmailLinkClicked();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async retrySetNewPassword(password: string): Promise<void> {
|
||||||
|
this.password = password;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.tryCheckEmailLinkClicked(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryCheckEmailLinkClicked(resolve: Function): void {
|
||||||
|
this.checkEmailLinkClicked()
|
||||||
|
.then(() => resolve())
|
||||||
|
.catch(() => {
|
||||||
|
setTimeout(
|
||||||
|
() => this.tryCheckEmailLinkClicked(resolve),
|
||||||
|
CHECK_EMAIL_VERIFIED_POLL_INTERVAL,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the email link has been clicked by attempting to change the password
|
* Checks if the email link has been clicked by attempting to change the password
|
||||||
* for the mxid linked to the email.
|
* for the mxid linked to the email.
|
||||||
@ -98,7 +150,7 @@ export default class PasswordReset {
|
|||||||
threepid_creds: creds,
|
threepid_creds: creds,
|
||||||
threepidCreds: creds,
|
threepidCreds: creds,
|
||||||
}, this.password, this.logoutDevices);
|
}, this.password, this.logoutDevices);
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
if (err.httpStatus === 401) {
|
if (err.httpStatus === 401) {
|
||||||
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
|
err.message = _t('Failed to verify email address: make sure you clicked the link in the email');
|
||||||
} else if (err.httpStatus === 404) {
|
} else if (err.httpStatus === 404) {
|
||||||
|
40
src/components/structures/ErrorMessage.tsx
Normal file
40
src/components/structures/ErrorMessage.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Icon as WarningBadgeIcon } from "../../../res/img/element-icons/warning-badge.svg";
|
||||||
|
|
||||||
|
interface ErrorMessageProps {
|
||||||
|
message: string | ReactNode | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error message component.
|
||||||
|
* Reserves two lines to display errors to prevent layout shifts when the error pops up.
|
||||||
|
*/
|
||||||
|
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
|
||||||
|
message,
|
||||||
|
}) => {
|
||||||
|
const icon = message
|
||||||
|
? <WarningBadgeIcon className="mx_Icon mx_Icon_16" />
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return <div className="mx_ErrorMessage">
|
||||||
|
{ icon }
|
||||||
|
{ message }
|
||||||
|
</div>;
|
||||||
|
};
|
@ -2086,7 +2086,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||||||
<ForgotPassword
|
<ForgotPassword
|
||||||
onComplete={this.onLoginClick}
|
onComplete={this.onLoginClick}
|
||||||
onLoginClick={this.onLoginClick}
|
onLoginClick={this.onLoginClick}
|
||||||
onServerConfigChange={this.onServerConfigChange}
|
|
||||||
{...this.getServerProperties()}
|
{...this.getServerProperties()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -16,104 +16,102 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import classNames from 'classnames';
|
import { logger } from 'matrix-js-sdk/src/logger';
|
||||||
import { logger } from "matrix-js-sdk/src/logger";
|
|
||||||
import { createClient } from "matrix-js-sdk/src/matrix";
|
import { createClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { _t, _td } from '../../../languageHandler';
|
import { _t, _td } from '../../../languageHandler';
|
||||||
import Modal from "../../../Modal";
|
import Modal from "../../../Modal";
|
||||||
import PasswordReset from "../../../PasswordReset";
|
import PasswordReset from "../../../PasswordReset";
|
||||||
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
|
|
||||||
import AuthPage from "../../views/auth/AuthPage";
|
import AuthPage from "../../views/auth/AuthPage";
|
||||||
import ServerPicker from "../../views/elements/ServerPicker";
|
|
||||||
import EmailField from "../../views/auth/EmailField";
|
|
||||||
import PassphraseField from '../../views/auth/PassphraseField';
|
import PassphraseField from '../../views/auth/PassphraseField';
|
||||||
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
|
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
|
||||||
import InlineSpinner from '../../views/elements/InlineSpinner';
|
|
||||||
import Spinner from "../../views/elements/Spinner";
|
|
||||||
import QuestionDialog from "../../views/dialogs/QuestionDialog";
|
|
||||||
import ErrorDialog from "../../views/dialogs/ErrorDialog";
|
|
||||||
import AuthHeader from "../../views/auth/AuthHeader";
|
import AuthHeader from "../../views/auth/AuthHeader";
|
||||||
import AuthBody from "../../views/auth/AuthBody";
|
import AuthBody from "../../views/auth/AuthBody";
|
||||||
import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField";
|
import PassphraseConfirmField from "../../views/auth/PassphraseConfirmField";
|
||||||
import AccessibleButton from '../../views/elements/AccessibleButton';
|
|
||||||
import StyledCheckbox from '../../views/elements/StyledCheckbox';
|
import StyledCheckbox from '../../views/elements/StyledCheckbox';
|
||||||
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
|
import { ValidatedServerConfig } from '../../../utils/ValidatedServerConfig';
|
||||||
|
import { Icon as LockIcon } from "../../../../res/img/element-icons/lock.svg";
|
||||||
|
import QuestionDialog from '../../views/dialogs/QuestionDialog';
|
||||||
|
import { EnterEmail } from './forgot-password/EnterEmail';
|
||||||
|
import { CheckEmail } from './forgot-password/CheckEmail';
|
||||||
|
import Field from '../../views/elements/Field';
|
||||||
|
import { ErrorMessage } from '../ErrorMessage';
|
||||||
|
import { Icon as CheckboxIcon } from "../../../../res/img/element-icons/Checkbox.svg";
|
||||||
|
import { VerifyEmailModal } from './forgot-password/VerifyEmailModal';
|
||||||
|
import Spinner from '../../views/elements/Spinner';
|
||||||
|
import { formatSeconds } from '../../../DateUtils';
|
||||||
|
import AutoDiscoveryUtils from '../../../utils/AutoDiscoveryUtils';
|
||||||
|
|
||||||
enum Phase {
|
enum Phase {
|
||||||
// Show the forgot password inputs
|
// Show email input
|
||||||
Forgot = 1,
|
EnterEmail = 1,
|
||||||
// Email is in the process of being sent
|
// Email is in the process of being sent
|
||||||
SendingEmail = 2,
|
SendingEmail = 2,
|
||||||
// Email has been sent
|
// Email has been sent
|
||||||
EmailSent = 3,
|
EmailSent = 3,
|
||||||
// User has clicked the link in email and completed reset
|
// Show new password input
|
||||||
Done = 4,
|
PasswordInput = 4,
|
||||||
|
// Password is in the process of being reset
|
||||||
|
ResettingPassword = 5,
|
||||||
|
// All done
|
||||||
|
Done = 6,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProps {
|
interface Props {
|
||||||
serverConfig: ValidatedServerConfig;
|
serverConfig: ValidatedServerConfig;
|
||||||
onServerConfigChange: (serverConfig: ValidatedServerConfig) => void;
|
|
||||||
onLoginClick?: () => void;
|
onLoginClick?: () => void;
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface State {
|
||||||
phase: Phase;
|
phase: Phase;
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
password2: string;
|
password2: string;
|
||||||
errorText: string;
|
errorText: string | ReactNode | null;
|
||||||
|
|
||||||
// We perform liveliness checks later, but for now suppress the errors.
|
// We perform liveliness checks later, but for now suppress the errors.
|
||||||
// We also track the server dead errors independently of the regular errors so
|
// We also track the server dead errors independently of the regular errors so
|
||||||
// that we can render it differently, and override any other error the user may
|
// that we can render it differently, and override any other error the user may
|
||||||
// be seeing.
|
// be seeing.
|
||||||
serverIsAlive: boolean;
|
serverIsAlive: boolean;
|
||||||
serverErrorIsFatal: boolean;
|
|
||||||
serverDeadError: string;
|
serverDeadError: string;
|
||||||
|
|
||||||
currentHttpRequest?: Promise<any>;
|
|
||||||
|
|
||||||
serverSupportsControlOfDevicesLogout: boolean;
|
serverSupportsControlOfDevicesLogout: boolean;
|
||||||
logoutDevices: boolean;
|
logoutDevices: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ForgotPasswordField {
|
export default class ForgotPassword extends React.Component<Props, State> {
|
||||||
Email = 'field_email',
|
|
||||||
Password = 'field_password',
|
|
||||||
PasswordConfirm = 'field_password_confirm',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class ForgotPassword extends React.Component<IProps, IState> {
|
|
||||||
private reset: PasswordReset;
|
private reset: PasswordReset;
|
||||||
|
private fieldPassword: Field | null = null;
|
||||||
|
private fieldPasswordConfirm: Field | null = null;
|
||||||
|
|
||||||
state: IState = {
|
public constructor(props: Props) {
|
||||||
phase: Phase.Forgot,
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
phase: Phase.EnterEmail,
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
password2: "",
|
password2: "",
|
||||||
errorText: null,
|
errorText: null,
|
||||||
|
|
||||||
// We perform liveliness checks later, but for now suppress the errors.
|
// We perform liveliness checks later, but for now suppress the errors.
|
||||||
// We also track the server dead errors independently of the regular errors so
|
// We also track the server dead errors independently of the regular errors so
|
||||||
// that we can render it differently, and override any other error the user may
|
// that we can render it differently, and override any other error the user may
|
||||||
// be seeing.
|
// be seeing.
|
||||||
serverIsAlive: true,
|
serverIsAlive: true,
|
||||||
serverErrorIsFatal: false,
|
|
||||||
serverDeadError: "",
|
serverDeadError: "",
|
||||||
serverSupportsControlOfDevicesLogout: false,
|
serverSupportsControlOfDevicesLogout: false,
|
||||||
logoutDevices: false,
|
logoutDevices: false,
|
||||||
};
|
};
|
||||||
|
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
|
||||||
|
}
|
||||||
|
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
this.reset = null;
|
|
||||||
this.checkServerLiveliness(this.props.serverConfig);
|
|
||||||
this.checkServerCapabilities(this.props.serverConfig);
|
this.checkServerCapabilities(this.props.serverConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: Readonly<IProps>) {
|
public componentDidUpdate(prevProps: Readonly<Props>) {
|
||||||
if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
|
if (prevProps.serverConfig.hsUrl !== this.props.serverConfig.hsUrl ||
|
||||||
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
|
prevProps.serverConfig.isUrl !== this.props.serverConfig.isUrl
|
||||||
) {
|
) {
|
||||||
@ -125,7 +123,7 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkServerLiveliness(serverConfig): Promise<void> {
|
private async checkServerLiveliness(serverConfig: ValidatedServerConfig): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
|
||||||
serverConfig.hsUrl,
|
serverConfig.hsUrl,
|
||||||
@ -135,8 +133,15 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||||||
this.setState({
|
this.setState({
|
||||||
serverIsAlive: true,
|
serverIsAlive: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState);
|
const {
|
||||||
|
serverIsAlive,
|
||||||
|
serverDeadError,
|
||||||
|
} = AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password");
|
||||||
|
this.setState({
|
||||||
|
serverIsAlive,
|
||||||
|
errorText: serverDeadError,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,52 +158,192 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public submitPasswordReset(email: string, password: string, logoutDevices = true): void {
|
private async onPhaseEmailInputSubmit() {
|
||||||
this.setState({
|
this.phase = Phase.SendingEmail;
|
||||||
phase: Phase.SendingEmail,
|
|
||||||
});
|
|
||||||
this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl);
|
|
||||||
this.reset.resetPassword(email, password, logoutDevices).then(() => {
|
|
||||||
this.setState({
|
|
||||||
phase: Phase.EmailSent,
|
|
||||||
});
|
|
||||||
}, (err) => {
|
|
||||||
this.showErrorDialog(_t('Failed to send email') + ": " + err.message);
|
|
||||||
this.setState({
|
|
||||||
phase: Phase.Forgot,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private onVerify = async (ev: React.MouseEvent): Promise<void> => {
|
if (await this.sendVerificationMail()) {
|
||||||
ev.preventDefault();
|
this.phase = Phase.EmailSent;
|
||||||
if (!this.reset) {
|
|
||||||
logger.error("onVerify called before submitPasswordReset!");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.state.currentHttpRequest) return;
|
|
||||||
|
this.phase = Phase.EnterEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendVerificationMail = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await this.reset.requestResetToken(this.state.email);
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.handleError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleError(err: any): void {
|
||||||
|
if (err?.httpStatus === 429) {
|
||||||
|
// 429: rate limit
|
||||||
|
const retryAfterMs = parseInt(err?.data?.retry_after_ms, 10);
|
||||||
|
|
||||||
|
const errorText = isNaN(retryAfterMs)
|
||||||
|
? _t("Too many attempts in a short time. Wait some time before trying again.")
|
||||||
|
: _t(
|
||||||
|
"Too many attempts in a short time. Retry after %(timeout)s.",
|
||||||
|
{
|
||||||
|
timeout: formatSeconds(retryAfterMs / 1000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
errorText,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err?.name === "ConnectionError") {
|
||||||
|
this.setState({
|
||||||
|
errorText: _t("Cannot reach homeserver") + ": "
|
||||||
|
+ _t("Ensure you have a stable internet connection, or get in touch with the server admin"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
errorText: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onPhaseEmailSentSubmit() {
|
||||||
|
this.setState({
|
||||||
|
phase: Phase.PasswordInput,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private set phase(phase: Phase) {
|
||||||
|
this.setState({ phase });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyFieldsBeforeSubmit(): Promise<boolean> {
|
||||||
|
const fieldIdsInDisplayOrder = [
|
||||||
|
this.fieldPassword,
|
||||||
|
this.fieldPasswordConfirm,
|
||||||
|
];
|
||||||
|
|
||||||
|
const invalidFields: Field[] = [];
|
||||||
|
|
||||||
|
for (const field of fieldIdsInDisplayOrder) {
|
||||||
|
if (!field) continue;
|
||||||
|
|
||||||
|
const valid = await field.validate({ allowEmpty: false });
|
||||||
|
if (!valid) {
|
||||||
|
invalidFields.push(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidFields.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus on the first invalid field, then re-validate,
|
||||||
|
// which will result in the error tooltip being displayed for that field.
|
||||||
|
invalidFields[0].focus();
|
||||||
|
invalidFields[0].validate({ allowEmpty: false, focused: true });
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onPhasePasswordInputSubmit(): Promise<void> {
|
||||||
|
if (!await this.verifyFieldsBeforeSubmit()) return;
|
||||||
|
|
||||||
|
if (this.state.logoutDevices) {
|
||||||
|
const logoutDevicesConfirmation = await this.renderConfirmLogoutDevicesDialog();
|
||||||
|
if (!logoutDevicesConfirmation) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.phase = Phase.ResettingPassword;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.handleHttpRequest(this.reset.checkEmailLinkClicked());
|
await this.reset.setNewPassword(this.state.password);
|
||||||
this.setState({ phase: Phase.Done });
|
} catch (err: any) {
|
||||||
} catch (err) {
|
if (err.httpStatus !== 401) {
|
||||||
this.showErrorDialog(err.message);
|
// 401 = waiting for email verification, else unknown error
|
||||||
|
this.handleError(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = Modal.createDialog(
|
||||||
|
VerifyEmailModal,
|
||||||
|
{
|
||||||
|
email: this.state.email,
|
||||||
|
errorText: this.state.errorText,
|
||||||
|
onResendClick: this.sendVerificationMail,
|
||||||
|
},
|
||||||
|
"mx_VerifyEMailDialog",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
{
|
||||||
|
// this modal cannot be dismissed except reset is done or forced
|
||||||
|
onBeforeClose: async (reason?: string) => {
|
||||||
|
return this.state.phase === Phase.Done || reason === "force";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.reset.retrySetNewPassword(this.state.password);
|
||||||
|
this.phase = Phase.Done;
|
||||||
|
modal.close();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
|
private onSubmitForm = async (ev: React.FormEvent): Promise<void> => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (this.state.currentHttpRequest) return;
|
|
||||||
|
|
||||||
// refresh the server errors, just in case the server came back online
|
// Should not happen because of disabled forms, but just return if currently doing an action.
|
||||||
await this.handleHttpRequest(this.checkServerLiveliness(this.props.serverConfig));
|
if ([Phase.SendingEmail, Phase.ResettingPassword].includes(this.state.phase)) return;
|
||||||
|
|
||||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
this.setState({
|
||||||
if (!allFieldsValid) {
|
errorText: "",
|
||||||
return;
|
});
|
||||||
|
|
||||||
|
// Refresh the server errors. Just in case the server came back online of went offline.
|
||||||
|
await this.checkServerLiveliness(this.props.serverConfig);
|
||||||
|
|
||||||
|
// Server error
|
||||||
|
if (!this.state.serverIsAlive) return;
|
||||||
|
|
||||||
|
switch (this.state.phase) {
|
||||||
|
case Phase.EnterEmail:
|
||||||
|
this.onPhaseEmailInputSubmit();
|
||||||
|
break;
|
||||||
|
case Phase.EmailSent:
|
||||||
|
this.onPhaseEmailSentSubmit();
|
||||||
|
break;
|
||||||
|
case Phase.PasswordInput:
|
||||||
|
this.onPhasePasswordInputSubmit();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
|
||||||
|
let value = ev.currentTarget.value;
|
||||||
|
if (stateKey === "email") value = value.trim();
|
||||||
|
this.setState({
|
||||||
|
[stateKey]: value,
|
||||||
|
} as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
renderEnterEmail(): JSX.Element {
|
||||||
|
return <EnterEmail
|
||||||
|
email={this.state.email}
|
||||||
|
errorText={this.state.errorText}
|
||||||
|
homeserver={this.props.serverConfig.hsName}
|
||||||
|
loading={this.state.phase === Phase.SendingEmail}
|
||||||
|
onInputChanged={this.onInputChanged}
|
||||||
|
onSubmitForm={this.onSubmitForm}
|
||||||
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.state.logoutDevices) {
|
async renderConfirmLogoutDevicesDialog(): Promise<boolean> {
|
||||||
const { finished } = Modal.createDialog<[boolean]>(QuestionDialog, {
|
const { finished } = Modal.createDialog<[boolean]>(QuestionDialog, {
|
||||||
title: _t('Warning!'),
|
title: _t('Warning!'),
|
||||||
description:
|
description:
|
||||||
@ -222,131 +367,47 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||||||
button: _t('Continue'),
|
button: _t('Continue'),
|
||||||
});
|
});
|
||||||
const [confirmed] = await finished;
|
const [confirmed] = await finished;
|
||||||
|
return confirmed;
|
||||||
if (!confirmed) return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.submitPasswordReset(this.state.email, this.state.password, this.state.logoutDevices);
|
renderCheckEmail(): JSX.Element {
|
||||||
};
|
return <CheckEmail
|
||||||
|
email={this.state.email}
|
||||||
private async verifyFieldsBeforeSubmit() {
|
errorText={this.state.errorText}
|
||||||
const fieldIdsInDisplayOrder = [
|
onResendClick={this.sendVerificationMail}
|
||||||
ForgotPasswordField.Email,
|
onSubmitForm={this.onSubmitForm}
|
||||||
ForgotPasswordField.Password,
|
/>;
|
||||||
ForgotPasswordField.PasswordConfirm,
|
|
||||||
];
|
|
||||||
|
|
||||||
const invalidFields = [];
|
|
||||||
for (const fieldId of fieldIdsInDisplayOrder) {
|
|
||||||
const valid = await this[fieldId].validate({ allowEmpty: false });
|
|
||||||
if (!valid) {
|
|
||||||
invalidFields.push(this[fieldId]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invalidFields.length === 0) {
|
renderSetPassword(): JSX.Element {
|
||||||
return true;
|
const submitButtonChild = this.state.phase === Phase.ResettingPassword
|
||||||
}
|
? <Spinner w={16} h={16} />
|
||||||
|
: _t("Reset password");
|
||||||
|
|
||||||
// Focus on the first invalid field, then re-validate,
|
return <>
|
||||||
// which will result in the error tooltip being displayed for that field.
|
<LockIcon className="mx_AuthBody_lockIcon" />
|
||||||
invalidFields[0].focus();
|
<h1>{ _t("Reset your password") }</h1>
|
||||||
invalidFields[0].validate({ allowEmpty: false, focused: true });
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private onInputChanged = (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => {
|
|
||||||
let value = ev.currentTarget.value;
|
|
||||||
if (stateKey === "email") value = value.trim();
|
|
||||||
this.setState({
|
|
||||||
[stateKey]: value,
|
|
||||||
} as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
private onLoginClick = (ev: React.MouseEvent): void => {
|
|
||||||
ev.preventDefault();
|
|
||||||
ev.stopPropagation();
|
|
||||||
this.props.onLoginClick();
|
|
||||||
};
|
|
||||||
|
|
||||||
public showErrorDialog(description: string, title?: string) {
|
|
||||||
Modal.createDialog(ErrorDialog, {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleHttpRequest<T = unknown>(request: Promise<T>): Promise<T> {
|
|
||||||
this.setState({
|
|
||||||
currentHttpRequest: request,
|
|
||||||
});
|
|
||||||
return request.finally(() => {
|
|
||||||
this.setState({
|
|
||||||
currentHttpRequest: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderForgot() {
|
|
||||||
let errorText = null;
|
|
||||||
const err = this.state.errorText;
|
|
||||||
if (err) {
|
|
||||||
errorText = <div className="mx_Login_error">{ err }</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let serverDeadSection;
|
|
||||||
if (!this.state.serverIsAlive) {
|
|
||||||
const classes = classNames({
|
|
||||||
"mx_Login_error": true,
|
|
||||||
"mx_Login_serverError": true,
|
|
||||||
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
|
|
||||||
});
|
|
||||||
serverDeadSection = (
|
|
||||||
<div className={classes}>
|
|
||||||
{ this.state.serverDeadError }
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div>
|
|
||||||
{ errorText }
|
|
||||||
{ serverDeadSection }
|
|
||||||
<ServerPicker
|
|
||||||
serverConfig={this.props.serverConfig}
|
|
||||||
onServerConfigChange={this.props.onServerConfigChange}
|
|
||||||
/>
|
|
||||||
<form onSubmit={this.onSubmitForm}>
|
<form onSubmit={this.onSubmitForm}>
|
||||||
<div className="mx_AuthBody_fieldRow">
|
<fieldset disabled={this.state.phase === Phase.ResettingPassword}>
|
||||||
<EmailField
|
|
||||||
name="reset_email" // define a name so browser's password autofill gets less confused
|
|
||||||
labelRequired={_td('The email address linked to your account must be entered.')}
|
|
||||||
labelInvalid={_td("The email address doesn't appear to be valid.")}
|
|
||||||
value={this.state.email}
|
|
||||||
fieldRef={field => this[ForgotPasswordField.Email] = field}
|
|
||||||
autoFocus={true}
|
|
||||||
onChange={this.onInputChanged.bind(this, "email")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mx_AuthBody_fieldRow">
|
<div className="mx_AuthBody_fieldRow">
|
||||||
<PassphraseField
|
<PassphraseField
|
||||||
name="reset_password"
|
name="reset_password"
|
||||||
type="password"
|
type="password"
|
||||||
label={_td('New Password')}
|
label={_td("New Password")}
|
||||||
value={this.state.password}
|
value={this.state.password}
|
||||||
minScore={PASSWORD_MIN_SCORE}
|
minScore={PASSWORD_MIN_SCORE}
|
||||||
fieldRef={field => this[ForgotPasswordField.Password] = field}
|
fieldRef={field => this.fieldPassword = field}
|
||||||
onChange={this.onInputChanged.bind(this, "password")}
|
onChange={this.onInputChanged.bind(this, "password")}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<PassphraseConfirmField
|
<PassphraseConfirmField
|
||||||
name="reset_password_confirm"
|
name="reset_password_confirm"
|
||||||
label={_td('Confirm')}
|
label={_td("Confirm new password")}
|
||||||
labelRequired={_td("A new password must be entered.")}
|
labelRequired={_td("A new password must be entered.")}
|
||||||
labelInvalid={_td("New passwords must match each other.")}
|
labelInvalid={_td("New passwords must match each other.")}
|
||||||
value={this.state.password2}
|
value={this.state.password2}
|
||||||
password={this.state.password}
|
password={this.state.password}
|
||||||
fieldRef={field => this[ForgotPasswordField.PasswordConfirm] = field}
|
fieldRef={field => this.fieldPasswordConfirm = field}
|
||||||
onChange={this.onInputChanged.bind(this, "password2")}
|
onChange={this.onInputChanged.bind(this, "password2")}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
@ -354,49 +415,26 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||||||
{ this.state.serverSupportsControlOfDevicesLogout ?
|
{ this.state.serverSupportsControlOfDevicesLogout ?
|
||||||
<div className="mx_AuthBody_fieldRow">
|
<div className="mx_AuthBody_fieldRow">
|
||||||
<StyledCheckbox onChange={() => this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}>
|
<StyledCheckbox onChange={() => this.setState({ logoutDevices: !this.state.logoutDevices })} checked={this.state.logoutDevices}>
|
||||||
{ _t("Sign out all devices") }
|
{ _t("Sign out of all devices") }
|
||||||
</StyledCheckbox>
|
</StyledCheckbox>
|
||||||
</div> : null
|
</div> : null
|
||||||
}
|
}
|
||||||
<span>{ _t(
|
{ this.state.errorText && <ErrorMessage message={this.state.errorText} /> }
|
||||||
'A verification email will be sent to your inbox to confirm ' +
|
<button
|
||||||
'setting your new password.',
|
|
||||||
) }</span>
|
|
||||||
<input
|
|
||||||
className="mx_Login_submit"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
value={_t('Send Reset Email')}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<AccessibleButton kind='link' className="mx_AuthBody_changeFlow" onClick={this.onLoginClick}>
|
|
||||||
{ _t('Sign in instead') }
|
|
||||||
</AccessibleButton>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderSendingEmail() {
|
|
||||||
return <Spinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderEmailSent() {
|
|
||||||
return <div>
|
|
||||||
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the " +
|
|
||||||
"link it contains, click below.", { emailAddress: this.state.email }) }
|
|
||||||
<br />
|
|
||||||
<input
|
|
||||||
className="mx_Login_submit"
|
className="mx_Login_submit"
|
||||||
type="button"
|
>
|
||||||
onClick={this.onVerify}
|
{ submitButtonChild }
|
||||||
value={_t('I have verified my email address')} />
|
</button>
|
||||||
{ this.state.currentHttpRequest && (
|
</fieldset>
|
||||||
<div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>)
|
</form>
|
||||||
}
|
</>;
|
||||||
</div>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderDone() {
|
renderDone() {
|
||||||
return <div>
|
return <>
|
||||||
<p>{ _t("Your password has been reset.") }</p>
|
<CheckboxIcon className="mx_Icon mx_Icon_32 mx_Icon_accent" />
|
||||||
|
<h1>{ _t("Your password has been reset.") }</h1>
|
||||||
{ this.state.logoutDevices ?
|
{ this.state.logoutDevices ?
|
||||||
<p>{ _t(
|
<p>{ _t(
|
||||||
"You have been logged out of all devices and will no longer receive " +
|
"You have been logged out of all devices and will no longer receive " +
|
||||||
@ -410,33 +448,40 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={this.props.onComplete}
|
onClick={this.props.onComplete}
|
||||||
value={_t('Return to login screen')} />
|
value={_t('Return to login screen')} />
|
||||||
</div>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let resetPasswordJsx;
|
let resetPasswordJsx: JSX.Element;
|
||||||
|
|
||||||
switch (this.state.phase) {
|
switch (this.state.phase) {
|
||||||
case Phase.Forgot:
|
case Phase.EnterEmail:
|
||||||
resetPasswordJsx = this.renderForgot();
|
|
||||||
break;
|
|
||||||
case Phase.SendingEmail:
|
case Phase.SendingEmail:
|
||||||
resetPasswordJsx = this.renderSendingEmail();
|
resetPasswordJsx = this.renderEnterEmail();
|
||||||
break;
|
break;
|
||||||
case Phase.EmailSent:
|
case Phase.EmailSent:
|
||||||
resetPasswordJsx = this.renderEmailSent();
|
resetPasswordJsx = this.renderCheckEmail();
|
||||||
|
break;
|
||||||
|
case Phase.PasswordInput:
|
||||||
|
case Phase.ResettingPassword:
|
||||||
|
resetPasswordJsx = this.renderSetPassword();
|
||||||
break;
|
break;
|
||||||
case Phase.Done:
|
case Phase.Done:
|
||||||
resetPasswordJsx = this.renderDone();
|
resetPasswordJsx = this.renderDone();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
resetPasswordJsx = <div className="mx_Login_spinner"><InlineSpinner w={64} h={64} /></div>;
|
// This should not happen. However, it is logged and the user is sent to the start.
|
||||||
|
logger.warn(`unknown forgot password phase ${this.state.phase}`);
|
||||||
|
this.setState({
|
||||||
|
phase: Phase.EnterEmail,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPage>
|
<AuthPage>
|
||||||
<AuthHeader />
|
<AuthHeader />
|
||||||
<AuthBody>
|
<AuthBody>
|
||||||
<h1> { _t('Set a new password') } </h1>
|
|
||||||
{ resetPasswordJsx }
|
{ resetPasswordJsx }
|
||||||
</AuthBody>
|
</AuthBody>
|
||||||
</AuthPage>
|
</AuthPage>
|
||||||
|
@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
|
import AccessibleButton from "../../../views/elements/AccessibleButton";
|
||||||
|
import { Icon as EMailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
|
||||||
|
import { Icon as RetryIcon } from "../../../../../res/img/element-icons/retry.svg";
|
||||||
|
import { _t } from '../../../../languageHandler';
|
||||||
|
import Tooltip, { Alignment } from "../../../views/elements/Tooltip";
|
||||||
|
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
|
||||||
|
import { ErrorMessage } from "../../ErrorMessage";
|
||||||
|
|
||||||
|
interface CheckEmailProps {
|
||||||
|
email: string;
|
||||||
|
errorText: string | ReactNode | null;
|
||||||
|
onResendClick: () => Promise<boolean>;
|
||||||
|
onSubmitForm: (ev: React.FormEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component renders the email verification view of the forgot password flow.
|
||||||
|
*/
|
||||||
|
export const CheckEmail: React.FC<CheckEmailProps> = ({
|
||||||
|
email,
|
||||||
|
errorText,
|
||||||
|
onSubmitForm,
|
||||||
|
onResendClick,
|
||||||
|
}) => {
|
||||||
|
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
|
||||||
|
|
||||||
|
const onResendClickFn = async (): Promise<void> => {
|
||||||
|
await onResendClick();
|
||||||
|
toggleTooltipVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<EMailPromptIcon className="mx_AuthBody_emailPromptIcon--shifted" />
|
||||||
|
<h1>{ _t("Check your email to continue") }</h1>
|
||||||
|
<p>
|
||||||
|
{ _t(
|
||||||
|
"Follow the instructions sent to <b>%(email)s</b>",
|
||||||
|
{ email: email },
|
||||||
|
{ b: t => <b>{ t }</b> },
|
||||||
|
) }
|
||||||
|
</p>
|
||||||
|
<div className="mx_AuthBody_did-not-receive">
|
||||||
|
<span className="mx_VerifyEMailDialog_text-light">{ _t("Did not receive it?") }</span>
|
||||||
|
<AccessibleButton
|
||||||
|
className="mx_AuthBody_resend-button"
|
||||||
|
kind="link"
|
||||||
|
onClick={onResendClickFn}
|
||||||
|
>
|
||||||
|
<RetryIcon className="mx_Icon mx_Icon_16" />
|
||||||
|
{ _t("Resend") }
|
||||||
|
<Tooltip
|
||||||
|
label={_t("Verification link email resent!")}
|
||||||
|
alignment={Alignment.Top}
|
||||||
|
visible={tooltipVisible}
|
||||||
|
/>
|
||||||
|
</AccessibleButton>
|
||||||
|
</div>
|
||||||
|
{ errorText && <ErrorMessage message={errorText} /> }
|
||||||
|
<input
|
||||||
|
onClick={onSubmitForm}
|
||||||
|
type="button"
|
||||||
|
className="mx_Login_submit"
|
||||||
|
value={_t("Next")}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
};
|
@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { ReactNode, useRef } from "react";
|
||||||
|
|
||||||
|
import { Icon as EmailIcon } from "../../../../../res/img/element-icons/Email-icon.svg";
|
||||||
|
import { _t, _td } from '../../../../languageHandler';
|
||||||
|
import EmailField from "../../../views/auth/EmailField";
|
||||||
|
import { ErrorMessage } from "../../ErrorMessage";
|
||||||
|
import Spinner from "../../../views/elements/Spinner";
|
||||||
|
import Field from "../../../views/elements/Field";
|
||||||
|
|
||||||
|
interface EnterEmailProps {
|
||||||
|
email: string;
|
||||||
|
errorText: string | ReactNode | null;
|
||||||
|
homeserver: string;
|
||||||
|
loading: boolean;
|
||||||
|
onInputChanged: (stateKey: string, ev: React.FormEvent<HTMLInputElement>) => void;
|
||||||
|
onSubmitForm: (ev: React.FormEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component renders the email input view of the forgot password flow.
|
||||||
|
*/
|
||||||
|
export const EnterEmail: React.FC<EnterEmailProps> = ({
|
||||||
|
email,
|
||||||
|
errorText,
|
||||||
|
homeserver,
|
||||||
|
loading,
|
||||||
|
onInputChanged,
|
||||||
|
onSubmitForm,
|
||||||
|
}) => {
|
||||||
|
const submitButtonChild = loading
|
||||||
|
? <Spinner w={16} h={16} />
|
||||||
|
: _t("Send email");
|
||||||
|
|
||||||
|
const emailFieldRef = useRef<Field>(null);
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent) => {
|
||||||
|
if (await emailFieldRef.current?.validate({ allowEmpty: false })) {
|
||||||
|
onSubmitForm(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emailFieldRef.current?.focus();
|
||||||
|
emailFieldRef.current?.validate({ allowEmpty: false, focused: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<EmailIcon className="mx_AuthBody_icon" />
|
||||||
|
<h1>{ _t("Enter your email to reset password") }</h1>
|
||||||
|
<p className="mx_AuthBody_text">
|
||||||
|
{
|
||||||
|
_t(
|
||||||
|
"<b>%(homeserver)s</b> will send you a verification link to let you reset your password.",
|
||||||
|
{ homeserver },
|
||||||
|
{ b: t => <b>{ t }</b> },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<fieldset disabled={loading}>
|
||||||
|
<div className="mx_AuthBody_fieldRow">
|
||||||
|
<EmailField
|
||||||
|
name="reset_email" // define a name so browser's password autofill gets less confused
|
||||||
|
label="Email address"
|
||||||
|
labelRequired={_td("The email address linked to your account must be entered.")}
|
||||||
|
labelInvalid={_td("The email address doesn't appear to be valid.")}
|
||||||
|
value={email}
|
||||||
|
autoFocus={true}
|
||||||
|
onChange={(event: React.FormEvent<HTMLInputElement>) => onInputChanged("email", event)}
|
||||||
|
fieldRef={emailFieldRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{ errorText && <ErrorMessage message={errorText} /> }
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="mx_Login_submit"
|
||||||
|
>
|
||||||
|
{ submitButtonChild }
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</>;
|
||||||
|
};
|
@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { _t } from "../../../../languageHandler";
|
||||||
|
import AccessibleButton from "../../../views/elements/AccessibleButton";
|
||||||
|
import { Icon as RetryIcon } from "../../../../../res/img/element-icons/retry.svg";
|
||||||
|
import { Icon as EmailPromptIcon } from "../../../../../res/img/element-icons/email-prompt.svg";
|
||||||
|
import Tooltip, { Alignment } from "../../../views/elements/Tooltip";
|
||||||
|
import { useTimeoutToggle } from "../../../../hooks/useTimeoutToggle";
|
||||||
|
import { ErrorMessage } from "../../ErrorMessage";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
email: string;
|
||||||
|
errorText: string | null;
|
||||||
|
onResendClick: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerifyEmailModal: React.FC<Props> = ({
|
||||||
|
email,
|
||||||
|
errorText,
|
||||||
|
onResendClick,
|
||||||
|
}) => {
|
||||||
|
const { toggle: toggleTooltipVisible, value: tooltipVisible } = useTimeoutToggle(false, 2500);
|
||||||
|
|
||||||
|
const onResendClickFn = async (): Promise<void> => {
|
||||||
|
await onResendClick();
|
||||||
|
toggleTooltipVisible();
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<EmailPromptIcon className="mx_AuthBody_emailPromptIcon" />
|
||||||
|
<h1>{ _t("Verify your email to continue") }</h1>
|
||||||
|
<p>
|
||||||
|
{ _t(
|
||||||
|
`We need to know it’s you before resetting your password.
|
||||||
|
Click the link in the email we just sent to <b>%(email)s</b>`,
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
b: sub => <b>{ sub }</b>,
|
||||||
|
},
|
||||||
|
) }
|
||||||
|
</p>
|
||||||
|
<div className="mx_AuthBody_did-not-receive mx_AuthBody_did-not-receive--centered">
|
||||||
|
<span className="mx_VerifyEMailDialog_text-light">{ _t("Did not receive it?") }</span>
|
||||||
|
<AccessibleButton
|
||||||
|
className="mx_AuthBody_resend-button"
|
||||||
|
kind="link"
|
||||||
|
onClick={onResendClickFn}
|
||||||
|
>
|
||||||
|
<RetryIcon className="mx_Icon mx_Icon_16" />
|
||||||
|
{ _t("Resend") }
|
||||||
|
<Tooltip
|
||||||
|
label={_t("Verification link email resent!")}
|
||||||
|
alignment={Alignment.Top}
|
||||||
|
visible={tooltipVisible}
|
||||||
|
/>
|
||||||
|
</AccessibleButton>
|
||||||
|
{ errorText && <ErrorMessage message={errorText} /> }
|
||||||
|
</div>
|
||||||
|
</>;
|
||||||
|
};
|
@ -290,7 +290,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||||||
let fieldTooltip;
|
let fieldTooltip;
|
||||||
if (tooltipContent || this.state.feedback) {
|
if (tooltipContent || this.state.feedback) {
|
||||||
fieldTooltip = <Tooltip
|
fieldTooltip = <Tooltip
|
||||||
tooltipClassName={classNames("mx_Field_tooltip", tooltipClassName)}
|
tooltipClassName={classNames("mx_Field_tooltip", "mx_Tooltip_noMargin", tooltipClassName)}
|
||||||
visible={(this.state.focused && forceTooltipVisible) || this.state.feedbackVisible}
|
visible={(this.state.focused && forceTooltipVisible) || this.state.feedbackVisible}
|
||||||
label={tooltipContent || this.state.feedback}
|
label={tooltipContent || this.state.feedback}
|
||||||
alignment={Tooltip.Alignment.Right}
|
alignment={Tooltip.Alignment.Right}
|
||||||
|
44
src/hooks/useTimeoutToggle.ts
Normal file
44
src/hooks/useTimeoutToggle.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that allows toggling a boolean value and resets it after a timeout.
|
||||||
|
*
|
||||||
|
* @param {boolean} defaultValue Default value
|
||||||
|
* @param {number} timeoutMs Time after that the value will be reset
|
||||||
|
*/
|
||||||
|
export const useTimeoutToggle = (defaultValue: boolean, timeoutMs: number) => {
|
||||||
|
const timeoutId = useRef<number | undefined>();
|
||||||
|
const [value, setValue] = useState<boolean>(defaultValue);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setValue(!defaultValue);
|
||||||
|
timeoutId.current = setTimeout(() => setValue(defaultValue), timeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId.current);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggle,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
};
|
@ -3424,24 +3424,20 @@
|
|||||||
"Device verified": "Device verified",
|
"Device verified": "Device verified",
|
||||||
"Really reset verification keys?": "Really reset verification keys?",
|
"Really reset verification keys?": "Really reset verification keys?",
|
||||||
"Skip verification for now": "Skip verification for now",
|
"Skip verification for now": "Skip verification for now",
|
||||||
"Failed to send email": "Failed to send email",
|
"Too many attempts in a short time. Wait some time before trying again.": "Too many attempts in a short time. Wait some time before trying again.",
|
||||||
|
"Too many attempts in a short time. Retry after %(timeout)s.": "Too many attempts in a short time. Retry after %(timeout)s.",
|
||||||
"Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
|
"Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Resetting your password on this homeserver will cause all of your devices to be signed out. This will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
|
||||||
"Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
|
"Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.": "Signing out your devices will delete the message encryption keys stored on them, making encrypted chat history unreadable.",
|
||||||
"If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.": "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.",
|
"If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.": "If you want to retain access to your chat history in encrypted rooms, set up Key Backup or export your message keys from one of your other devices before proceeding.",
|
||||||
"The email address linked to your account must be entered.": "The email address linked to your account must be entered.",
|
"Reset password": "Reset password",
|
||||||
"The email address doesn't appear to be valid.": "The email address doesn't appear to be valid.",
|
"Reset your password": "Reset your password",
|
||||||
|
"Confirm new password": "Confirm new password",
|
||||||
"A new password must be entered.": "A new password must be entered.",
|
"A new password must be entered.": "A new password must be entered.",
|
||||||
"New passwords must match each other.": "New passwords must match each other.",
|
"New passwords must match each other.": "New passwords must match each other.",
|
||||||
"Sign out all devices": "Sign out all devices",
|
"Sign out of all devices": "Sign out of all devices",
|
||||||
"A verification email will be sent to your inbox to confirm setting your new password.": "A verification email will be sent to your inbox to confirm setting your new password.",
|
|
||||||
"Send Reset Email": "Send Reset Email",
|
|
||||||
"Sign in instead": "Sign in instead",
|
|
||||||
"An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.",
|
|
||||||
"I have verified my email address": "I have verified my email address",
|
|
||||||
"Your password has been reset.": "Your password has been reset.",
|
"Your password has been reset.": "Your password has been reset.",
|
||||||
"You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.",
|
"You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.": "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device.",
|
||||||
"Return to login screen": "Return to login screen",
|
"Return to login screen": "Return to login screen",
|
||||||
"Set a new password": "Set a new password",
|
|
||||||
"Invalid homeserver discovery response": "Invalid homeserver discovery response",
|
"Invalid homeserver discovery response": "Invalid homeserver discovery response",
|
||||||
"Failed to get autodiscovery configuration from server": "Failed to get autodiscovery configuration from server",
|
"Failed to get autodiscovery configuration from server": "Failed to get autodiscovery configuration from server",
|
||||||
"Invalid base_url for m.homeserver": "Invalid base_url for m.homeserver",
|
"Invalid base_url for m.homeserver": "Invalid base_url for m.homeserver",
|
||||||
@ -3501,6 +3497,16 @@
|
|||||||
"You're signed out": "You're signed out",
|
"You're signed out": "You're signed out",
|
||||||
"Clear personal data": "Clear personal data",
|
"Clear personal data": "Clear personal data",
|
||||||
"Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.",
|
"Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.": "Warning: Your personal data (including encryption keys) is still stored in this session. Clear it if you're finished using this session, or want to sign in to another account.",
|
||||||
|
"Follow the instructions sent to <b>%(email)s</b>": "Follow the instructions sent to <b>%(email)s</b>",
|
||||||
|
"Did not receive it?": "Did not receive it?",
|
||||||
|
"Verification link email resent!": "Verification link email resent!",
|
||||||
|
"Send email": "Send email",
|
||||||
|
"Enter your email to reset password": "Enter your email to reset password",
|
||||||
|
"<b>%(homeserver)s</b> will send you a verification link to let you reset your password.": "<b>%(homeserver)s</b> will send you a verification link to let you reset your password.",
|
||||||
|
"The email address linked to your account must be entered.": "The email address linked to your account must be entered.",
|
||||||
|
"The email address doesn't appear to be valid.": "The email address doesn't appear to be valid.",
|
||||||
|
"Verify your email to continue": "Verify your email to continue",
|
||||||
|
"We need to know it’s you before resetting your password.\n Click the link in the email we just sent to <b>%(email)s</b>": "We need to know it’s you before resetting your password.\n Click the link in the email we just sent to <b>%(email)s</b>",
|
||||||
"Commands": "Commands",
|
"Commands": "Commands",
|
||||||
"Command Autocomplete": "Command Autocomplete",
|
"Command Autocomplete": "Command Autocomplete",
|
||||||
"Emoji Autocomplete": "Emoji Autocomplete",
|
"Emoji Autocomplete": "Emoji Autocomplete",
|
||||||
|
@ -14,89 +14,287 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react";
|
import { mocked } from "jest-mock";
|
||||||
import { createClient, MatrixClient } from 'matrix-js-sdk/src/matrix';
|
import { act, render, RenderResult, screen } from "@testing-library/react";
|
||||||
import { mocked } from 'jest-mock';
|
import userEvent from "@testing-library/user-event";
|
||||||
import fetchMock from "fetch-mock-jest";
|
import { MatrixClient, createClient } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import SdkConfig, { DEFAULTS } from '../../../../src/SdkConfig';
|
|
||||||
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../test-utils";
|
|
||||||
import ForgotPassword from "../../../../src/components/structures/auth/ForgotPassword";
|
import ForgotPassword from "../../../../src/components/structures/auth/ForgotPassword";
|
||||||
import PasswordReset from "../../../../src/PasswordReset";
|
import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig";
|
||||||
|
import { flushPromisesWithFakeTimers, stubClient } from "../../../test-utils";
|
||||||
|
import Modal from "../../../../src/Modal";
|
||||||
|
import AutoDiscoveryUtils from "../../../../src/utils/AutoDiscoveryUtils";
|
||||||
|
|
||||||
jest.mock('matrix-js-sdk/src/matrix');
|
jest.mock("matrix-js-sdk/src/matrix", () => ({
|
||||||
jest.mock("../../../../src/PasswordReset", () => (jest.fn().mockReturnValue({
|
...jest.requireActual("matrix-js-sdk/src/matrix"),
|
||||||
resetPassword: jest.fn().mockReturnValue(new Promise(() => {})),
|
createClient: jest.fn(),
|
||||||
})));
|
}));
|
||||||
jest.useFakeTimers();
|
|
||||||
|
|
||||||
describe('<ForgotPassword/>', () => {
|
describe("<ForgotPassword>", () => {
|
||||||
const mockClient = mocked({
|
const testEmail = "user@example.com";
|
||||||
doesServerSupportLogoutDevices: jest.fn().mockResolvedValue(true),
|
const testSid = "sid42";
|
||||||
} as unknown as MatrixClient);
|
const testPassword = "cRaZyP4ssw0rd!";
|
||||||
|
let client: MatrixClient;
|
||||||
|
let serverConfig: ValidatedServerConfig;
|
||||||
|
let onComplete: () => void;
|
||||||
|
let renderResult: RenderResult;
|
||||||
|
|
||||||
beforeEach(function() {
|
const typeIntoField = async (label: string, value: string): Promise<void> => {
|
||||||
SdkConfig.put({
|
await act(async () => {
|
||||||
...DEFAULTS,
|
await userEvent.type(screen.getByLabelText(label), value, { delay: null });
|
||||||
disable_custom_urls: true,
|
// the message is shown after some time
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
});
|
});
|
||||||
mocked(createClient).mockImplementation(opts => {
|
|
||||||
mockClient.idBaseUrl = opts.idBaseUrl;
|
|
||||||
mockClient.baseUrl = opts.baseUrl;
|
|
||||||
return mockClient;
|
|
||||||
});
|
|
||||||
fetchMock.get("https://matrix.org/_matrix/client/versions", {
|
|
||||||
unstable_features: {},
|
|
||||||
versions: [],
|
|
||||||
});
|
|
||||||
mockPlatformPeg({
|
|
||||||
startSingleSignOn: jest.fn(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function() {
|
|
||||||
fetchMock.restore();
|
|
||||||
SdkConfig.unset(); // we touch the config, so clean up
|
|
||||||
unmockPlatformPeg();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
defaultDeviceDisplayName: 'test-device-display-name',
|
|
||||||
onServerConfigChange: jest.fn(),
|
|
||||||
onLoginClick: jest.fn(),
|
|
||||||
onComplete: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRawComponent(hsUrl = "https://matrix.org", isUrl = "https://vector.im") {
|
const submitForm = async (submitLabel: string): Promise<void> => {
|
||||||
return <ForgotPassword
|
await act(async () => {
|
||||||
{...defaultProps}
|
await userEvent.click(screen.getByText(submitLabel), { delay: null });
|
||||||
serverConfig={mkServerConfig(hsUrl, isUrl)}
|
|
||||||
/>;
|
|
||||||
}
|
|
||||||
|
|
||||||
it("should handle serverConfig updates correctly", async () => {
|
|
||||||
const { container, rerender } = render(getRawComponent());
|
|
||||||
await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading..."));
|
|
||||||
|
|
||||||
fetchMock.get("https://server2/_matrix/client/versions", {
|
|
||||||
unstable_features: {},
|
|
||||||
versions: [],
|
|
||||||
});
|
});
|
||||||
fetchMock.get("https://vector.im/_matrix/identity/api/v1", {});
|
};
|
||||||
rerender(getRawComponent("https://server2"));
|
|
||||||
|
|
||||||
const email = "email@addy.com";
|
beforeEach(() => {
|
||||||
const pass = "thisIsAT0tallySecurePassword";
|
client = stubClient();
|
||||||
|
mocked(createClient).mockReturnValue(client);
|
||||||
|
|
||||||
fireEvent.change(container.querySelector('[label=Email]'), { target: { value: email } });
|
serverConfig = new ValidatedServerConfig();
|
||||||
fireEvent.change(container.querySelector('[label="New Password"]'), { target: { value: pass } });
|
serverConfig.hsName = "example.com";
|
||||||
fireEvent.change(container.querySelector('[label=Confirm]'), { target: { value: pass } });
|
|
||||||
fireEvent.change(container.querySelector('[type=checkbox]')); // this allows us to bypass the modal
|
|
||||||
fireEvent.submit(container.querySelector("form"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
onComplete = jest.fn();
|
||||||
return expect(PasswordReset).toHaveBeenCalledWith("https://server2", expect.anything());
|
|
||||||
}, { timeout: 5000 });
|
jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue(serverConfig);
|
||||||
|
jest.spyOn(AutoDiscoveryUtils, "authComponentStateForError");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// clean up modals
|
||||||
|
Modal.closeCurrentModal("force");
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when starting a password reset flow", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
renderResult = render(<ForgotPassword
|
||||||
|
serverConfig={serverConfig}
|
||||||
|
onComplete={onComplete}
|
||||||
|
/>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the email input and mention the homeserver", () => {
|
||||||
|
expect(screen.queryByLabelText("Email address")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("example.com")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("and updating the server config", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
serverConfig.hsName = "example2.com";
|
||||||
|
renderResult.rerender(<ForgotPassword
|
||||||
|
serverConfig={serverConfig}
|
||||||
|
onComplete={onComplete}
|
||||||
|
/>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the new homeserver server name", () => {
|
||||||
|
expect(screen.queryByText("example2.com")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when entering a non-email value", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await typeIntoField("Email address", "not en email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show a message about the wrong format", () => {
|
||||||
|
expect(screen.getByText("The email address doesn't appear to be valid.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when submitting an unknown email", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await typeIntoField("Email address", testEmail);
|
||||||
|
mocked(client).requestPasswordEmailToken.mockRejectedValue({
|
||||||
|
errcode: "M_THREEPID_NOT_FOUND",
|
||||||
|
});
|
||||||
|
await submitForm("Send email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show an email not found message", () => {
|
||||||
|
expect(screen.getByText("This email address was not found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when a connection error occurs", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await typeIntoField("Email address", testEmail);
|
||||||
|
mocked(client).requestPasswordEmailToken.mockRejectedValue({
|
||||||
|
name: "ConnectionError",
|
||||||
|
});
|
||||||
|
await submitForm("Send email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show an info about that", () => {
|
||||||
|
expect(screen.getByText(
|
||||||
|
"Cannot reach homeserver: "
|
||||||
|
+ "Ensure you have a stable internet connection, or get in touch with the server admin",
|
||||||
|
)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when the server liveness check fails", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await typeIntoField("Email address", testEmail);
|
||||||
|
mocked(AutoDiscoveryUtils.validateServerConfigWithStaticUrls).mockRejectedValue({});
|
||||||
|
mocked(AutoDiscoveryUtils.authComponentStateForError).mockReturnValue({
|
||||||
|
serverErrorIsFatal: true,
|
||||||
|
serverIsAlive: false,
|
||||||
|
serverDeadError: "server down",
|
||||||
|
});
|
||||||
|
await submitForm("Send email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the server error", () => {
|
||||||
|
expect(screen.queryByText("server down")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when submitting an known email", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await typeIntoField("Email address", testEmail);
|
||||||
|
mocked(client).requestPasswordEmailToken.mockResolvedValue({
|
||||||
|
sid: testSid,
|
||||||
|
});
|
||||||
|
await submitForm("Send email");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send the mail and show the check email view", () => {
|
||||||
|
expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
|
||||||
|
testEmail,
|
||||||
|
expect.any(String),
|
||||||
|
1, // second send attempt
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Check your email to continue")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(testEmail)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking resend email", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await userEvent.click(screen.getByText("Resend"), { delay: null });
|
||||||
|
// the message is shown after some time
|
||||||
|
jest.advanceTimersByTime(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should should resend the mail and show the tooltip", () => {
|
||||||
|
expect(client.requestPasswordEmailToken).toHaveBeenCalledWith(
|
||||||
|
testEmail,
|
||||||
|
expect.any(String),
|
||||||
|
2, // second send attempt
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Verification link email resent!")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when clicking next", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await submitForm("Next");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the password input view", () => {
|
||||||
|
expect(screen.getByText("Reset your password")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when entering different passwords", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await typeIntoField("New Password", testPassword);
|
||||||
|
await typeIntoField("Confirm new password", testPassword + "asd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show an info about that", () => {
|
||||||
|
expect(screen.getByText("New passwords must match each other.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when entering a new password", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mocked(client.setPassword).mockRejectedValue({ httpStatus: 401 });
|
||||||
|
await typeIntoField("New Password", testPassword);
|
||||||
|
await typeIntoField("Confirm new password", testPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("and submitting it running into rate limiting", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mocked(client.setPassword).mockRejectedValue({
|
||||||
|
message: "rate limit reached",
|
||||||
|
httpStatus: 429,
|
||||||
|
data: {
|
||||||
|
retry_after_ms: (13 * 60 + 37) * 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await submitForm("Reset password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the rate limit error message", () => {
|
||||||
|
expect(
|
||||||
|
screen.getByText("Too many attempts in a short time. Retry after 13:37."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("and submitting it", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await submitForm("Reset password");
|
||||||
|
// double flush promises for the modal to appear
|
||||||
|
await flushPromisesWithFakeTimers();
|
||||||
|
await flushPromisesWithFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send the new password and show the click validation link dialog", () => {
|
||||||
|
expect(client.setPassword).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
type: "m.login.email.identity",
|
||||||
|
threepid_creds: {
|
||||||
|
client_secret: expect.any(String),
|
||||||
|
sid: testSid,
|
||||||
|
},
|
||||||
|
threepidCreds: {
|
||||||
|
client_secret: expect.any(String),
|
||||||
|
sid: testSid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
testPassword,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Verify your email to continue")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(testEmail)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when validating the link from the mail", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
mocked(client.setPassword).mockResolvedValue({});
|
||||||
|
// be sure the next set password attempt was sent
|
||||||
|
jest.advanceTimersByTime(3000);
|
||||||
|
// quad flush promises for the modal to disappear
|
||||||
|
await flushPromisesWithFakeTimers();
|
||||||
|
await flushPromisesWithFakeTimers();
|
||||||
|
await flushPromisesWithFakeTimers();
|
||||||
|
await flushPromisesWithFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display the confirm reset view and now show the dialog", () => {
|
||||||
|
expect(screen.queryByText("Your password has been reset.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Verify your email to continue")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -85,6 +85,7 @@ export function createTestClient(): MatrixClient {
|
|||||||
getIdentityServerUrl: jest.fn(),
|
getIdentityServerUrl: jest.fn(),
|
||||||
getDomain: jest.fn().mockReturnValue("matrix.org"),
|
getDomain: jest.fn().mockReturnValue("matrix.org"),
|
||||||
getUserId: jest.fn().mockReturnValue("@userId:matrix.org"),
|
getUserId: jest.fn().mockReturnValue("@userId:matrix.org"),
|
||||||
|
getUserIdLocalpart: jest.fn().mockResolvedValue("userId"),
|
||||||
getUser: jest.fn().mockReturnValue({ on: jest.fn() }),
|
getUser: jest.fn().mockReturnValue({ on: jest.fn() }),
|
||||||
getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"),
|
getDeviceId: jest.fn().mockReturnValue("ABCDEFGHI"),
|
||||||
deviceId: "ABCDEFGHI",
|
deviceId: "ABCDEFGHI",
|
||||||
@ -193,6 +194,9 @@ export function createTestClient(): MatrixClient {
|
|||||||
uploadContent: jest.fn(),
|
uploadContent: jest.fn(),
|
||||||
getEventMapper: () => (opts) => new MatrixEvent(opts),
|
getEventMapper: () => (opts) => new MatrixEvent(opts),
|
||||||
leaveRoomChain: jest.fn(roomId => ({ [roomId]: null })),
|
leaveRoomChain: jest.fn(roomId => ({ [roomId]: null })),
|
||||||
|
doesServerSupportLogoutDevices: jest.fn().mockReturnValue(true),
|
||||||
|
requestPasswordEmailToken: jest.fn().mockRejectedValue({}),
|
||||||
|
setPassword: jest.fn().mockRejectedValue({}),
|
||||||
} as unknown as MatrixClient;
|
} as unknown as MatrixClient;
|
||||||
|
|
||||||
client.reEmitter = new ReEmitter(client);
|
client.reEmitter = new ReEmitter(client);
|
||||||
|
Loading…
Reference in New Issue
Block a user