diff --git a/_redirects b/_redirects
index e58f547a2a..0e23aee174 100644
--- a/_redirects
+++ b/_redirects
@@ -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
diff --git a/packages/components/src/api/ApiConfig.ts b/packages/components/src/api/ApiConfig.ts
index 2aa8220648..34b1f4e544 100644
--- a/packages/components/src/api/ApiConfig.ts
+++ b/packages/components/src/api/ApiConfig.ts
@@ -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`;
}
diff --git a/packages/components/src/api/user/RedeemResetPassword.ts b/packages/components/src/api/user/RedeemResetPassword.ts
new file mode 100644
index 0000000000..2f043c089e
--- /dev/null
+++ b/packages/components/src/api/user/RedeemResetPassword.ts
@@ -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
+{
+ 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 };
+ });
+}
diff --git a/packages/components/src/api/user/RequestResetPassword.ts b/packages/components/src/api/user/RequestResetPassword.ts
new file mode 100644
index 0000000000..b6eb45cb12
--- /dev/null
+++ b/packages/components/src/api/user/RequestResetPassword.ts
@@ -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
+{
+ 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 };
+ });
+}
diff --git a/packages/fakeyou.com/src/v2/view/PageContainer.tsx b/packages/fakeyou.com/src/v2/view/PageContainer.tsx
index edce3e7bdf..03cd213e71 100644
--- a/packages/fakeyou.com/src/v2/view/PageContainer.tsx
+++ b/packages/fakeyou.com/src/v2/view/PageContainer.tsx
@@ -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<
/>
-
-
-
-
diff --git a/packages/fakeyou.com/src/v2/view/pages/login/LoginPage.tsx b/packages/fakeyou.com/src/v2/view/pages/login/LoginPage.tsx
index a48d4400ae..1135cc46e3 100644
--- a/packages/fakeyou.com/src/v2/view/pages/login/LoginPage.tsx
+++ b/packages/fakeyou.com/src/v2/view/pages/login/LoginPage.tsx
@@ -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}
/>
- {/*
+
Forgot your password?
-
*/}
+
diff --git a/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetChangePage.tsx b/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetChangePage.tsx
deleted file mode 100644
index f8e1127982..0000000000
--- a/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetChangePage.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- {isPasswordChanged ? (
-
-
Your password has been successfully changed!
-
-
- ) : (
-
- )}
-
-
- );
-}
-
-export { PasswordResetChangePage };
diff --git a/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetEmailPage.tsx b/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetEmailPage.tsx
index c28aa8d728..a8b6244938 100644
--- a/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetEmailPage.tsx
+++ b/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetEmailPage.tsx
@@ -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
+ ) => {
+ const value = (ev.target as HTMLInputElement).value;
+ setEmailOrUsername(value);
+ }
+
+ const handleSubmit = async (
+ ev: React.FormEvent
+ ) : Promise => {
+ 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 (
+
+
+
+ Click here once you have the code.
+
+
+ );
+ }
return (
@@ -36,12 +89,14 @@ function PasswordResetEmailPage(props: Props) {
diff --git a/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetVerificationPage.tsx b/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetVerificationPage.tsx
index 12a7b756f2..2140abf903 100644
--- a/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetVerificationPage.tsx
+++ b/packages/fakeyou.com/src/v2/view/pages/password_reset/PasswordResetVerificationPage.tsx
@@ -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(ERR_PASSWORD_TOO_SHORT);
+
+ const [newPasswordConfirmation, setNewPasswordConfirmation] = useState("");
+ const [newPasswordConfirmationIsValid, setNewPasswordConfirmationIsValid] = useState(false);
+ const [newPasswordConfirmationInvalidReason, setNewPasswordConfirmationInvalidReason] = useState(ERR_PASSWORD_TOO_SHORT);
+
+ const [backendError, setBackendError] = useState(undefined);
if (props.sessionWrapper.isLoggedIn()) {
history.push("/");
}
- usePrefixedDocumentTitle("Code Verification");
+ const handleChangeResetToken = (ev: React.FormEvent) => {
+ 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) => {
+ 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) => {
+ 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
+ ) : Promise => {
+ 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 (
@@ -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 };