From b1daae95eeb15205f92030089dd4c0f1152a372a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 12 Nov 2023 03:18:30 -0500 Subject: [PATCH 1/6] password reset forms --- _redirects | 1 + packages/components/src/api/ApiConfig.ts | 8 +++ .../src/api/user/RedeemResetPassword.ts | 57 ++++++++++++++++ .../src/api/user/RequestResetPassword.ts | 55 +++++++++++++++ .../src/v2/view/pages/login/LoginPage.tsx | 6 +- .../password_reset/PasswordResetEmailPage.tsx | 67 +++++++++++++++++-- 6 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 packages/components/src/api/user/RedeemResetPassword.ts create mode 100644 packages/components/src/api/user/RequestResetPassword.ts 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..285dc49826 --- /dev/null +++ b/packages/components/src/api/user/RedeemResetPassword.ts @@ -0,0 +1,57 @@ +import { ApiConfig } from "@storyteller/components"; + +export interface RequestResetPasswordRequest { + reset_token: string, + new_password: string, + new_password_validation: 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().passwordResetRedeem(); + + 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/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/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/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) {
-
From bcb232301a3173d6be4fa63796a257cbaabb2e79 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 12 Nov 2023 04:08:59 -0500 Subject: [PATCH 2/6] form validation --- .../PasswordResetVerificationPage.tsx | 143 ++++++++++++++++-- 1 file changed, 131 insertions(+), 12 deletions(-) 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..df5cbe22ec 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 @@ -15,44 +15,141 @@ interface Props { 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("new password is too short"); + + const [newPasswordConfirmation, setNewPasswordConfirmation] = useState(""); + const [newPasswordConfirmationIsValid, setNewPasswordConfirmationIsValid] = useState(false); + const [newPasswordConfirmationInvalidReason, setNewPasswordConfirmationInvalidReason] = useState("new password is too short"); 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 handleChangePassword = (ev: React.FormEvent) => { + const value = (ev.target as HTMLInputElement).value; + + let isValid = true; + let invalidReason = undefined; + + if (value.length < 5) { + isValid = false; + invalidReason = "new password is too short"; + } + + setNewPassword(value); + setNewPasswordIsValid(isValid); + setNewPasswordInvalidReason(invalidReason) + + if (value !== newPasswordConfirmation) { + setNewPasswordConfirmationIsValid(false); + setNewPasswordConfirmationInvalidReason("new 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 = "new password does not match"; + } + else if (value.length < 5) { + isValid = false; + invalidReason = "new password is too short"; + } + + setNewPasswordConfirmation(value); + setNewPasswordConfirmationIsValid(isValid); + setNewPasswordConfirmationInvalidReason(invalidReason) + } + const handleVerifyCode = async () => { - // Simulate a delay using a timer (dummy timer) - setIsLoading(true); - - setTimeout(() => { - setIsLoading(false); - }, 2000); }; + 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"; + return (
+ +

+ {resetTokenInvalidReason} +

+ + + +

+ {newPasswordInvalidReason} +

+ + + +

+ {newPasswordConfirmationInvalidReason} +

+
@@ -61,4 +158,26 @@ function PasswordResetVerificationPage(props: Props) { ); } +function getCodeFromUrl() : string | null { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get('token'); +} + +function checkResetCodeLooksValid(code: string | null) : boolean { + if (!code) { + return false; + } + return code.length > 10; +} + +function getResetCodeErrors(code: string | null) : string | undefined { + if (!code) { + return "no code set"; + } + if (code.length < 10) { + return "code is too short"; + }; +} + + export { PasswordResetVerificationPage }; From 21b8fffe08431744058c5673f3c8c573f56fb452 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 12 Nov 2023 04:18:55 -0500 Subject: [PATCH 3/6] redemption request --- .../src/api/user/RedeemResetPassword.ts | 16 ++++---- .../PasswordResetVerificationPage.tsx | 37 ++++++++++++++----- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/packages/components/src/api/user/RedeemResetPassword.ts b/packages/components/src/api/user/RedeemResetPassword.ts index 285dc49826..2f043c089e 100644 --- a/packages/components/src/api/user/RedeemResetPassword.ts +++ b/packages/components/src/api/user/RedeemResetPassword.ts @@ -1,32 +1,32 @@ import { ApiConfig } from "@storyteller/components"; -export interface RequestResetPasswordRequest { +export interface RedeemResetPasswordRedeem { reset_token: string, new_password: string, new_password_validation: string, } -export interface RequestResetPasswordSuccessResponse { +export interface RedeemResetPasswordSuccessResponse { success: boolean, } -export interface RequestResetPasswordErrorResponse { +export interface RedeemResetPasswordErrorResponse { success: boolean, error_type: string, error_fields: { [key: string]: string; }, } -type RequestResetPasswordResponse = RequestResetPasswordSuccessResponse | RequestResetPasswordErrorResponse; +type RedeemResetPasswordResponse = RedeemResetPasswordSuccessResponse | RedeemResetPasswordErrorResponse; -export function RequestResetPasswordIsSuccess(response: RequestResetPasswordResponse): response is RequestResetPasswordSuccessResponse { +export function RedeemResetPasswordIsSuccess(response: RedeemResetPasswordResponse): response is RedeemResetPasswordSuccessResponse { return response?.success === true; } -export function RequestResetPasswordIsError(response: RequestResetPasswordResponse): response is RequestResetPasswordErrorResponse { +export function RedeemResetPasswordIsError(response: RedeemResetPasswordResponse): response is RedeemResetPasswordErrorResponse { return response?.success === false; } -export async function RequestResetPassword(request: RequestResetPasswordRequest) : Promise +export async function RedeemResetPassword(Redeem: RedeemResetPasswordRedeem) : Promise { const endpoint = new ApiConfig().passwordResetRedeem(); @@ -37,7 +37,7 @@ export async function RequestResetPassword(request: RequestResetPasswordRequest) 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify(request), + body: JSON.stringify(Redeem), }) .then(res => res.json()) .then(res => { 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 df5cbe22ec..b7d256bac6 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,6 +8,9 @@ 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; @@ -86,8 +89,29 @@ function PasswordResetVerificationPage(props: Props) { setNewPasswordConfirmationInvalidReason(invalidReason) } + const handleSubmit = async ( + ev: React.FormEvent + ) : Promise => { + ev.preventDefault(); - const handleVerifyCode = async () => { + 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)) { + history.push("/"); + } + + return false; }; const canSubmit = resetTokenLooksValid && newPasswordIsValid && newPasswordConfirmationIsValid; @@ -148,7 +172,7 @@ function PasswordResetVerificationPage(props: Props) {