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 (
+ +

+ {resetTokenInvalidReason} +

+ + + +

+ {newPasswordInvalidReason} +

+ + + +

+ {newPasswordConfirmationInvalidReason} +

+ +

+ {backendError} +

+
@@ -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 };