Merge pull request #346 from storytold/brandon-password-reset

password reset
This commit is contained in:
Brandon Thomas authored and GitHub committed 2023-11-12 05:45:01 -05:00
commit da9223ba95
9 files changed
+364 -110

No files matched your search

+1
View File
@@ -23,6 +23,7 @@
/news* /index.html 200
/obs_configs* /index.html 200
/old* /index.html 200
/password-reset* /index.html 200
/patrons* /index.html 200
/pending* /index.html 200
/portal_success* /index.html 200
+8
View File
@@ -103,6 +103,14 @@ class ApiConfig {
return `${this.getApiOrigin()}/v1/login`;
}
passwordResetRequest(): string {
return `${this.getApiOrigin()}/v1/password_reset/request`;
}
passwordResetRedeem(): string {
return `${this.getApiOrigin()}/v1/password_reset/redeem`;
}
logout(): string {
return `${this.getApiOrigin()}/v1/logout`;
}
@@ -0,0 +1,57 @@
import { ApiConfig } from "@storyteller/components";
export interface RedeemResetPasswordRedeem {
reset_token: string,
new_password: string,
new_password_validation: string,
}
export interface RedeemResetPasswordSuccessResponse {
success: boolean,
}
export interface RedeemResetPasswordErrorResponse {
success: boolean,
error_type: string,
error_fields: { [key: string]: string; },
}
type RedeemResetPasswordResponse = RedeemResetPasswordSuccessResponse | RedeemResetPasswordErrorResponse;
export function RedeemResetPasswordIsSuccess(response: RedeemResetPasswordResponse): response is RedeemResetPasswordSuccessResponse {
return response?.success === true;
}
export function RedeemResetPasswordIsError(response: RedeemResetPasswordResponse): response is RedeemResetPasswordErrorResponse {
return response?.success === false;
}
export async function RedeemResetPassword(Redeem: RedeemResetPasswordRedeem) : Promise<RedeemResetPasswordResponse>
{
const endpoint = new ApiConfig().passwordResetRedeem();
return await fetch(endpoint, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(Redeem),
})
.then(res => res.json())
.then(res => {
if (!res) {
return { success : false };
}
if (res && 'success' in res) {
return res;
} else {
return { success : false };
}
})
.catch(e => {
return { success : false };
});
}
@@ -0,0 +1,55 @@
import { ApiConfig } from "@storyteller/components";
export interface RequestResetPasswordRequest {
username_or_email: string,
}
export interface RequestResetPasswordSuccessResponse {
success: boolean,
}
export interface RequestResetPasswordErrorResponse {
success: boolean,
error_type: string,
error_fields: { [key: string]: string; },
}
type RequestResetPasswordResponse = RequestResetPasswordSuccessResponse | RequestResetPasswordErrorResponse;
export function RequestResetPasswordIsSuccess(response: RequestResetPasswordResponse): response is RequestResetPasswordSuccessResponse {
return response?.success === true;
}
export function RequestResetPasswordIsError(response: RequestResetPasswordResponse): response is RequestResetPasswordErrorResponse {
return response?.success === false;
}
export async function RequestResetPassword(request: RequestResetPasswordRequest) : Promise<RequestResetPasswordResponse>
{
const endpoint = new ApiConfig().passwordResetRequest();
return await fetch(endpoint, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(request),
})
.then(res => res.json())
.then(res => {
if (!res) {
return { success : false };
}
if (res && 'success' in res) {
return res;
} else {
return { success : false };
}
})
.catch(e => {
return { success : false };
});
}
@@ -97,7 +97,6 @@ import { VoiceDesignerVoiceEditPage } from "./pages/voice_designer/VoiceDesigner
import VoiceDesignerUseVoicePage from "./pages/voice_designer/VoiceDesignerUseVoicePage";
import { PasswordResetEmailPage } from "./pages/password_reset/PasswordResetEmailPage";
import { PasswordResetVerificationPage } from "./pages/password_reset/PasswordResetVerificationPage";
import { PasswordResetChangePage } from "./pages/password_reset/PasswordResetChangePage";
interface Props {
sessionWrapper: SessionWrapper;
@@ -274,15 +273,13 @@ class PageContainer extends React.Component<
/>
</Route>
<Route path="/password-reset/new">
<PasswordResetChangePage
sessionWrapper={this.props.sessionWrapper}
/>
</Route>
<Route path="/password-reset/verify">
<PasswordResetVerificationPage
sessionWrapper={this.props.sessionWrapper}
querySessionAction={this.props.querySessionAction}
querySessionSubscriptionsAction={
this.props.querySessionSubscriptionsAction
}
/>
</Route>
@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { useHistory } from "react-router-dom";
import { Link, useHistory } from "react-router-dom";
import {
CreateSession,
CreateSessionIsError,
@@ -132,11 +132,11 @@ function LoginPage(props: Props) {
onChange={handlePasswordChange}
/>
</div>
{/* <p className="d-flex flex-column flex-lg-row gap-2">
<p className="d-flex flex-column flex-lg-row gap-2">
<Link to="/password-reset" className="text-link form-text">
Forgot your password?
</Link>
</p> */}
</p>
</div>
</div>
<button className="btn btn-primary w-100 mt-4">Login</button>
@@ -1,80 +0,0 @@
import React, { useState } from "react";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { useHistory } from "react-router-dom";
import { usePrefixedDocumentTitle } from "../../../../common/UsePrefixedDocumentTitle";
import Container from "components/common/Container";
import PageHeader from "components/layout/PageHeader";
import Input from "components/common/Input";
import { Button } from "components/common";
import Panel from "components/common/Panel";
import { faKey } from "@fortawesome/pro-solid-svg-icons";
import BackButton from "components/common/BackButton";
interface Props {
sessionWrapper: SessionWrapper;
}
function PasswordResetChangePage(props: Props) {
let history = useHistory();
const [isPasswordChanged, setIsPasswordChanged] = useState(false);
const [isLoading, setIsLoading] = useState(false);
if (props.sessionWrapper.isLoggedIn()) {
history.push("/");
}
usePrefixedDocumentTitle("Reset Password");
const handlePasswordChange = async () => {
// Simulate a delay using a timer (dummy timer)
setIsLoading(true);
setTimeout(() => {
setIsPasswordChanged(true);
setIsLoading(false);
}, 2000);
};
return (
<Container type="panel" className="login-panel">
<PageHeader
title="Change Password"
subText="Set your new password."
panel={false}
/>
<Panel padding={true}>
{isPasswordChanged ? (
<div className="d-flex flex-column gap-3 align-items-start">
<p>Your password has been successfully changed!</p>
<BackButton label="Back to Login" to="/login" />
</div>
) : (
<form>
<div className="d-flex flex-column gap-4">
<Input
label="New Password"
icon={faKey}
placeholder="Enter new password"
/>
<Input
label="Confirm New Password"
icon={faKey}
placeholder="Confirm new password"
/>
<Button
label="Confirm Change"
onClick={handlePasswordChange}
isLoading={isLoading}
/>
</div>
</form>
)}
</Panel>
</Container>
);
}
export { PasswordResetChangePage };
@@ -1,6 +1,6 @@
import React from "react";
import React, { useState } from "react";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { useHistory } from "react-router-dom";
import { Link, useHistory } from "react-router-dom";
import { usePrefixedDocumentTitle } from "../../../../common/UsePrefixedDocumentTitle";
import Container from "components/common/Container";
import PageHeader from "components/layout/PageHeader";
@@ -8,6 +8,9 @@ import Input from "components/common/Input";
import { Button } from "components/common";
import Panel from "components/common/Panel";
import { faEnvelope } from "@fortawesome/pro-solid-svg-icons";
import {
RequestResetPassword, RequestResetPasswordIsSuccess,
} from "@storyteller/components/src/api/user/RequestResetPassword";
interface Props {
sessionWrapper: SessionWrapper;
@@ -16,11 +19,61 @@ interface Props {
function PasswordResetEmailPage(props: Props) {
let history = useHistory();
usePrefixedDocumentTitle("Reset Password");
const [emailOrUsername, setEmailOrUsername] = useState("");
const [isSent, setIsSent] = useState(false);
if (props.sessionWrapper.isLoggedIn()) {
history.push("/");
}
usePrefixedDocumentTitle("Reset Password");
const handleChange = (
ev: React.FormEvent<HTMLInputElement>
) => {
const value = (ev.target as HTMLInputElement).value;
setEmailOrUsername(value);
}
const handleSubmit = async (
ev: React.FormEvent<HTMLButtonElement>
) : Promise<boolean> => {
ev.preventDefault();
const value = emailOrUsername.trim();
if (value.length < 4) {
return false;
}
const request = {
username_or_email: value
};
const response = await RequestResetPassword(request);
if (RequestResetPasswordIsSuccess(response)) {
setIsSent(true);
}
return false;
}
if (isSent) {
return (
<Container type="panel" className="login-panel">
<PageHeader
title="Password Reset Sent"
subText="If you entered a valid email or username, an email with instructions to reset your password has been sent your way. "
panel={false}
showBackButton={true}
/>
<Link to={"/password-reset/verify"}>
Click here once you have the code.
</Link>
</Container>
);
}
return (
<Container type="panel" className="login-panel">
@@ -36,12 +89,14 @@ function PasswordResetEmailPage(props: Props) {
<form>
<div className="d-flex flex-column gap-4">
<Input
label="Enter Email"
label="Enter Email or Username"
icon={faEnvelope}
placeholder="Email address"
placeholder="Email address or username"
value={emailOrUsername}
onChange={handleChange}
/>
<Button label="Reset Password" onClick={() => {}} />
<Button label="Reset Password" onClick={handleSubmit} />
</div>
</form>
</Panel>
@@ -8,51 +8,193 @@ import Input from "components/common/Input";
import { Button } from "components/common";
import Panel from "components/common/Panel";
import { faLock } from "@fortawesome/pro-solid-svg-icons";
import {
RedeemResetPassword, RedeemResetPasswordIsSuccess,
} from "@storyteller/components/src/api/user/RedeemResetPassword";
interface Props {
sessionWrapper: SessionWrapper;
querySessionAction: () => void;
querySessionSubscriptionsAction: () => void;
}
// TODO(bt,2023-11-12): Localize error messages
const ERR_CODE_NOT_SET = "password reset code is not set";
const ERR_CODE_TOO_SHORT = "password reset code is too short";
const ERR_PASSWORD_TOO_SHORT = "new password is too short";
const ERR_PASSWORD_DOES_NOT_MATCH = "new password does not match";
const ERR_BACKEND = "There was an issue resetting your password. Perhaps your code expired?";
function PasswordResetVerificationPage(props: Props) {
let history = useHistory();
const [isLoading, setIsLoading] = useState(false);
usePrefixedDocumentTitle("Password Reset Verification");
const [resetToken, setResetToken] = useState(getCodeFromUrl() || "");
const [resetTokenLooksValid, setResetTokenLooksValid] = useState(!!!getResetCodeErrors(getCodeFromUrl()));
const [resetTokenInvalidReason, setResetTokenInvalidReason] = useState(getResetCodeErrors(getCodeFromUrl()));
const [newPassword, setNewPassword] = useState("");
const [newPasswordIsValid, setNewPasswordIsValid] = useState(false);
const [newPasswordInvalidReason, setNewPasswordInvalidReason] = useState<string|undefined>(ERR_PASSWORD_TOO_SHORT);
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
const [newPasswordConfirmationIsValid, setNewPasswordConfirmationIsValid] = useState(false);
const [newPasswordConfirmationInvalidReason, setNewPasswordConfirmationInvalidReason] = useState<string|undefined>(ERR_PASSWORD_TOO_SHORT);
const [backendError, setBackendError] = useState<string|undefined>(undefined);
if (props.sessionWrapper.isLoggedIn()) {
history.push("/");
}
usePrefixedDocumentTitle("Code Verification");
const handleChangeResetToken = (ev: React.FormEvent<HTMLInputElement>) => {
const token = (ev.target as HTMLInputElement).value;
const errors = getResetCodeErrors(token);
setResetToken(token);
setResetTokenLooksValid(!!!errors);
setResetTokenInvalidReason(errors);
}
const handleVerifyCode = async () => {
// Simulate a delay using a timer (dummy timer)
setIsLoading(true);
const handleChangePassword = (ev: React.FormEvent<HTMLInputElement>) => {
const value = (ev.target as HTMLInputElement).value;
setTimeout(() => {
setIsLoading(false);
}, 2000);
let isValid = true;
let invalidReason = undefined;
if (value.length < 5) {
isValid = false;
invalidReason = ERR_PASSWORD_TOO_SHORT;
}
setNewPassword(value);
setNewPasswordIsValid(isValid);
setNewPasswordInvalidReason(invalidReason)
if (value !== newPasswordConfirmation) {
setNewPasswordConfirmationIsValid(false);
setNewPasswordConfirmationInvalidReason(ERR_PASSWORD_DOES_NOT_MATCH)
} else if (newPasswordConfirmation.length > 4) {
setNewPasswordConfirmationIsValid(true);
setNewPasswordConfirmationInvalidReason(undefined)
}
}
const handleChangePasswordConfirmation = (ev: React.FormEvent<HTMLInputElement>) => {
const value = (ev.target as HTMLInputElement).value;
let isValid = true;
let invalidReason = undefined;
if (value !== newPassword) {
isValid = false;
invalidReason = ERR_PASSWORD_DOES_NOT_MATCH;
}
else if (value.length < 5) {
isValid = false;
invalidReason = ERR_PASSWORD_TOO_SHORT;
}
setNewPasswordConfirmation(value);
setNewPasswordConfirmationIsValid(isValid);
setNewPasswordConfirmationInvalidReason(invalidReason)
}
const handleSubmit = async (
ev: React.FormEvent<HTMLButtonElement>
) : Promise<boolean> => {
ev.preventDefault();
const password = newPassword.trim();
const passwordConfirmation = newPasswordConfirmation.trim();
const request = {
reset_token: resetToken,
new_password: password,
new_password_validation: passwordConfirmation,
};
const response = await RedeemResetPassword(request);
// TODO(bt,2023-11-12): Handle server-side errors
if (RedeemResetPasswordIsSuccess(response)) {
setBackendError(undefined);
props.querySessionAction();
props.querySessionSubscriptionsAction();
history.push("/");
} else {
setBackendError(ERR_BACKEND);
}
return false;
};
const canSubmit = resetTokenLooksValid && newPasswordIsValid && newPasswordConfirmationIsValid;
let resetTokenHelpClasses = resetTokenLooksValid ? "" : "form-control is-danger";
let newPasswordHelpClasses = newPasswordIsValid ? "" : "form-control is-danger";
let newPasswordConfirmationHelpClasses = newPasswordConfirmationIsValid ? "" : "form-control is-danger";
let backendErrorClasses = !!!backendError ? "" : "form-control is-danger";
return (
<Container type="panel" className="login-panel">
<PageHeader
title="Code Verification"
subText="Enter the 6-digit code sent to your email address."
title="Password Reset Verification"
subText="Enter the code sent to your email address."
panel={false}
/>
<Panel padding={true}>
<form>
<div className="d-flex flex-column gap-4">
<Input
label="Verification Code"
icon={faLock}
placeholder="Enter 6-digit code"
placeholder="Enter verification code"
value={resetToken}
onChange={handleChangeResetToken}
/>
<p className={resetTokenHelpClasses}>
{resetTokenInvalidReason}
</p>
<Input
type="password"
label="New Password"
icon={faLock}
placeholder="Enter new password"
value={newPassword}
onChange={handleChangePassword}
/>
<p className={newPasswordHelpClasses}>
{newPasswordInvalidReason}
</p>
<Input
type="password"
label="Verify New Password"
icon={faLock}
placeholder="Enter new password again"
value={newPasswordConfirmation}
onChange={handleChangePasswordConfirmation}
/>
<p className={newPasswordConfirmationHelpClasses}>
{newPasswordConfirmationInvalidReason}
</p>
<p className={backendErrorClasses}>
{backendError}
</p>
<Button
label="Verify Code"
onClick={handleVerifyCode}
isLoading={isLoading}
label="Change Password"
onClick={handleSubmit}
disabled={!canSubmit}
/>
</div>
</form>
@@ -61,4 +203,23 @@ function PasswordResetVerificationPage(props: Props) {
);
}
// Pre-load the code from a URL query string, eg https://fakeyou.com/password-reset/validate?code=codeGoesHere
function getCodeFromUrl() : string | null {
const urlParams = new URLSearchParams(window.location.search);
const tokenUnsafe = urlParams.get('token');
const tokenSafe = tokenUnsafe === null ? null : tokenUnsafe.replace(/[^A-Za-z0-9]/g, '');
return tokenSafe;
}
// Handle error state at initialization
function getResetCodeErrors(code: string | null) : string | undefined {
if (!code) {
return ERR_CODE_NOT_SET;
}
if (code.length < 10) {
return ERR_CODE_TOO_SHORT;
};
}
export { PasswordResetVerificationPage };