mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-06 02:00:46 +00:00
Merge pull request #346 from storytold/brandon-password-reset
password reset
This commit is contained in:
9 files changed
+364
-110
No files matched your search
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+175
-14
@@ -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 };
|
||||
Reference in new issue
Block a user