diff --git a/src/client/ui/main-screen/pages.ts b/src/client/ui/main-screen/pages.ts index e35a63c0..ba357fd3 100644 --- a/src/client/ui/main-screen/pages.ts +++ b/src/client/ui/main-screen/pages.ts @@ -4,14 +4,6 @@ import FeedbackPage from "./feedback/FeedbackPage"; import PreparePage from "./prepare/PreparePage"; import SettingsPage from "./settings/SettingsPage"; -import { - FORGOT_PASSWORD_PAGE, - LOGIN_PAGE, - PROFILE_PAGE, - REGISTER_ACCOUNT_PAGE, - RESET_PASSWORD_PAGE, - VERIFY_PAGE, -} from "@jyosuushi/ui/modules/authentication/pages"; import { EXPLORE_PAGE } from "@jyosuushi/ui/modules/explore/pages"; import { RELEASE_NOTES_PAGE } from "@jyosuushi/ui/modules/release-notes/pages"; @@ -38,13 +30,6 @@ export const UNORDERED_NESTED_PAGES: ReadonlyArray = [ SETTINGS_PAGE, RELEASE_NOTES_PAGE, FEEDBACK_PAGE, - PROFILE_PAGE, - LOGIN_PAGE, - FORGOT_PASSWORD_PAGE, - REGISTER_ACCOUNT_PAGE, - RESET_PASSWORD_PAGE, - VERIFY_PAGE, - PROFILE_PAGE, ]; export { EXPLORE_PAGE, RELEASE_NOTES_PAGE }; diff --git a/src/client/ui/main-screen/sidebar/Sidebar.tsx b/src/client/ui/main-screen/sidebar/Sidebar.tsx index e4b428a6..88e0d0ba 100644 --- a/src/client/ui/main-screen/sidebar/Sidebar.tsx +++ b/src/client/ui/main-screen/sidebar/Sidebar.tsx @@ -1,12 +1,6 @@ import React from "react"; import { defineMessages } from "react-intl"; -import useAuthenticationStatus, { - AuthenticationStatus, -} from "@jyosuushi/hooks/useAuthenticationStatus"; - -import { PageDefinition } from "@jyosuushi/ui/types"; -import { LOGIN_PAGE } from "@jyosuushi/ui/modules/authentication/pages"; import { EXPLORE_PAGE, FEEDBACK_PAGE, @@ -14,13 +8,10 @@ import { RELEASE_NOTES_PAGE, SETTINGS_PAGE, } from "@jyosuushi/ui/main-screen/pages"; -import { PROFILE_PAGE } from "@jyosuushi/ui/modules/authentication/pages"; import ExplorePageIcon from "./explore-icon.svg"; import FeedbackPageIcon from "./feedback-icon.svg"; -import LoginPageIcon from "./login-icon.svg"; import PreparePageIcon from "./prepare-icon.svg"; -import ProfilePageIcon from "./profile-icon.svg"; import ReleaseNotesPageIcon from "./release-notes-icon.svg"; import SettingsPageIcon from "./settings-icon.svg"; @@ -36,18 +27,10 @@ const INTL_MESSAGES = defineMessages({ defaultMessage: "Feedback", id: "Sidebar.pages.feedback", }, - loginPage: { - defaultMessage: "Login", - id: "Sidebar.pages.login", - }, preparePage: { defaultMessage: "Prepare", id: "Sidebar.pages.prepare", }, - profilePage: { - defaultMessage: "Your Profile", - id: "Sidebar.pages.profile", - }, releaseNotesPage: { defaultMessage: "Release Notes", id: "Sidebar.pages.releaseNotes", @@ -59,26 +42,6 @@ const INTL_MESSAGES = defineMessages({ }); function Sidebar(): React.ReactElement { - // Connect to GraphQL to determine whether we're logged in, and who we are if we are - const authStatus = useAuthenticationStatus(); - - // Determine the user-related sidebar link - let userLinkPage: PageDefinition | null; - switch (authStatus) { - case AuthenticationStatus.Loading: { - userLinkPage = null; - break; - } - case AuthenticationStatus.NotAuthenticated: { - userLinkPage = LOGIN_PAGE; - break; - } - case AuthenticationStatus.Authenticated: { - userLinkPage = PROFILE_PAGE; - break; - } - } - // Render the component return (
@@ -107,19 +70,6 @@ function Sidebar(): React.ReactElement { page={FEEDBACK_PAGE} text={INTL_MESSAGES.feedbackPage} /> -
); } diff --git a/src/client/ui/main-screen/sidebar/login-icon.svg b/src/client/ui/main-screen/sidebar/login-icon.svg deleted file mode 100644 index 114a152c..00000000 --- a/src/client/ui/main-screen/sidebar/login-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/client/ui/main-screen/sidebar/profile-icon.svg b/src/client/ui/main-screen/sidebar/profile-icon.svg deleted file mode 100644 index f757b9e6..00000000 --- a/src/client/ui/main-screen/sidebar/profile-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/client/ui/modules/authentication/auth-form/AuthForm.tsx b/src/client/ui/modules/authentication/auth-form/AuthForm.tsx deleted file mode 100644 index 3cb9265f..00000000 --- a/src/client/ui/modules/authentication/auth-form/AuthForm.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React, { useCallback, useEffect, useState } from "react"; -import { MessageDescriptor } from "react-intl"; - -import useIsMounted from "@jyosuushi/hooks/useIsMounted"; - -import ActionBar from "@jyosuushi/ui/components/forms/ActionBar"; -import ErrorDisplay from "@jyosuushi/ui/components/forms/ErrorDisplay"; -import Form from "@jyosuushi/ui/components/forms/Form"; -import FormButton from "@jyosuushi/ui/components/forms/FormButton"; - -import AuthFormFieldDisplay from "./AuthFormFieldDisplay"; -import { - AuthFormContext, - AuthFormError, - AuthFormFieldDefinition, - AuthFormValues, -} from "./types"; -import useTouched from "./useTouched"; -import useValidation from "./useValidation"; - -interface ComponentProps { - children?: React.ReactElement; - context: AuthFormContext | null; - fields: readonly AuthFormFieldDefinition[]; - onSubmit: ( - values: AuthFormValues - ) => Promise | null>; - submitButtonLabel: MessageDescriptor; -} - -function AuthForm({ - children, - context, - fields, - onSubmit, - submitButtonLabel, -}: ComponentProps): React.ReactElement { - const isMounted = useIsMounted(); - - // Define state - const [values, setValues] = useState>( - (): AuthFormValues => { - const initial: AuthFormValues = {}; - fields.forEach(({ fieldName }): void => { - initial[fieldName] = ""; - }); - - return initial; - } - ); - const [isSubmitting, setIsSubmitting] = useState(false); - const [currentError, setCurrentError] = useState | null>(null); - - // Integrate hooks - const [fieldsTouched, touchSpecificField, touchAllFields] = useTouched( - fields - ); - const [validationFailures, areAllFieldsValid] = useValidation( - fields, - values, - fieldsTouched - ); - - // Dismiss errors that are time based - useEffect(() => { - if (!currentError || currentError.dismissal.method !== "time-elapsed") { - return; - } - - const timeoutId = window.setTimeout( - (): void => setCurrentError(null), - currentError.dismissal.milliseconds - ); - return (): void => { - window.clearTimeout(timeoutId); - }; - }, [currentError]); - - // Handle events from individual fields - const handleFieldChange = useCallback( - (field: TFieldNames, next: string): void => - setValues((current) => { - if (current[field] === next) { - return current; - } - - return { - ...current, - [field]: next, - }; - }), - [] - ); - - const handleFieldBlured = useCallback( - (field: TFieldNames): void => { - touchSpecificField(field); - }, - [touchSpecificField] - ); - - const handleClearError = useCallback((): void => setCurrentError(null), []); - - // Handle submission - const handleSubmit = async (): Promise => { - if (isSubmitting) { - return; - } - - setIsSubmitting(true); - try { - // Touch all fields once we've submitted - touchAllFields(); - - // Validate and make sure that all of the fields are in a good spot to - // submit. - if (!areAllFieldsValid()) { - return; - } - - // Run the submit function and process the results. - const submitError = await onSubmit(values); - if (isMounted.current) { - setCurrentError(submitError); - } - } finally { - if (isMounted.current) { - setIsSubmitting(false); - } - } - }; - - // Render the page - return ( -
- {context && context.username && ( - - )} - {fields.map( - (definition): React.ReactElement => ( - - ) - )} - {!!currentError && currentError.specificField === null && ( - - )} - - {children} - 0} - text={submitButtonLabel} - variant="primary" - /> - - - ); -} - -export default AuthForm; diff --git a/src/client/ui/modules/authentication/auth-form/AuthFormFieldDisplay.tsx b/src/client/ui/modules/authentication/auth-form/AuthFormFieldDisplay.tsx deleted file mode 100644 index 68f8d788..00000000 --- a/src/client/ui/modules/authentication/auth-form/AuthFormFieldDisplay.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React, { useCallback } from "react"; -import { MessageDescriptor } from "react-intl"; - -import ErrorDisplay from "@jyosuushi/ui/components/forms/ErrorDisplay"; -import LabeledContainer from "@jyosuushi/ui/components/forms/LabeledContainer"; -import StringInput from "@jyosuushi/ui/components/forms/StringInput"; - -import { - AuthFormError, - AuthFormFieldDefinition, - AuthFormValidationFailure, -} from "./types"; - -interface ComponentProps { - currentError: AuthFormError | null; - definition: AuthFormFieldDefinition; - onBlur: (field: TFieldNames) => void; - onClearError: () => void; - onChange: (field: TFieldNames, next: string) => void; - validationError: AuthFormValidationFailure | null; - value: string; -} - -function AuthFormFieldDisplay({ - currentError, - definition, - onBlur, - onClearError, - onChange, - validationError, - value, -}: ComponentProps): React.ReactElement { - // Handle field being changed - const handleChange = useCallback( - (next: string): void => { - if ( - currentError && - currentError.dismissal.method === "field-change" && - (currentError.specificField === null || - currentError.specificField === definition.fieldName) - ) { - // If the error disappears when we change this field (either - // specifically, or change any form field) wipe the error. - onClearError(); - } - - onChange(definition.fieldName, next); - }, - [currentError, definition.fieldName, onChange, onClearError] - ); - - // Handle field events - const handleBlur = useCallback((): void => onBlur(definition.fieldName), [ - onBlur, - definition.fieldName, - ]); - - // Coerce field definition into component properties - let role: "username" | "password"; - let type: "text" | "password" | "email"; - switch (definition.inputType) { - case "username": { - role = "username"; - type = "email"; - break; - } - case "password": { - role = "password"; - type = "password"; - break; - } - } - - // Determine the error to display, if there is any - let displayedError: { - message: MessageDescriptor; - messageValues?: Record; - } | null; - if (currentError && currentError.specificField === definition.fieldName) { - displayedError = currentError.message; - } else if (validationError) { - displayedError = validationError.message; - } else { - displayedError = null; - } - - // Render the page - return ( - - - {displayedError && ( - - )} - - ); -} - -export default AuthFormFieldDisplay; diff --git a/src/client/ui/modules/authentication/auth-form/types.ts b/src/client/ui/modules/authentication/auth-form/types.ts deleted file mode 100644 index c1b601a7..00000000 --- a/src/client/ui/modules/authentication/auth-form/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { MessageDescriptor } from "react-intl"; - -import { ErrorMessageDefinition } from "@jyosuushi/ui/modules/authentication/error-messages"; - -export interface AuthFormContext { - /** - * The username of the user who is currently logged in. This can be used to - * configure proper support and integration with password managers for forms - * that don't have a username field. - */ - username?: string; -} - -export type AuthFormValues = { - [field in TFieldNames]: string; -}; - -export type AuthFormFieldValidation = - | { - valid: true; - } - | { - valid: false; - reason: ErrorMessageDefinition; - }; - -export interface AuthFormFieldDefinition { - fieldName: TFieldNames; - label: MessageDescriptor; - inputType: "username" | "password"; - validation: - | ((values: AuthFormValues) => AuthFormFieldValidation) - | null; -} - -export interface AuthFormError { - message: ErrorMessageDefinition; - dismissal: - | { - method: "field-change"; - } - | { - method: "time-elapsed"; - milliseconds: number; - }; - specificField: TFieldNames | null; -} - -export interface AuthFormValidationFailure { - message: ErrorMessageDefinition; -} diff --git a/src/client/ui/modules/authentication/auth-form/useTouched.ts b/src/client/ui/modules/authentication/auth-form/useTouched.ts deleted file mode 100644 index cf815674..00000000 --- a/src/client/ui/modules/authentication/auth-form/useTouched.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useCallback, useState } from "react"; - -import { AuthFormFieldDefinition } from "./types"; - -export type TouchSpecificFieldFn = ( - field: TFieldNames -) => void; - -export type TouchAllFieldsFn = () => void; - -function useTouched( - fields: readonly AuthFormFieldDefinition[] -): [ - ReadonlySet, - TouchSpecificFieldFn, - TouchAllFieldsFn -] { - // Define state - const [fieldsTouched, setFieldsTouched] = useState(new Set()); - - // Create a callback to touch a specific field if it hasn't already been - // touched. - const touchSpecificField = useCallback( - (field: TFieldNames): void => - setFieldsTouched((current) => { - if (current.has(field)) { - return current; - } - - const next = new Set(current); - next.add(field); - return next; - }), - [] - ); - - // Create a callback that marks all fields in the form as touched. - const touchAllFields = useCallback( - (): void => - setFieldsTouched((current) => { - // If every field has already been marked as touched, then return the - // current object so we avoid a rerender. - if (fields.every(({ fieldName }) => current.has(fieldName))) { - return current; - } - - // At least one field hasn't been touched, so we should return a new - // set with all fields touched. - const next = new Set( - fields.map(({ fieldName }): TFieldNames => fieldName) - ); - return next; - }), - [fields] - ); - - // Return public API - return [fieldsTouched, touchSpecificField, touchAllFields]; -} - -export default useTouched; diff --git a/src/client/ui/modules/authentication/auth-form/useValidation.ts b/src/client/ui/modules/authentication/auth-form/useValidation.ts deleted file mode 100644 index 1835f69e..00000000 --- a/src/client/ui/modules/authentication/auth-form/useValidation.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { identity, isEqual } from "lodash"; -import memoizeOne from "memoize-one"; -import { useCallback, useMemo } from "react"; - -import { - AuthFormFieldDefinition, - AuthFormValidationFailure, - AuthFormValues, -} from "./types"; - -export type CurrentValidationFailures = Map< - TFieldNames, - AuthFormValidationFailure ->; - -export type AreAllFieldsValidFn = () => boolean; - -function areCollectionsEqual( - a: CurrentValidationFailures, - b: CurrentValidationFailures -): boolean { - if (a.size !== b.size) { - return false; - } - - let hasEncounteredDifference = false; - a.forEach((aValue, key): void => { - if (hasEncounteredDifference) { - // Stop performing work if we already know the end result. - return; - } - - if (!b.has(key)) { - hasEncounteredDifference = true; - return; - } - - const bValue = b.get(key); - if (!isEqual(aValue, bValue)) { - hasEncounteredDifference = true; - } - }); - - return !hasEncounteredDifference; -} - -type MemoizeArgs = [ - CurrentValidationFailures -]; - -function useValidation( - fields: readonly AuthFormFieldDefinition[], - values: AuthFormValues, - touchedFields: ReadonlySet -): [CurrentValidationFailures, AreAllFieldsValidFn] { - // Create an internal utility function to compute and return on demand - // what the validation object would look like for a particular group of - // fields. - const validateFields = useCallback( - ( - fieldsToValidate: ReadonlySet - ): CurrentValidationFailures => { - const results = new Map(); - fields.forEach((definition): void => { - if (!fieldsToValidate.has(definition.fieldName)) { - return; - } - - if (!definition.validation) { - return; - } - - const result = definition.validation(values); - if (result.valid) { - return; - } - - results.set(definition.fieldName, { message: result.reason }); - }); - - return results; - }, - [fields, values] - ); - - // Create a memoizing function that helps ensure deep equality on the - // validation failures object that is returned from this hook. - const memoizeFailures = useMemo( - () => - memoizeOne( - identity, - ( - [next]: MemoizeArgs, - [current]: MemoizeArgs - ): boolean => areCollectionsEqual(current, next) - ), - [] - ); - - // Perform validation on all of the touched fields - const failures = useMemo((): CurrentValidationFailures => { - // Return a deeply-memoized collection of validation failures - // (That is, only return a new reference if some value actually changed) - return memoizeFailures(validateFields(touchedFields)); - }, [validateFields, touchedFields, memoizeFailures]); - - // Create a function that determines if all fields on the form are currently - // valid, on demand. - const areAllFieldsValid = useCallback((): boolean => { - const results = validateFields( - new Set(fields.map(({ fieldName }) => fieldName)) - ); - return results.size === 0; - }, [validateFields, fields]); - - // Return public API - return [failures, areAllFieldsValid]; -} - -export default useValidation; diff --git a/src/client/ui/modules/authentication/components/AuthPageLayout.scss b/src/client/ui/modules/authentication/components/AuthPageLayout.scss deleted file mode 100644 index a845ae84..00000000 --- a/src/client/ui/modules/authentication/components/AuthPageLayout.scss +++ /dev/null @@ -1,24 +0,0 @@ -@use "@jyosuushi/palette"; -@use "@jyosuushi/ui/_constants"; - -.AuthPageLayout { - margin: auto; - padding: constants.$page-margin; - width: 75%; -} - -.pageHeader { - border-bottom: 1px solid palette.$app-outline; - font-size: 28px; - font-weight: bold; - margin: 0 auto 15px; - padding-bottom: 5px; - text-align: center; - width: 50%; -} - -.purpose { - font-size: 14px; - margin-bottom: 20px; - text-align: center; -} diff --git a/src/client/ui/modules/authentication/components/AuthPageLayout.tsx b/src/client/ui/modules/authentication/components/AuthPageLayout.tsx deleted file mode 100644 index e400002b..00000000 --- a/src/client/ui/modules/authentication/components/AuthPageLayout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; -import { FormattedMessage, MessageDescriptor } from "react-intl"; - -import styles from "./AuthPageLayout.scss"; - -interface ComponentProps { - children: React.ReactNode; - purpose: MessageDescriptor | null; - purposeValues?: Record; - title: MessageDescriptor; -} - -function AuthPageLayout({ - children, - purpose, - purposeValues, - title, -}: ComponentProps): React.ReactElement { - return ( -
-

- -

- {purpose && ( -

- -

- )} - {children} -
- ); -} - -export default AuthPageLayout; diff --git a/src/client/ui/modules/authentication/error-messages.ts b/src/client/ui/modules/authentication/error-messages.ts deleted file mode 100644 index 2135fa11..00000000 --- a/src/client/ui/modules/authentication/error-messages.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { defineMessages, MessageDescriptor } from "react-intl"; - -import { MIN_PASSWORD_LENGTH } from "@shared/constants"; - -const INTL_MESSAGES = defineMessages({ - errorEmailBadFormat: { - defaultMessage: "This should be an email.", - id: "authentication.errors.emailBadFormat", - }, - errorFieldEmpty: { - defaultMessage: "This field must be specified.", - id: "authentication.errors.fieldEmpty", - }, - errorPasswordMissingNumeral: { - defaultMessage: - "Your password must have at least one numerical character (0-9).", - id: "authentication.errors.passwordMissingNumeral", - }, - errorPasswordTooShort: { - defaultMessage: - "Your password must be at least {minLength, plural, one {1 character} other {# characters}} long.", - id: "authentication.errors.passwordTooShort", - }, - errorPasswordsDontMatch: { - defaultMessage: "Passwords do not match.", - id: "authentication.errors.passwordsDontMatch", - }, - errorUnknownError: { - defaultMessage: - "An error occurred while trying to process your request. Please try again in a moment.", - id: "authentication.errors.unknown", - }, -}); - -export interface ErrorMessageDefinition { - message: MessageDescriptor; - messageValues?: Record; -} - -export const ERROR_MESSAGE_PASSWORD_TOO_SHORT: ErrorMessageDefinition = { - message: INTL_MESSAGES.errorPasswordTooShort, - messageValues: { - minLength: MIN_PASSWORD_LENGTH, - }, -}; - -export const ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL: ErrorMessageDefinition = { - message: INTL_MESSAGES.errorPasswordMissingNumeral, -}; - -export const ERROR_MESSAGE_UNKNOWN_ERROR: ErrorMessageDefinition = { - message: INTL_MESSAGES.errorUnknownError, -}; - -export const ERROR_MESSAGE_EMAIL_DOESNT_LOOK_LIKE_EMAIL: ErrorMessageDefinition = { - message: INTL_MESSAGES.errorEmailBadFormat, -}; - -export const ERROR_MESSAGE_FIELD_EMPTY: ErrorMessageDefinition = { - message: INTL_MESSAGES.errorFieldEmpty, -}; - -export const ERROR_MESSAGE_PASSWORDS_DONT_MATCH: ErrorMessageDefinition = { - message: INTL_MESSAGES.errorPasswordsDontMatch, -}; diff --git a/src/client/ui/modules/authentication/form-validation.ts b/src/client/ui/modules/authentication/form-validation.ts deleted file mode 100644 index 64724c8f..00000000 --- a/src/client/ui/modules/authentication/form-validation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - PasswordValidationError, - validatePassword, -} from "@shared/authentication"; - -import { AuthFormFieldValidation } from "@jyosuushi/ui/modules/authentication/auth-form/types"; -import { - ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL, - ERROR_MESSAGE_PASSWORD_TOO_SHORT, - ERROR_MESSAGE_PASSWORDS_DONT_MATCH, -} from "@jyosuushi/ui/modules/authentication/error-messages"; - -export function makePasswordCreationFieldValidation< - TPasswordFieldName extends string ->( - field: TPasswordFieldName -): (fields: Record) => AuthFormFieldValidation { - return (values): AuthFormFieldValidation => { - const error = validatePassword(values[field]); - if (!error) { - return { - valid: true, - }; - } - - switch (error) { - case PasswordValidationError.TooShort: { - return { - reason: ERROR_MESSAGE_PASSWORD_TOO_SHORT, - valid: false, - }; - } - case PasswordValidationError.MissingNumeral: { - return { - reason: ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL, - valid: false, - }; - } - } - }; -} - -export function makePasswordConfirmationFieldValidation< - TPasswordFieldName extends string, - TConfirmFieldName extends string ->( - passwordField: TPasswordFieldName, - confirmField: TConfirmFieldName -): ( - fields: Record -) => AuthFormFieldValidation { - return (values): AuthFormFieldValidation => { - if (values[passwordField] === values[confirmField]) { - return { - valid: true, - }; - } - - return { - reason: ERROR_MESSAGE_PASSWORDS_DONT_MATCH, - valid: false, - }; - }; -} diff --git a/src/client/ui/modules/authentication/pages.ts b/src/client/ui/modules/authentication/pages.ts deleted file mode 100644 index ff97e932..00000000 --- a/src/client/ui/modules/authentication/pages.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { PageDefinition } from "@jyosuushi/ui/types"; - -import ForgotPasswordPage from "./pages/forgot-password/ForgotPasswordPage"; -import LoginPage from "./pages/login/LoginPage"; -import ProfilePageWrapper from "./pages/profile/ProfilePageWrapper"; -import RegisterAccountPage from "./pages/register-account/RegisterAccountPage"; -import ResetPasswordPage from "./pages/reset-password/ResetPasswordPage"; -import VerifyPage from "./pages/verify/VerifyPage"; - -export const LOGIN_PAGE: PageDefinition = { - aliasPaths: [], - component: LoginPage, - primaryPath: "/login", -}; - -export const FORGOT_PASSWORD_PAGE: PageDefinition = { - aliasPaths: [], - component: ForgotPasswordPage, - primaryPath: "/forgot-password", -}; - -export const PROFILE_PAGE: PageDefinition = { - aliasPaths: [], - component: ProfilePageWrapper, - primaryPath: "/profile", -}; - -export const REGISTER_ACCOUNT_PAGE: PageDefinition = { - aliasPaths: [], - component: RegisterAccountPage, - primaryPath: "/register", -}; - -const UUID_V4_REGEX = - "[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}"; - -export const RESET_PASSWORD_PAGE: PageDefinition = { - aliasPaths: [], - component: ResetPasswordPage, - primaryPath: `/reset-password/:firstCode(${UUID_V4_REGEX})/:secondCode(${UUID_V4_REGEX})`, -}; - -export const VERIFY_PAGE: PageDefinition = { - aliasPaths: [], - component: VerifyPage, - primaryPath: `/verify`, -}; diff --git a/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordForm.tsx b/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordForm.tsx deleted file mode 100644 index 22ad5d01..00000000 --- a/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordForm.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -import { defineMessages } from "react-intl"; - -import AuthForm from "@jyosuushi/ui/modules/authentication/auth-form/AuthForm"; -import { - AuthFormError, - AuthFormFieldDefinition, - AuthFormValues, -} from "@jyosuushi/ui/modules/authentication/auth-form/types"; - -type ForgotPasswordFormFields = "email"; - -export type ForgotPasswordFormError = AuthFormError; -export type ForgotPasswordFormValues = AuthFormValues; - -const INTL_MESSAGES = defineMessages({ - buttonResetPassword: { - defaultMessage: "Reset Password", - id: "forgot-password.form.button.resetPassword", - }, - labelEmail: { - defaultMessage: "Email", - id: "forgot-password.form.email.label", - }, -}); - -const FORGOT_PASSWORD_FORM_FIELDS: readonly AuthFormFieldDefinition< - ForgotPasswordFormFields ->[] = [ - { - fieldName: "email", - inputType: "username", - label: INTL_MESSAGES.labelEmail, - validation: null, - }, -]; - -interface ComponentProps { - onSubmit: ( - fields: ForgotPasswordFormValues - ) => Promise; -} - -function ForgotPasswordForm({ onSubmit }: ComponentProps): React.ReactElement { - return ( - - ); -} - -export default ForgotPasswordForm; diff --git a/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordPage.scss b/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordPage.scss deleted file mode 100644 index a50e2f3b..00000000 --- a/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordPage.scss +++ /dev/null @@ -1,15 +0,0 @@ -@import "@jyosuushi/palette"; - -.emailSent { - background-color: $blue-base; - border: 2px solid $blue-darkest; - border-radius: 4px; - padding: 20px 10px; - text-align: center; -} - -.bold { - display: block; - font-weight: bold; - margin-bottom: 10px; -} diff --git a/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordPage.tsx b/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordPage.tsx deleted file mode 100644 index 615d7b0d..00000000 --- a/src/client/ui/modules/authentication/pages/forgot-password/ForgotPasswordPage.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { defineMessages, FormattedMessage } from "react-intl"; -import { Redirect } from "react-router-dom"; - -import { ONE_SECOND, ONE_MINUTE } from "@shared/constants"; - -import useAuthenticationStatus, { - AuthenticationStatus, -} from "@jyosuushi/hooks/useAuthenticationStatus"; - -import { - AUTH_MUTATION_REFETCH_QUERIES, - AUTH_MUTATION_UPDATE_FUNCTION, -} from "@jyosuushi/graphql/authentication"; -import { - useRequestPasswordResetMutation, - RequestPasswordResetError, -} from "@jyosuushi/graphql/types.generated"; - -import AuthPageLayout from "@jyosuushi/ui/modules/authentication/components/AuthPageLayout"; -import { - ERROR_MESSAGE_EMAIL_DOESNT_LOOK_LIKE_EMAIL, - ERROR_MESSAGE_FIELD_EMPTY, - ERROR_MESSAGE_UNKNOWN_ERROR, -} from "@jyosuushi/ui/modules/authentication/error-messages"; - -import ForgotPasswordForm, { - ForgotPasswordFormError, - ForgotPasswordFormValues, -} from "./ForgotPasswordForm"; - -import styles from "./ForgotPasswordPage.scss"; - -const INTL_MESSAGES = defineMessages({ - errorRateLimited: { - defaultMessage: - "You have attempted to reset your password too many times too quickly. Please try again after a minute.", - id: "forgot-password.errors.rateLimited", - }, - formPurpose: { - defaultMessage: - "Use this form to send an email that will guide you through resetting your password.", - id: "forgot-password.description", - }, - header: { - defaultMessage: "Reset Password", - id: "forgot-password.header", - }, - possibleSuccess: { - defaultMessage: - "Please check your email inbox. If the email specified is registered with us, you will receive a link to reset your password shortly.", - id: "forgot-password.possibleSuccess", - }, -}); - -function BoldFormatting(...contents: readonly string[]): React.ReactElement { - return
{contents}
; -} - -function ForgotPasswordPage(): React.ReactElement { - const authStatus = useAuthenticationStatus(); - - // Manage state - const [possibleSuccess, setPossibleSuccess] = useState(false); - const [shouldRedirectToProfile, setShouldRedirectToProfile] = useState< - boolean - >(false); - - // Handle the submission of the login form - const [requestPasswordReset] = useRequestPasswordResetMutation({ - refetchQueries: AUTH_MUTATION_REFETCH_QUERIES, - update: AUTH_MUTATION_UPDATE_FUNCTION, - }); - const handleSubmit = useCallback( - async ({ - email, - }: ForgotPasswordFormValues): Promise => { - const result = await requestPasswordReset({ - variables: { - email, - }, - }); - - if (!result.data) { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - } - - const { error, possibleSuccess } = result.data.requestPasswordReset; - if (error) { - switch (error) { - case RequestPasswordResetError.EmailEmpty: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_FIELD_EMPTY, - specificField: "email", - }; - } - case RequestPasswordResetError.EmailInvalidFormat: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_EMAIL_DOESNT_LOOK_LIKE_EMAIL, - specificField: "email", - }; - } - case RequestPasswordResetError.RateLimited: { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_MINUTE, - }, - message: { message: INTL_MESSAGES.errorRateLimited }, - specificField: null, - }; - } - case RequestPasswordResetError.AlreadyAuthenticated: { - setShouldRedirectToProfile(true); - return null; - } - default: { - return error; - } - } - } - - if (possibleSuccess) { - setPossibleSuccess(true); - return null; - } - - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - }, - [requestPasswordReset] - ); - - // Redirect to the profile page if we're supposed to - if ( - authStatus === AuthenticationStatus.Authenticated || - shouldRedirectToProfile - ) { - return ; - } - - // If we've seen a POSSIBLE success, render the post-form page - if (possibleSuccess) { - return ( - -
- -
-
- ); - } - - // Render the appropriate form on the page - return ( - - - - ); -} - -export default ForgotPasswordPage; diff --git a/src/client/ui/modules/authentication/pages/login/LoginForm.scss b/src/client/ui/modules/authentication/pages/login/LoginForm.scss deleted file mode 100644 index 187c68c9..00000000 --- a/src/client/ui/modules/authentication/pages/login/LoginForm.scss +++ /dev/null @@ -1,3 +0,0 @@ -.forgotPasswordLink { - font-size: 12px; -} diff --git a/src/client/ui/modules/authentication/pages/login/LoginForm.tsx b/src/client/ui/modules/authentication/pages/login/LoginForm.tsx deleted file mode 100644 index 5c5ae465..00000000 --- a/src/client/ui/modules/authentication/pages/login/LoginForm.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from "react"; -import { defineMessages, FormattedMessage } from "react-intl"; -import { Link } from "react-router-dom"; - -import AuthForm from "@jyosuushi/ui/modules/authentication/auth-form/AuthForm"; -import { - AuthFormError, - AuthFormFieldDefinition, - AuthFormValues, -} from "@jyosuushi/ui/modules/authentication/auth-form/types"; - -import styles from "./LoginForm.scss"; - -type LoginFormFields = "email" | "password"; - -export type LoginFormError = AuthFormError; -export type LoginFormValues = AuthFormValues; - -const INTL_MESSAGES = defineMessages({ - buttonLogin: { - defaultMessage: "Login", - id: "login-page.login-form.buttons.login", - }, - labelEmail: { - defaultMessage: "Email", - id: "login-page.login-form.email.label", - }, - labelPassword: { - defaultMessage: "Password", - id: "login-page.login-form.password.label", - }, - linkForgotPassword: { - defaultMessage: "Forgot password?", - id: "login-page.login-form.forgot-password", - }, -}); - -const LOGIN_FORM_FIELDS: readonly AuthFormFieldDefinition[] = [ - { - fieldName: "email", - inputType: "username", - label: INTL_MESSAGES.labelEmail, - validation: null, - }, - { - fieldName: "password", - inputType: "password", - label: INTL_MESSAGES.labelPassword, - validation: null, - }, -]; - -interface ComponentProps { - onSubmit: (fields: LoginFormValues) => Promise; -} - -function LoginForm({ onSubmit }: ComponentProps): React.ReactElement { - return ( - - - - - - ); -} - -export default LoginForm; diff --git a/src/client/ui/modules/authentication/pages/login/LoginPage.scss b/src/client/ui/modules/authentication/pages/login/LoginPage.scss deleted file mode 100644 index 302f3cfe..00000000 --- a/src/client/ui/modules/authentication/pages/login/LoginPage.scss +++ /dev/null @@ -1,12 +0,0 @@ -@import "@jyosuushi/palette"; - -.separator { - background-color: $black-lightest; - height: 1px; - margin: 30px auto; - width: 50%; -} - -.registerAccount { - text-align: center; -} diff --git a/src/client/ui/modules/authentication/pages/login/LoginPage.tsx b/src/client/ui/modules/authentication/pages/login/LoginPage.tsx deleted file mode 100644 index 70d9acbd..00000000 --- a/src/client/ui/modules/authentication/pages/login/LoginPage.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { defineMessages, FormattedMessage } from "react-intl"; -import { Link, Redirect } from "react-router-dom"; - -import { ONE_SECOND, ONE_MINUTE } from "@shared/constants"; - -import useAuthenticationStatus, { - AuthenticationStatus, -} from "@jyosuushi/hooks/useAuthenticationStatus"; - -import { - AUTH_MUTATION_REFETCH_QUERIES, - AUTH_MUTATION_UPDATE_FUNCTION, -} from "@jyosuushi/graphql/authentication"; -import { - useLoginMutation, - LoginError, -} from "@jyosuushi/graphql/types.generated"; - -import AuthPageLayout from "@jyosuushi/ui/modules/authentication/components/AuthPageLayout"; -import { - ERROR_MESSAGE_FIELD_EMPTY, - ERROR_MESSAGE_UNKNOWN_ERROR, -} from "@jyosuushi/ui/modules/authentication/error-messages"; - -import LoginForm, { LoginFormError, LoginFormValues } from "./LoginForm"; -import ResendVerificationEmailForm from "./ResendVerificationEmailForm"; - -import styles from "./LoginPage.scss"; - -const INTL_MESSAGES = defineMessages({ - errorCredentialsInvalid: { - defaultMessage: "The email/password combination provided was incorrect.", - id: "login-page.errors.credentialsInvalid", - }, - errorRateLimited: { - defaultMessage: - "You have attempted to log in too many times. Please try again after a minute.", - id: "login-page.errors.rateLimited", - }, - headerLogin: { - defaultMessage: "Sign in", - id: "login-page.login.header", - }, - headerResendVerification: { - defaultMessage: "Verification Required", - id: "login-page.resend-email-verification.header", - }, - loginPurpose: { - defaultMessage: - "Log in to your account to create custom quizzes and track your results.", - id: "login-page.login.description", - }, - registerAccountParagraph: { - defaultMessage: - "Or, click here to register an account with us for free.", - id: "login-page.login.registerAccountParagraph", - }, -}); - -type LoginPageForm = - | { - type: "login"; - } - | { - type: "resend-email-verification"; - email: string; - password: string; - }; - -function renderRegisterLink( - ...contents: readonly string[] -): React.ReactElement { - return {contents}; -} - -function LoginPage(): React.ReactElement { - const authStatus = useAuthenticationStatus(); - - // Define component state - const [form, setForm] = useState({ - type: "login", - }); - const [shouldRedirectToProfile, setShouldRedirectToProfile] = useState< - boolean - >(false); - - // Handle the submission of the login form - const [login] = useLoginMutation({ - refetchQueries: AUTH_MUTATION_REFETCH_QUERIES, - update: AUTH_MUTATION_UPDATE_FUNCTION, - }); - const handleSubmit = useCallback( - async ({ - email, - password, - }: LoginFormValues): Promise => { - const result = await login({ - variables: { - email, - password, - }, - }); - - if (!result.data) { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - } - - const { error, user } = result.data.login; - if (error) { - switch (error) { - case LoginError.EmailEmpty: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_FIELD_EMPTY, - specificField: "email", - }; - } - case LoginError.PasswordEmpty: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_FIELD_EMPTY, - specificField: "password", - }; - } - case LoginError.EmailPasswordCombinationIncorrect: { - return { - dismissal: { - method: "field-change", - }, - message: { message: INTL_MESSAGES.errorCredentialsInvalid }, - specificField: null, - }; - } - case LoginError.RateLimited: { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_MINUTE, - }, - message: { message: INTL_MESSAGES.errorRateLimited }, - specificField: null, - }; - } - case LoginError.AlreadyAuthenticated: { - setShouldRedirectToProfile(true); - return null; - } - case LoginError.EmailNotVerified: { - setForm({ - email, - password, - type: "resend-email-verification", - }); - return null; - } - default: { - return error; - } - } - } - - if (!user) { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - } - - setShouldRedirectToProfile(true); - return null; - }, - [login] - ); - - // Handle events coming from the - const handleReturnToLoginForm = useCallback( - (): void => setForm({ type: "login" }), - [] - ); - const handleRedirectToProfile = useCallback( - (): void => setShouldRedirectToProfile(true), - [] - ); - - // Redirect to the profile page if we're supposed to - if ( - shouldRedirectToProfile || - authStatus === AuthenticationStatus.Authenticated - ) { - return ; - } - - // Render the appropriate form on the page - switch (form.type) { - case "login": { - return ( - - -
-

- -

- - ); - } - case "resend-email-verification": { - return ( - - - - ); - } - } -} - -export default LoginPage; diff --git a/src/client/ui/modules/authentication/pages/login/ResendVerificationEmailForm.scss b/src/client/ui/modules/authentication/pages/login/ResendVerificationEmailForm.scss deleted file mode 100644 index 28fe93f1..00000000 --- a/src/client/ui/modules/authentication/pages/login/ResendVerificationEmailForm.scss +++ /dev/null @@ -1,37 +0,0 @@ -@import "@jyosuushi/palette"; - -.container { - display: flex; - flex-direction: column; - gap: 20px; -} - -.explanationBrief { - background-color: $green-base; - border: 2px solid $green-darkest; - font-weight: bold; - padding: 20px 10px; - text-align: center; -} - -.explanationNormal { - text-align: center; -} - -.buttonContainer { - display: flex; - justify-content: center; -} - -.resendButton { - font-size: 16px; - text-align: center; -} - -.resendResult { - text-align: center; - - &.error { - color: $error-text; - } -} diff --git a/src/client/ui/modules/authentication/pages/login/ResendVerificationEmailForm.tsx b/src/client/ui/modules/authentication/pages/login/ResendVerificationEmailForm.tsx deleted file mode 100644 index 378dc4b2..00000000 --- a/src/client/ui/modules/authentication/pages/login/ResendVerificationEmailForm.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import classnames from "classnames"; -import React, { useCallback, useEffect, useState } from "react"; -import { - defineMessages, - FormattedMessage, - MessageDescriptor, -} from "react-intl"; - -import { ONE_MINUTE, ONE_SECOND } from "@shared/constants"; - -import { - useRequestEmailVerificationMutation, - RequestEmailVerificationError, -} from "@jyosuushi/graphql/types.generated"; - -import StandardButton from "@jyosuushi/ui/components/StandardButton"; - -import styles from "./ResendVerificationEmailForm.scss"; - -const INTL_MESSAGES = defineMessages({ - errorEmailSentTooRecently: { - defaultMessage: - "A verification link was recently sent to your inbox. Please wait a few minutes before requesting a new link.", - id: "login-page.verification-required.errorEmailSentTooRecently", - }, - errorRateLimited: { - defaultMessage: - "You've requested too many verification emails recently. Please wait a few minutes and try again.", - id: "login-page.verification-required.errorRateLimited", - }, - errorUnknownError: { - defaultMessage: - "An error occurred while trying to request your new verification email. Please try again in a few seconds or reach out to the developers.", - id: "login-page.verification-required.errorUnknownError", - }, - explanationBrief: { - defaultMessage: "You must verify your email before you can log in.", - id: "login-page.verification-required.explanationBrief", - }, - explanationDetailed: { - defaultMessage: - "A link was sent to your email when you registered; following that link will verify your account and allow you to log in.", - id: "login-page.verification-required.explanationDetailed", - }, - explanationResend: { - defaultMessage: - "If you haven't received the email or no longer have it, you can click the button below to send a new link to the same address.", - id: "login-page.verification-required.explanationResend", - }, - requestSuccessMessage: { - defaultMessage: - "A verification link was just sent to your email address. Give it a few seconds to arrive, and check your spam folder to make sure it wasn't caught there.", - id: "login-page.verification-required.requestSuccessMessage", - }, - resendButtonLabel: { - defaultMessage: "Resend Verification Email", - id: "login-page.verification-required.resendButtonLabel", - }, -}); - -interface ComponentProps { - email: string; - onRedirectToProfile: () => void; - onReturnToLoginForm: () => void; - password: string; -} - -type RequestResult = - | { - success: true; - } - | { - success: false; - - /** - * The localized error message that should be displayed to the user. - */ - message: MessageDescriptor; - - /** - * The amount of time the resend button should be disabled after this - * error, measured in milliseconds. - */ - disableDurationMs: number; - }; - -function ResendVerificationEmailForm({ - email, - onRedirectToProfile, - onReturnToLoginForm, - password, -}: ComponentProps): React.ReactElement { - // Define component state - const [isRequesting, setIsRequesting] = useState(false); - const [ - latestRequestResult, - setLatestRequestResult, - ] = useState(null); - - // Handle the user requesting a new email - const [requestEmailVerification] = useRequestEmailVerificationMutation({ - variables: { - email, - password, - }, - }); - const handleResendClick = useCallback(async (): Promise => { - setIsRequesting(true); - try { - const result = await requestEmailVerification(); - if (!result.data) { - // TODO - return; - } - - const { error, success } = result.data.requestEmailVerification; - if (error) { - switch (error) { - case RequestEmailVerificationError.EmailEmpty: - case RequestEmailVerificationError.PasswordEmpty: - case RequestEmailVerificationError.EmailPasswordCombinationIncorrect: - case RequestEmailVerificationError.EmailAlreadyVerified: { - onReturnToLoginForm(); - return; - } - case RequestEmailVerificationError.RateLimited: { - setLatestRequestResult({ - disableDurationMs: ONE_MINUTE, - message: INTL_MESSAGES.errorRateLimited, - success: false, - }); - return; - } - case RequestEmailVerificationError.VerificationEmailSentTooRecently: { - setLatestRequestResult({ - disableDurationMs: ONE_MINUTE, - message: INTL_MESSAGES.errorEmailSentTooRecently, - success: false, - }); - return; - } - case RequestEmailVerificationError.AlreadyAuthenticated: { - onRedirectToProfile(); - return; - } - default: { - setLatestRequestResult(error); - return; - } - } - } - - if (success) { - setLatestRequestResult({ - success: true, - }); - return; - } - - setLatestRequestResult({ - disableDurationMs: ONE_SECOND * 15, - message: INTL_MESSAGES.errorUnknownError, - success: false, - }); - } catch { - setLatestRequestResult({ - disableDurationMs: ONE_SECOND * 15, - message: INTL_MESSAGES.errorUnknownError, - success: false, - }); - } finally { - setIsRequesting(false); - } - }, [onRedirectToProfile, onReturnToLoginForm, requestEmailVerification]); - - // Clear the latest result, if we have one - useEffect(() => { - if (!latestRequestResult) { - return; - } - - const duration = latestRequestResult.success - ? ONE_SECOND * 30 - : latestRequestResult.disableDurationMs; - const timeoutId = window.setTimeout( - (): void => setLatestRequestResult(null), - duration - ); - return (): void => window.clearTimeout(timeoutId); - }, [latestRequestResult]); - - // Render the component - return ( -
-
- -
-
- -
-
- -
-
- - - -
- {latestRequestResult && ( -
- {latestRequestResult.success ? ( - - ) : ( - - )} -
- )} -
- ); -} - -export default ResendVerificationEmailForm; diff --git a/src/client/ui/modules/authentication/pages/profile/ProfilePage.scss b/src/client/ui/modules/authentication/pages/profile/ProfilePage.scss deleted file mode 100644 index 16ae0070..00000000 --- a/src/client/ui/modules/authentication/pages/profile/ProfilePage.scss +++ /dev/null @@ -1,36 +0,0 @@ -@import "@jyosuushi/palette"; - -.grid { - display: grid; - gap: 30px 20px; - grid-template-columns: 1fr 1fr; -} - -.doubleColumnField { - grid-column: 1 / 3; -} - -.passwordLastChangedLabel { - font-size: 12px; - margin-left: 10px; -} - -.passwordLastChangedDate { - font-weight: bold; -} - -.logoutButtonContainer { - align-items: center; - border-top: 1px solid $pink-dark; - display: flex; - justify-content: center; - margin: 50px auto 0; - padding: 15px; - width: 80%; -} - -.logoutButton { - background-color: $blue-light; - border-color: $blue-darkest; - font-size: 16px; -} diff --git a/src/client/ui/modules/authentication/pages/profile/ProfilePage.tsx b/src/client/ui/modules/authentication/pages/profile/ProfilePage.tsx deleted file mode 100644 index 8a381ece..00000000 --- a/src/client/ui/modules/authentication/pages/profile/ProfilePage.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { FormattedDate, FormattedMessage, defineMessages } from "react-intl"; - -import { - AUTH_MUTATION_REFETCH_QUERIES, - AUTH_MUTATION_UPDATE_FUNCTION, -} from "@jyosuushi/graphql/authentication"; -import { useLogoutMutation } from "@jyosuushi/graphql/types.generated"; - -import StandardButton from "@jyosuushi/ui/components/StandardButton"; - -import AuthPageLayout from "@jyosuushi/ui/modules/authentication/components/AuthPageLayout"; - -import ChangePasswordModal from "./change-password/ChangePasswordModal"; -import ProfilePageField from "./ProfilePageField"; -import { ProfileData } from "./types"; - -import styles from "./ProfilePage.scss"; - -const INTL_MESSAGES = defineMessages({ - changePasswordButtonLabel: { - defaultMessage: "Change Password", - id: "profile-page.fields.password.open-modal-button-label", - }, - headerDateRegistered: { - defaultMessage: "Date Registered", - id: "profile-page.fields.date-registered.header", - }, - headerEmail: { - defaultMessage: "Email", - id: "profile-page.fields.email.header", - }, - headerPassword: { - defaultMessage: "Password", - id: "profile-page.fields.password.header", - }, - logoutButtonLabel: { - defaultMessage: "Logout", - id: "profile-page.logout.button-label", - }, - pagePurpose: { - defaultMessage: "Manage your account details and track your progress.", - id: "profile-page.page-purpose", - }, - pageTitle: { - defaultMessage: "Your Profile", - id: "profile-page.page-title", - }, - passwordLastChangedLabel: { - defaultMessage: "Date last changed: {date}", - id: "profile-page.fields.password.last-changed-label", - }, -}); - -interface ComponentProps { - data: ProfileData; -} - -function ProfilePage({ data }: ComponentProps): React.ReactElement { - // Define state - const [changePasswordModal, setChangePasswordModal] = useState< - "open" | "closed" - >("closed"); - - // Connect with GraphQL - const [logoutMutation] = useLogoutMutation({ - refetchQueries: AUTH_MUTATION_REFETCH_QUERIES, - update: AUTH_MUTATION_UPDATE_FUNCTION, - }); - - // Handle events - const handleChangePasswordClick = useCallback((): void => { - setChangePasswordModal("open"); - }, []); - - const handleRequestCloseChangePassword = useCallback((): void => { - setChangePasswordModal("closed"); - }, []); - - const handleLogoutClick = useCallback((): void => { - logoutMutation(); - }, [logoutMutation]); - - // Render the component that performs the verification - return ( - -
- - {data.email} - - - - - - - - - - - - - ), - }} - /> - - {changePasswordModal === "open" && ( - - )} - -
-
- - - -
-
- ); -} - -export default ProfilePage; diff --git a/src/client/ui/modules/authentication/pages/profile/ProfilePageField.scss b/src/client/ui/modules/authentication/pages/profile/ProfilePageField.scss deleted file mode 100644 index 7090a0ff..00000000 --- a/src/client/ui/modules/authentication/pages/profile/ProfilePageField.scss +++ /dev/null @@ -1,18 +0,0 @@ -@import "@jyosuushi/palette"; - -.ProfilePageField { - display: flex; - flex-direction: column; - gap: 3px; -} - -.header { - border-bottom: 1px solid $black-lightest; - font-size: 13px; - font-weight: bold; - padding-bottom: 1px; -} - -.content { - padding: 0 5px; -} diff --git a/src/client/ui/modules/authentication/pages/profile/ProfilePageField.tsx b/src/client/ui/modules/authentication/pages/profile/ProfilePageField.tsx deleted file mode 100644 index 769d7c5e..00000000 --- a/src/client/ui/modules/authentication/pages/profile/ProfilePageField.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import classnames from "classnames"; -import React from "react"; -import { FormattedMessage, MessageDescriptor } from "react-intl"; - -import styles from "./ProfilePageField.scss"; - -type ValidElement = React.ReactElement | string | false; - -interface ComponentProps { - children: ValidElement | ValidElement[]; - className?: string; - header: MessageDescriptor; -} - -function ProfilePageField({ - children, - className, - header, -}: ComponentProps): React.ReactElement { - // Render the component - return ( -
-
- -
-
{children}
-
- ); -} - -export default ProfilePageField; diff --git a/src/client/ui/modules/authentication/pages/profile/ProfilePageWrapper.scss b/src/client/ui/modules/authentication/pages/profile/ProfilePageWrapper.scss deleted file mode 100644 index dfd033de..00000000 --- a/src/client/ui/modules/authentication/pages/profile/ProfilePageWrapper.scss +++ /dev/null @@ -1,6 +0,0 @@ -.loadingWrapper { - align-items: center; - display: flex; - justify-content: center; - padding: 100px 0; -} diff --git a/src/client/ui/modules/authentication/pages/profile/ProfilePageWrapper.tsx b/src/client/ui/modules/authentication/pages/profile/ProfilePageWrapper.tsx deleted file mode 100644 index 822585d7..00000000 --- a/src/client/ui/modules/authentication/pages/profile/ProfilePageWrapper.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useMemo } from "react"; -import { Redirect } from "react-router-dom"; - -import { useAuthenticatedProfileQueryQuery } from "@jyosuushi/graphql/types.generated"; - -import LoadingSpinner from "@jyosuushi/ui/components/LoadingSpinner"; - -import ProfilePage from "./ProfilePage"; -import { ProfileData } from "./types"; - -import styles from "./ProfilePageWrapper.scss"; - -function ProfilePageWrapper(): React.ReactElement { - const { data, loading, error } = useAuthenticatedProfileQueryQuery(); - - // Coerce the GraphQL data into a memoized version of the profile data - // object - const profileData = useMemo((): ProfileData | null => { - if (loading || !data || error || !data.activeUser) { - return null; - } - - return { - dateRegistered: data.activeUser.dateRegistered, - email: data.activeUser.username, - passwordLastChanged: data.activeUser.passwordLastChanged, - }; - }, [data, error, loading]); - - // If we're still loading, then we'll render the loading spinner while we - // wait to resolve to authenticated/not authenticated. - if (loading) { - return ( -
- -
- ); - } - - // Redirect to the login screen if we aren't authenticated (we don't have - // an active user) - if (!profileData) { - return ; - } - - // Render the profile page once we've confirmed that we are authenticated. - return ; -} - -export default ProfilePageWrapper; diff --git a/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordForm.tsx b/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordForm.tsx deleted file mode 100644 index 881c8cef..00000000 --- a/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordForm.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useCallback, useMemo } from "react"; -import { defineMessages } from "react-intl"; - -import AuthForm from "@jyosuushi/ui/modules/authentication/auth-form/AuthForm"; -import { - AuthFormContext, - AuthFormError, - AuthFormFieldDefinition, - AuthFormValues, -} from "@jyosuushi/ui/modules/authentication/auth-form/types"; -import { - makePasswordConfirmationFieldValidation, - makePasswordCreationFieldValidation, -} from "@jyosuushi/ui/modules/authentication/form-validation"; - -export type ChangePasswordFormFields = - | "oldPassword" - | "newPassword" - | "confirmPassword"; -export type ChangePasswordFormError = AuthFormError; - -interface ComponentProps { - onSubmit: ( - oldPassword: string, - newPassword: string - ) => Promise; - - /** - * The username of the user who is currently logged in and whose password is - * being changed. - */ - username: string; -} - -const INTL_MESSAGES = defineMessages({ - buttonConfirm: { - defaultMessage: "Change Password", - id: "profile.change-password.form.buttons.confirm", - }, - labelConfirmPassword: { - defaultMessage: "Confirm New Password", - id: "profile.change-password.form.confirm-password.label", - }, - labelNewPassword: { - defaultMessage: "New Password", - id: "profile.change-password.form.new-password.label", - }, - labelOldPassword: { - defaultMessage: "Current Password", - id: "profile.change-password.form.old-password.label", - }, -}); - -const FORM_FIELDS: readonly AuthFormFieldDefinition< - ChangePasswordFormFields ->[] = [ - { - fieldName: "oldPassword", - inputType: "password", - label: INTL_MESSAGES.labelOldPassword, - validation: null, - }, - { - fieldName: "newPassword", - inputType: "password", - label: INTL_MESSAGES.labelNewPassword, - validation: makePasswordCreationFieldValidation("newPassword"), - }, - { - fieldName: "confirmPassword", - inputType: "password", - label: INTL_MESSAGES.labelConfirmPassword, - validation: makePasswordConfirmationFieldValidation( - "newPassword", - "confirmPassword" - ), - }, -]; - -function ChangePasswordForm({ - onSubmit, - username, -}: ComponentProps): React.ReactElement { - // Wrap the submit callback to simplify the signature - const handleSubmit = useCallback( - ( - values: AuthFormValues - ): Promise => - onSubmit(values.oldPassword, values.newPassword), - [onSubmit] - ); - - // Create the form context from incoming parameters - const context = useMemo((): AuthFormContext => ({ username }), [username]); - - // Render the component - return ( - - ); -} - -export default ChangePasswordForm; diff --git a/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordModal.scss b/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordModal.scss deleted file mode 100644 index e9e86186..00000000 --- a/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordModal.scss +++ /dev/null @@ -1,3 +0,0 @@ -.modalContents { - padding: 20px; -} diff --git a/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordModal.tsx b/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordModal.tsx deleted file mode 100644 index 985ee158..00000000 --- a/src/client/ui/modules/authentication/pages/profile/change-password/ChangePasswordModal.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import React, { useCallback } from "react"; -import { defineMessages } from "react-intl"; - -import { ONE_SECOND, ONE_MINUTE } from "@shared/constants"; - -import { - ChangePasswordError, - useChangePasswordMutation, -} from "@jyosuushi/graphql/types.generated"; - -import Modal from "@jyosuushi/ui/components/popups/Modal"; - -import { - ERROR_MESSAGE_FIELD_EMPTY, - ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL, - ERROR_MESSAGE_PASSWORD_TOO_SHORT, - ERROR_MESSAGE_UNKNOWN_ERROR, -} from "@jyosuushi/ui/modules/authentication/error-messages"; - -import ChangePasswordForm, { - ChangePasswordFormError, -} from "./ChangePasswordForm"; - -import styles from "./ChangePasswordModal.scss"; - -const INTL_MESSAGES = defineMessages({ - errorNoLongerAuthenticated: { - defaultMessage: "An error has occurred. Please try refreshing the page.", - id: "profile-page.change-password-modal.errors.noLongerAuthenticated", - }, - errorOldPasswordIncorrect: { - defaultMessage: "Password does not match your current password.", - id: "profile-page.change-password-modal.errors.oldPasswordIncorrect", - }, - errorRateLimited: { - defaultMessage: - "You have attempted to change your password too many times too quickly. Please try again after a minute.", - id: "profile-page.change-password-modal.errors.rateLimited", - }, - modalHeader: { - defaultMessage: "Change Password", - id: "profile-page.change-password-modal.header", - }, -}); - -interface ComponentProps { - onRequestClose: () => void; - - /** - * The username of the user who is currently logged in and whose password is - * being changed. - */ - username: string; -} - -function ChangePasswordModal({ - onRequestClose, - username, -}: ComponentProps): React.ReactElement { - // Connect with GraphQL - const [changePassword] = useChangePasswordMutation(); - - // Handle the submission of the change password form - const handleSubmit = useCallback( - async ( - oldPassword: string, - newPassword: string - ): Promise => { - const result = await changePassword({ - variables: { - newPassword, - oldPassword, - }, - }); - - if (!result.data) { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - } - - const { error, success } = result.data.changePassword; - if (error) { - switch (error) { - case ChangePasswordError.OldPasswordEmpty: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_FIELD_EMPTY, - specificField: "oldPassword", - }; - } - case ChangePasswordError.OldPasswordNotCorrect: { - return { - dismissal: { - method: "field-change", - }, - message: { - message: INTL_MESSAGES.errorOldPasswordIncorrect, - }, - specificField: "oldPassword", - }; - } - case ChangePasswordError.NewPasswordTooShort: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_PASSWORD_TOO_SHORT, - specificField: "newPassword", - }; - } - case ChangePasswordError.NewPasswordMissingNumeral: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL, - specificField: "newPassword", - }; - } - case ChangePasswordError.RateLimited: { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_MINUTE, - }, - message: { message: INTL_MESSAGES.errorRateLimited }, - specificField: null, - }; - } - case ChangePasswordError.NotAuthenticated: { - return { - dismissal: { - method: "field-change", - }, - message: { - message: INTL_MESSAGES.errorNoLongerAuthenticated, - }, - specificField: null, - }; - } - default: { - return error; - } - } - } - - if (!success) { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - } - - onRequestClose(); - return null; - }, - [changePassword, onRequestClose] - ); - - // Render the component - return ( - - - - ); -} - -export default ChangePasswordModal; diff --git a/src/client/ui/modules/authentication/pages/profile/types.ts b/src/client/ui/modules/authentication/pages/profile/types.ts deleted file mode 100644 index 662face5..00000000 --- a/src/client/ui/modules/authentication/pages/profile/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ProfileData { - dateRegistered: Date; - email: string; - passwordLastChanged: Date; -} diff --git a/src/client/ui/modules/authentication/pages/register-account/RegisterAccountForm.tsx b/src/client/ui/modules/authentication/pages/register-account/RegisterAccountForm.tsx deleted file mode 100644 index f64ee7ff..00000000 --- a/src/client/ui/modules/authentication/pages/register-account/RegisterAccountForm.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react"; -import { defineMessages } from "react-intl"; - -import AuthForm from "@jyosuushi/ui/modules/authentication/auth-form/AuthForm"; -import { - AuthFormError, - AuthFormFieldDefinition, - AuthFormValues, -} from "@jyosuushi/ui/modules/authentication/auth-form/types"; -import { makePasswordCreationFieldValidation } from "@jyosuushi/ui/modules/authentication/form-validation"; - -type RegisterAccountFormFields = "email" | "password"; - -export type RegisterAccountFormError = AuthFormError; -export type RegisterAccountFormValues = AuthFormValues< - RegisterAccountFormFields ->; - -const INTL_MESSAGES = defineMessages({ - buttonRegister: { - defaultMessage: "Register", - id: "register-account.RegisterAccountForm.buttons.register", - }, - labelEmail: { - defaultMessage: "Email", - id: "register-account.RegisterAccountForm.email.label", - }, - labelPassword: { - defaultMessage: "Password", - id: "register-account.RegisterAccountForm.password.label", - }, -}); - -const REGISTER_ACCOUNT_FORM_FIELDS: readonly AuthFormFieldDefinition< - RegisterAccountFormFields ->[] = [ - { - fieldName: "email", - inputType: "username", - label: INTL_MESSAGES.labelEmail, - validation: null, - }, - { - fieldName: "password", - inputType: "password", - label: INTL_MESSAGES.labelPassword, - validation: makePasswordCreationFieldValidation("password"), - }, -]; - -interface ComponentProps { - onSubmit: ( - fields: RegisterAccountFormValues - ) => Promise; -} - -function RegisterAccountForm({ onSubmit }: ComponentProps): React.ReactElement { - return ( - - ); -} - -export default RegisterAccountForm; diff --git a/src/client/ui/modules/authentication/pages/register-account/RegisterAccountPage.scss b/src/client/ui/modules/authentication/pages/register-account/RegisterAccountPage.scss deleted file mode 100644 index fb1b0a55..00000000 --- a/src/client/ui/modules/authentication/pages/register-account/RegisterAccountPage.scss +++ /dev/null @@ -1,35 +0,0 @@ -@import "@jyosuushi/palette"; - -.RegisterAccountPage { - margin: auto; - width: 75%; -} - -.pageHeader { - border-bottom: 1px solid $app-outline; - font-size: 28px; - font-weight: bold; - margin: 0 auto 15px; - padding-bottom: 5px; - text-align: center; - width: 50%; -} - -.formPurpose { - font-size: 14px; - margin-bottom: 20px; - text-align: center; -} - -.registerSuccess { - background-color: $blue-base; - border: 2px solid $blue-darkest; - border-radius: 4px; - padding: 20px 10px; - text-align: center; -} - -.bold { - display: inline; - font-weight: bold; -} diff --git a/src/client/ui/modules/authentication/pages/register-account/RegisterAccountPage.tsx b/src/client/ui/modules/authentication/pages/register-account/RegisterAccountPage.tsx deleted file mode 100644 index 049ff839..00000000 --- a/src/client/ui/modules/authentication/pages/register-account/RegisterAccountPage.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { defineMessages, FormattedMessage } from "react-intl"; -import { Redirect } from "react-router-dom"; - -import { ONE_SECOND, ONE_MINUTE } from "@shared/constants"; - -import useAuthenticationStatus, { - AuthenticationStatus, -} from "@jyosuushi/hooks/useAuthenticationStatus"; - -import { - AUTH_MUTATION_REFETCH_QUERIES, - AUTH_MUTATION_UPDATE_FUNCTION, -} from "@jyosuushi/graphql/authentication"; -import { - useRegisterAccountMutation, - RegisterAccountError, -} from "@jyosuushi/graphql/types.generated"; - -import { - ERROR_MESSAGE_EMAIL_DOESNT_LOOK_LIKE_EMAIL, - ERROR_MESSAGE_FIELD_EMPTY, - ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL, - ERROR_MESSAGE_PASSWORD_TOO_SHORT, - ERROR_MESSAGE_UNKNOWN_ERROR, -} from "@jyosuushi/ui/modules/authentication/error-messages"; - -import RegisterAccountForm, { - RegisterAccountFormError, - RegisterAccountFormValues, -} from "./RegisterAccountForm"; - -import styles from "./RegisterAccountPage.scss"; - -const INTL_MESSAGES = defineMessages({ - errorAccountAlreadyExists: { - defaultMessage: "An account with this email already exists.", - id: "register-account.RegisterAccountPage.errors.accountAlreadyExists", - }, - errorRateLimited: { - defaultMessage: - "You have attempted to register an account too many times. Please try again after a minute.", - id: "register-account.RegisterAccountPage.errors.rateLimited", - }, - headerRegister: { - defaultMessage: "Register Account", - id: "register-account.RegisterAccountPage.header", - }, - registerPurpose: { - defaultMessage: - "Register an account for free to create custom quizzes and track your results.", - id: "register-account.RegisterAccountPage.description", - }, - registerSuccess: { - defaultMessage: - "Your account has been registered! Please check your email to verify your account and log in.", - id: "register-account.RegisterAccountPage.success", - }, -}); - -function BoldFormatting(...contents: readonly string[]): React.ReactElement { - return
{contents}
; -} - -function RegisterAccountPage(): React.ReactElement { - const authStatus = useAuthenticationStatus(); - - // Define component state - const [hasRegisteredAccount, setHasRegisteredAccount] = useState( - false - ); - - // Handle the submission of the login form - const [registerAccount] = useRegisterAccountMutation({ - refetchQueries: AUTH_MUTATION_REFETCH_QUERIES, - update: AUTH_MUTATION_UPDATE_FUNCTION, - }); - const handleSubmit = useCallback( - async ({ - email, - password, - }: RegisterAccountFormValues): Promise => { - const result = await registerAccount({ - variables: { - email, - password, - }, - }); - - if (!result.data) { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - } - - const { error, success } = result.data.registerAccount; - if (error) { - switch (error) { - case RegisterAccountError.EmailEmpty: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_FIELD_EMPTY, - specificField: "email", - }; - } - case RegisterAccountError.EmailInvalidFormat: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_EMAIL_DOESNT_LOOK_LIKE_EMAIL, - specificField: "email", - }; - } - case RegisterAccountError.PasswordTooShort: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_PASSWORD_TOO_SHORT, - specificField: "password", - }; - } - case RegisterAccountError.PasswordMissingNumeral: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL, - specificField: null, - }; - } - case RegisterAccountError.RateLimited: { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_MINUTE, - }, - message: { message: INTL_MESSAGES.errorRateLimited }, - specificField: null, - }; - } - case RegisterAccountError.AlreadyAuthenticated: { - // setShouldRedirectToProfile(true); - return null; - } - case RegisterAccountError.AccountAlreadyExists: { - return { - dismissal: { - method: "field-change", - }, - message: { message: INTL_MESSAGES.errorAccountAlreadyExists }, - specificField: "email", - }; - } - default: { - return error; - } - } - } - - if (!success) { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - } - - setHasRegisteredAccount(true); - return null; - }, - [registerAccount] - ); - - // Redirect to the profile page if we're supposed to - if (authStatus === AuthenticationStatus.Authenticated) { - return ; - } - - // Render the register account page - let pageContents: React.ReactElement; - if (hasRegisteredAccount) { - pageContents = ( -

- -

- ); - } else { - pageContents = ( - <> -

- -

- - - ); - } - - return ( -
-

- -

- {pageContents} -
- ); -} - -export default RegisterAccountPage; diff --git a/src/client/ui/modules/authentication/pages/reset-password/ResetPasswordForm.tsx b/src/client/ui/modules/authentication/pages/reset-password/ResetPasswordForm.tsx deleted file mode 100644 index a9dada49..00000000 --- a/src/client/ui/modules/authentication/pages/reset-password/ResetPasswordForm.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from "react"; -import { defineMessages } from "react-intl"; - -import AuthForm from "@jyosuushi/ui/modules/authentication/auth-form/AuthForm"; -import { - AuthFormError, - AuthFormFieldDefinition, - AuthFormValues, -} from "@jyosuushi/ui/modules/authentication/auth-form/types"; -import { - makePasswordCreationFieldValidation, - makePasswordConfirmationFieldValidation, -} from "@jyosuushi/ui/modules/authentication/form-validation"; - -type ResetPasswordFormFields = "password" | "confirmPassword"; - -export type ResetPasswordFormError = AuthFormError; -export type ResetPasswordFormValues = AuthFormValues; - -const INTL_MESSAGES = defineMessages({ - buttonReset: { - defaultMessage: "Change Password", - id: "reset-password.reset-password-form.buttons.changePassword", - }, - labelConfirmPassword: { - defaultMessage: "Confirm password", - id: "reset-password.reset-password-form.confirmPassword.label", - }, - labelPassword: { - defaultMessage: "Password", - id: "reset-password.reset-password-form.password.label", - }, - validationPasswordsDontMatch: { - defaultMessage: "Passwords do not match.", - id: "reset-password.reset-password-form.validation.passwordsDontMatch", - }, -}); - -const RESET_PASSWORD_FORM_FIELDS: readonly AuthFormFieldDefinition< - ResetPasswordFormFields ->[] = [ - { - fieldName: "password", - inputType: "password", - label: INTL_MESSAGES.labelPassword, - validation: makePasswordCreationFieldValidation("password"), - }, - { - fieldName: "confirmPassword", - inputType: "password", - label: INTL_MESSAGES.labelConfirmPassword, - validation: makePasswordConfirmationFieldValidation( - "password", - "confirmPassword" - ), - }, -]; - -interface ComponentProps { - onSubmit: ( - fields: ResetPasswordFormValues - ) => Promise; -} - -function ResetPasswordForm({ onSubmit }: ComponentProps): React.ReactElement { - return ( - - ); -} - -export default ResetPasswordForm; diff --git a/src/client/ui/modules/authentication/pages/reset-password/ResetPasswordPage.tsx b/src/client/ui/modules/authentication/pages/reset-password/ResetPasswordPage.tsx deleted file mode 100644 index d55e203a..00000000 --- a/src/client/ui/modules/authentication/pages/reset-password/ResetPasswordPage.tsx +++ /dev/null @@ -1,202 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { defineMessages } from "react-intl"; -import { RouteComponentProps } from "react-router"; -import { Redirect } from "react-router-dom"; - -import { MIN_PASSWORD_LENGTH, ONE_SECOND, ONE_MINUTE } from "@shared/constants"; - -import useAuthenticationStatus, { - AuthenticationStatus, -} from "@jyosuushi/hooks/useAuthenticationStatus"; - -import { - AUTH_MUTATION_REFETCH_QUERIES, - AUTH_MUTATION_UPDATE_FUNCTION, -} from "@jyosuushi/graphql/authentication"; -import { - useRedeemPasswordResetMutation, - RedeemPasswordResetError, -} from "@jyosuushi/graphql/types.generated"; - -import AuthPageLayout from "@jyosuushi/ui/modules/authentication/components/AuthPageLayout"; -import { - ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL, - ERROR_MESSAGE_PASSWORD_TOO_SHORT, - ERROR_MESSAGE_UNKNOWN_ERROR, -} from "@jyosuushi/ui/modules/authentication/error-messages"; - -import ResetPasswordForm, { - ResetPasswordFormError, - ResetPasswordFormValues, -} from "./ResetPasswordForm"; - -const INTL_MESSAGES = defineMessages({ - errorRateLimited: { - defaultMessage: - "You have attempted to reset your password too many times too quickly. Please try again after a minute.", - id: "reset-password.errors.rateLimited", - }, - formPurpose: { - defaultMessage: - "Choose a new password for your account. It should be at least {minLength, plural, one {# character} other {# characters}} long and include at least one numeral.", - id: "reset-password.description", - }, - header: { - defaultMessage: "Reset Password", - id: "reset-password.header", - }, -}); - -export interface ResetPasswordPagePathParams { - firstCode: string; - secondCode: string; -} - -type ComponentProps = RouteComponentProps; - -enum RedirectError { - CodesBadFormat = "codes-bad-format", - CodesInvalidOrExpired = "codes-invalid-or-expired", -} - -const PURPOSE_VALUES: Record = { - minLength: MIN_PASSWORD_LENGTH, -}; - -function ResetPasswordPage({ - match: { - params: { firstCode, secondCode }, - }, -}: ComponentProps): React.ReactElement { - const authStatus = useAuthenticationStatus(); - - // // Manage state - const [redirectError, setRedirectError] = useState( - null - ); - const [shouldRedirectToProfile, setShouldRedirectToProfile] = useState< - boolean - >(false); - - // // Handle the submission of the login form - const [redeemPasswordReset] = useRedeemPasswordResetMutation({ - refetchQueries: AUTH_MUTATION_REFETCH_QUERIES, - update: AUTH_MUTATION_UPDATE_FUNCTION, - }); - const handleSubmit = useCallback( - async ({ - password, - }: ResetPasswordFormValues): Promise => { - const result = await redeemPasswordReset({ - variables: { - firstCode, - password, - secondCode, - }, - }); - - if (!result.data) { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - } - - const { error, user } = result.data.redeemPasswordReset; - if (error) { - switch (error) { - case RedeemPasswordResetError.FirstCodeBadFormat: - case RedeemPasswordResetError.SecondCodeBadFormat: { - setRedirectError(RedirectError.CodesBadFormat); - return null; - } - case RedeemPasswordResetError.CodesInvalid: { - setRedirectError(RedirectError.CodesInvalidOrExpired); - return null; - } - case RedeemPasswordResetError.PasswordTooShort: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_PASSWORD_TOO_SHORT, - specificField: "password", - }; - } - case RedeemPasswordResetError.PasswordMissingNumeral: { - return { - dismissal: { - method: "field-change", - }, - message: ERROR_MESSAGE_PASSWORD_MISSING_NUMERAL, - specificField: "password", - }; - } - case RedeemPasswordResetError.RateLimited: { - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_MINUTE, - }, - message: { message: INTL_MESSAGES.errorRateLimited }, - specificField: null, - }; - } - case RedeemPasswordResetError.AlreadyAuthenticated: { - setShouldRedirectToProfile(true); - return null; - } - default: { - return error; - } - } - } - - if (user) { - setShouldRedirectToProfile(true); - return null; - } - - return { - dismissal: { - method: "time-elapsed", - milliseconds: ONE_SECOND * 20, - }, - message: ERROR_MESSAGE_UNKNOWN_ERROR, - specificField: null, - }; - }, - [redeemPasswordReset, firstCode, secondCode] - ); - - // Redirect to the profile page if we're supposed to - if ( - authStatus === AuthenticationStatus.Authenticated || - shouldRedirectToProfile - ) { - return ; - } - - // Redirect to the login screen if we have an error that requires - // redirection - if (redirectError) { - return ; - } - - // Render the appropriate form on the page - return ( - - - - ); -} - -export default ResetPasswordPage; diff --git a/src/client/ui/modules/authentication/pages/verify/VerificationPerformer.tsx b/src/client/ui/modules/authentication/pages/verify/VerificationPerformer.tsx deleted file mode 100644 index 6352fbe6..00000000 --- a/src/client/ui/modules/authentication/pages/verify/VerificationPerformer.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useCallback, useEffect } from "react"; - -import { - AUTH_MUTATION_REFETCH_QUERIES, - AUTH_MUTATION_UPDATE_FUNCTION, -} from "@jyosuushi/graphql/authentication"; -import { - useVerifyEmailMutationMutation, - VerifyEmailError, -} from "@jyosuushi/graphql/types.generated"; - -interface ComponentProps { - code: string; - email: string; - - /** - * A callback that will be invoked asynchronously if, after the verification - * process, it's determined that the user should be redirected to the login - * screen. - */ - onRedirectToLogin: () => void; - - /** - * A callback that will be invoked asynchronously if, during or after the - * verification process, it's determined that the user should be redirected - * to their profile page. - */ - onRedirectToProfile: () => void; -} - -function VerificationPerformer({ - code, - email, - onRedirectToLogin, - onRedirectToProfile, -}: ComponentProps): null { - // Connect to the GraphQL - const [verifyEmail] = useVerifyEmailMutationMutation({ - refetchQueries: AUTH_MUTATION_REFETCH_QUERIES, - update: AUTH_MUTATION_UPDATE_FUNCTION, - }); - - // Create a wrapper that invokes the mutation and then processes the - // result - const performVerification = useCallback(async (): Promise => { - const result = await verifyEmail({ - variables: { - code, - email, - }, - }); - - if (!result.data) { - onRedirectToLogin(); - return; - } - - const { error, user } = result.data.verifyEmail; - if (error) { - switch (error) { - case VerifyEmailError.CodeEmpty: - case VerifyEmailError.CodeInvalidFormat: - case VerifyEmailError.CodeNotFound: - case VerifyEmailError.EmailEmpty: - case VerifyEmailError.EmailInvalidFormat: - case VerifyEmailError.RateLimited: { - onRedirectToLogin(); - return; - } - case VerifyEmailError.AlreadyVerifiedEmail: { - onRedirectToProfile(); - return; - } - default: { - // Will not produce a TypeScript error unless this switch statement - // is missing one or more case statements. - return error; - } - } - } - - if (!user) { - onRedirectToLogin(); - return; - } - - onRedirectToProfile(); - }, [code, email, onRedirectToLogin, onRedirectToProfile, verifyEmail]); - - // Perform the GraphQL mutation - useEffect(() => { - performVerification(); - }, [performVerification]); - - // Don't render anything while we're waiting (process should take a short - // amount of time) - return null; -} - -export default VerificationPerformer; diff --git a/src/client/ui/modules/authentication/pages/verify/VerifyPage.tsx b/src/client/ui/modules/authentication/pages/verify/VerifyPage.tsx deleted file mode 100644 index 78b51f2f..00000000 --- a/src/client/ui/modules/authentication/pages/verify/VerifyPage.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { useCallback, useState } from "react"; -import { Redirect } from "react-router-dom"; - -import useAuthenticationStatus, { - AuthenticationStatus, -} from "@jyosuushi/hooks/useAuthenticationStatus"; - -import VerificationPerformer from "./VerificationPerformer"; -import useValidatedQueryParameters from "./useValidatedQueryParameters"; - -function VerifyPage(): React.ReactElement { - const authStatus = useAuthenticationStatus(); - const validatedParams = useValidatedQueryParameters(); - - // Manage state - const [redirectDestination, setRedirectDestination] = useState< - "login" | "profile" | null - >(null); - - // Handle events - const handleRedirectToProfile = useCallback((): void => { - setRedirectDestination("profile"); - }, []); - - const handleRedirectToLogin = useCallback((): void => { - setRedirectDestination("login"); - }, []); - - // Redirect to the profile page if we're supposed to - if ( - authStatus === AuthenticationStatus.Authenticated || - redirectDestination === "profile" - ) { - return ; - } - - // Redirect to the login screen if we have an error that requires - // redirection - if (!validatedParams.valid || redirectDestination === "login") { - return ; - } - - // Render the component that performs the verification - return ( - - ); -} - -export default VerifyPage; diff --git a/src/client/ui/modules/authentication/pages/verify/types.ts b/src/client/ui/modules/authentication/pages/verify/types.ts deleted file mode 100644 index 849a5edf..00000000 --- a/src/client/ui/modules/authentication/pages/verify/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type ValidatedQueryParameters = - | { - valid: true; - code: string; - email: string; - } - | { - valid: false; - error: "missing-parameters" | "malformed-parameters"; - }; diff --git a/src/client/ui/modules/authentication/pages/verify/useValidatedQueryParameters.ts b/src/client/ui/modules/authentication/pages/verify/useValidatedQueryParameters.ts deleted file mode 100644 index 47d5f39a..00000000 --- a/src/client/ui/modules/authentication/pages/verify/useValidatedQueryParameters.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMemo } from "react"; -import { useLocation } from "react-router"; -import { validate as validateUuid } from "uuid"; - -import { ValidatedQueryParameters } from "./types"; - -function useValidatedQueryParameters(): ValidatedQueryParameters { - // Connect to the rest of the app - const { search } = useLocation(); - - // Parse the query string (if it exists) and pull out the unvalidated args - const params = new URLSearchParams(search); - const queryCode = params.get("code"); - const queryEmail = params.get("email"); - - // Get a memoized response for processing all of the query parameters - return useMemo((): ValidatedQueryParameters => { - if (!queryCode || !queryEmail) { - return { - error: "missing-parameters", - valid: false, - }; - } - - if (!validateUuid(queryCode)) { - return { - error: "malformed-parameters", - valid: false, - }; - } - - return { - code: queryCode, - email: queryEmail, - valid: true, - }; - }, [queryCode, queryEmail]); -} - -export default useValidatedQueryParameters;