Update: update confirm user, user account page

This commit is contained in:
Aaron William Po
2023-05-31 21:00:01 -04:00
parent 06ae380b8f
commit 49565d0533
8 changed files with 293 additions and 263 deletions

View File

@@ -47,57 +47,61 @@ const AccountInfo: FC = () => {
), ),
}); });
const { register, handleSubmit, formState, reset } = useForm< const [editToggled, setEditToggled] = useState(false);
z.infer<typeof EditUserSchema>
>({
resolver: zodResolver(EditUserSchema),
defaultValues: {
username: user!.username,
email: user!.email,
firstName: user!.firstName,
lastName: user!.lastName,
},
});
const [inEditMode, setInEditMode] = useState(false);
const onSubmit = async (data: z.infer<typeof EditUserSchema>) => { const onSubmit = async (data: z.infer<typeof EditUserSchema>) => {
const loadingToast = toast.loading('Submitting edits...'); const loadingToast = toast.loading('Submitting edits...');
try { try {
await sendEditUserRequest({ user: user!, data }); await sendEditUserRequest({ user: user!, data });
await mutate!();
setInEditMode(false);
toast.remove(loadingToast); toast.remove(loadingToast);
toast.success('Edits submitted successfully.'); toast.success('Edits submitted successfully.');
setEditToggled(false);
await mutate!();
} catch (error) { } catch (error) {
setInEditMode(false); setEditToggled(false);
toast.remove(loadingToast); toast.remove(loadingToast);
createErrorToast(error); createErrorToast(error);
await mutate!(); await mutate!();
} }
}; };
const { register, handleSubmit, formState, reset } = useForm<
z.infer<typeof EditUserSchema>
>({
resolver: zodResolver(EditUserSchema),
});
return ( return (
<div className="mt-8"> <div className="card mt-8">
<div className="flex flex-col space-y-3"> <div className="card-body flex flex-col space-y-3">
<div className="flex w-full items-center justify-between space-x-5">
<div className="">
<h1 className="text-lg font-bold">Edit Your Account Info</h1>
<p>Update your personal account information.</p>
</div>
<div>
<Switch
className="toggle"
id="edit-toggle"
checked={editToggled}
onClick={async () => {
setEditToggled((val) => !val);
await mutate!();
reset({
username: user!.username,
email: user!.email,
firstName: user!.firstName,
lastName: user!.lastName,
});
}}
/>
</div>
</div>
{editToggled && (
<form <form
className="form-control space-y-5" className="form-control space-y-5"
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
noValidate noValidate
> >
<label className="label w-36 cursor-pointer p-0">
<span className="label-text font-bold uppercase">Enable Edit</span>
<Switch
checked={inEditMode}
className="toggle"
onClick={() => {
setInEditMode((editMode) => !editMode);
reset();
}}
id="edit-toggle"
/>
</label>
<div> <div>
<FormInfo> <FormInfo>
<FormLabel htmlFor="username">Username</FormLabel> <FormLabel htmlFor="username">Username</FormLabel>
@@ -105,7 +109,7 @@ const AccountInfo: FC = () => {
</FormInfo> </FormInfo>
<FormTextInput <FormTextInput
type="text" type="text"
disabled={!inEditMode || formState.isSubmitting} disabled={!editToggled || formState.isSubmitting}
error={!!formState.errors.username} error={!!formState.errors.username}
id="username" id="username"
formValidationSchema={register('username')} formValidationSchema={register('username')}
@@ -116,7 +120,7 @@ const AccountInfo: FC = () => {
</FormInfo> </FormInfo>
<FormTextInput <FormTextInput
type="email" type="email"
disabled={!inEditMode || formState.isSubmitting} disabled={!editToggled || formState.isSubmitting}
error={!!formState.errors.email} error={!!formState.errors.email}
id="email" id="email"
formValidationSchema={register('email')} formValidationSchema={register('email')}
@@ -130,7 +134,7 @@ const AccountInfo: FC = () => {
</FormInfo> </FormInfo>
<FormTextInput <FormTextInput
type="text" type="text"
disabled={!inEditMode || formState.isSubmitting} disabled={!editToggled || formState.isSubmitting}
error={!!formState.errors.firstName} error={!!formState.errors.firstName}
id="firstName" id="firstName"
formValidationSchema={register('firstName')} formValidationSchema={register('firstName')}
@@ -143,20 +147,23 @@ const AccountInfo: FC = () => {
</FormInfo> </FormInfo>
<FormTextInput <FormTextInput
type="text" type="text"
disabled={!inEditMode || formState.isSubmitting} disabled={!editToggled || formState.isSubmitting}
error={!!formState.errors.lastName} error={!!formState.errors.lastName}
id="lastName" id="lastName"
formValidationSchema={register('lastName')} formValidationSchema={register('lastName')}
/> />
</div> </div>
</div> </div>
</div> <button
{inEditMode && ( className="btn-primary btn my-5 w-full"
<button className="btn-primary btn w-full" type="submit"> type="submit"
disabled={!editToggled || formState.isSubmitting}
>
Save Changes Save Changes
</button> </button>
)} </div>
</form> </form>
)}
</div> </div>
</div> </div>
); );

View File

@@ -20,12 +20,13 @@ const Security: FunctionComponent = () => {
const onSubmit: SubmitHandler<z.infer<typeof UpdatePasswordSchema>> = async (data) => { const onSubmit: SubmitHandler<z.infer<typeof UpdatePasswordSchema>> = async (data) => {
await sendUpdatePasswordRequest(data); await sendUpdatePasswordRequest(data);
setEditToggled(value => !value)
reset(); reset();
}; };
return ( return (
<div className="mt-8 w-full space-y-4"> <div className="card w-full space-y-4">
<div className="card-body">
<div className="flex w-full items-center justify-between space-x-5"> <div className="flex w-full items-center justify-between space-x-5">
<div className=""> <div className="">
<h1 className="text-lg font-bold">Change Your Password</h1> <h1 className="text-lg font-bold">Change Your Password</h1>
@@ -36,7 +37,10 @@ const Security: FunctionComponent = () => {
className="toggle" className="toggle"
id="edit-toggle" id="edit-toggle"
checked={editToggled} checked={editToggled}
onClick={() => setEditToggled((val) => !val)} onClick={() => {
setEditToggled((val) => !val);
reset();
}}
/> />
</div> </div>
</div> </div>
@@ -65,12 +69,17 @@ const Security: FunctionComponent = () => {
formValidationSchema={register('confirmPassword')} formValidationSchema={register('confirmPassword')}
/> />
<button className="btn-primary btn mt-5" disabled={!editToggled} type="submit"> <button
className="btn-primary btn mt-5"
disabled={!editToggled || formState.isSubmitting}
type="submit"
>
Update Update
</button> </button>
</form> </form>
)} )}
</div> </div>
</div>
); );
}; };

View File

@@ -22,14 +22,14 @@ const toastToClassName = (toastType: Toast['type']) => {
const CustomToast: FC<{ children: ReactNode }> = ({ children }) => { const CustomToast: FC<{ children: ReactNode }> = ({ children }) => {
return ( return (
<> <>
<Toaster position="bottom-center"> <Toaster position="bottom-right">
{(t) => { {(t) => {
const alertType = toastToClassName(t.type); const alertType = toastToClassName(t.type);
return ( return (
<div <div
className={`alert ${alertType} w-11/12 flex-row items-center shadow-lg animate-in fade-in duration-200 lg:w-6/12`} className={`alert ${alertType} w-11/12 flex-row items-center shadow-lg animate-in fade-in duration-200 lg:w-2/12`}
> >
<p>{resolveValue(t.message, t)}</p> <p className='text-sm'>{resolveValue(t.message, t)}</p>
{t.type !== 'loading' && ( {t.type !== 'loading' && (
<div> <div>
<button <button

View File

@@ -0,0 +1,69 @@
import UserContext from '@/contexts/UserContext';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import { useRouter } from 'next/router';
import { useState, useContext, useEffect } from 'react';
import toast from 'react-hot-toast';
import useSWR from 'swr';
const useConfirmUser = () => {
const router = useRouter();
const { user, mutate } = useContext(UserContext);
const token = router.query.token as string | undefined;
const [needsToLogin, setNeedsToLogin] = useState(false);
const [tokenInvalid, setTokenInvalid] = useState(false);
const fetcher = async <T extends string>(url: T) => {
if (!token) {
throw new Error('Token must be provided.');
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response validation failed.');
}
mutate!();
return parsed.data;
};
const { data, error } = useSWR(`/api/users/confirm?token=${token}`, fetcher);
useEffect(() => {
const loadingToast = toast.loading('Attempting to confirm your account.');
if (user && user.accountIsVerified) {
toast.remove(loadingToast);
router.replace('/users/current');
toast('Your account is already verified.');
}
if (!token) {
toast.remove(loadingToast);
setTokenInvalid(true);
setNeedsToLogin(false);
}
if (user && !user.accountIsVerified && !data) {
toast.remove(loadingToast);
setTokenInvalid(true);
setNeedsToLogin(false);
}
if (error instanceof Error && error.message === 'Unauthorized') {
toast.remove(loadingToast);
setTokenInvalid(false);
setNeedsToLogin(true);
}
return () => {
toast.remove(loadingToast);
};
}, [error, data, router, user, token]);
return { needsToLogin, tokenInvalid };
};
export default useConfirmUser;

View File

@@ -21,7 +21,7 @@ const AccountPage: NextPage = () => {
content="Your account page. Here you can view your account information, change your settings, and view your posts." content="Your account page. Here you can view your account information, change your settings, and view your posts."
/> />
</Head> </Head>
<div className="flex h-full flex-col items-center bg-base-300"> <div className="flex flex-col items-center">
<div className="m-12 flex w-11/12 flex-col items-center justify-center space-y-3 lg:w-7/12"> <div className="m-12 flex w-11/12 flex-col items-center justify-center space-y-3 lg:w-7/12">
<div className="flex flex-col items-center space-y-3"> <div className="flex flex-col items-center space-y-3">
<div className="avatar"> <div className="avatar">
@@ -34,24 +34,19 @@ const AccountPage: NextPage = () => {
</div> </div>
</div> </div>
<div className="w-full"> <div className="h-full w-full">
<Tab.Group> <Tab.Group>
<Tab.List className="tabs tabs-boxed items-center justify-center rounded-2xl"> <Tab.List className="tabs tabs-boxed items-center justify-center rounded-2xl">
<Tab className="tab tab-md w-1/3 uppercase ui-selected:tab-active"> <Tab className="tab tab-md w-1/2 uppercase ui-selected:tab-active">
Account Info Account Info and Security
</Tab> </Tab>
<Tab className="tab tab-md w-1/3 uppercase ui-selected:tab-active"> <Tab className="tab tab-md w-1/2 uppercase ui-selected:tab-active">
Security
</Tab>
<Tab className="tab tab-md w-1/3 uppercase ui-selected:tab-active">
Your Posts Your Posts
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel> <Tab.Panel className="h-full space-y-5">
<AccountInfo /> <AccountInfo />
</Tab.Panel>
<Tab.Panel>
<Security /> <Security />
</Tab.Panel> </Tab.Panel>
<Tab.Panel>Your posts!</Tab.Panel> <Tab.Panel>Your posts!</Tab.Panel>

View File

@@ -1,75 +1,18 @@
import UserContext from '@/contexts/UserContext'; import useConfirmUser from '@/hooks/auth/useConfirmUser';
import createErrorToast from '@/util/createErrorToast'; import createErrorToast from '@/util/createErrorToast';
import APIResponseValidationSchema from '@/validation/APIResponseValidationSchema';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router';
import { FC, useContext, useState } from 'react'; import { FC, useState } from 'react';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import useSWR from 'swr';
const useSendConfirmUserRequest = () => {
const router = useRouter();
const token = router.query.token as string | undefined;
const { data, error } = useSWR(`/api/users/confirm?token=${token}`, async (url) => {
if (!token) {
throw new Error('Token must be provided.');
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(response.statusText);
}
const json = await response.json();
const parsed = APIResponseValidationSchema.safeParse(json);
if (!parsed.success) {
throw new Error('API response validation failed.');
}
return parsed.data;
});
return { data, error: error as unknown };
};
const ConfirmUserPage: FC = () => { const ConfirmUserPage: FC = () => {
const router = useRouter(); const { needsToLogin, tokenInvalid } = useConfirmUser();
const { error, data } = useSendConfirmUserRequest();
const { user } = useContext(UserContext);
const needsToLogin =
error instanceof Error && error.message === 'Unauthorized' && !user;
const tokenExpired = error instanceof Error && error.message === 'Unauthorized' && user;
const [confirmationResent, setConfirmationResent] = useState(false); const [confirmationResent, setConfirmationResent] = useState(false);
if (user?.accountIsVerified) {
router.push('/users/current');
return null;
}
if (data) {
router.push('/users/current');
return null;
}
if (needsToLogin) {
return (
<>
<Head>
<title>Confirm User | The Biergarten App</title>
</Head>
<div className="flex h-full flex-col items-center justify-center">
<p className="text-center text-xl font-bold">
Please login to confirm your account.
</p>
</div>
</>
);
}
if (tokenExpired) {
const onClick = async () => { const onClick = async () => {
const loadingToast = toast.loading('Resending your confirmation email.'); const resentConfirmationLoadingToast = toast.loading(
'Resending your confirmation email.',
);
try { try {
const response = await fetch('/api/users/resend-confirmation', { const response = await fetch('/api/users/resend-confirmation', {
method: 'POST', method: 'POST',
@@ -78,7 +21,7 @@ const ConfirmUserPage: FC = () => {
throw new Error('Something went wrong.'); throw new Error('Something went wrong.');
} }
toast.remove(loadingToast); toast.remove(resentConfirmationLoadingToast);
toast.success('Sent a new confirmation email.'); toast.success('Sent a new confirmation email.');
setConfirmationResent(true); setConfirmationResent(true);
@@ -93,10 +36,16 @@ const ConfirmUserPage: FC = () => {
<title>Confirm User | The Biergarten App</title> <title>Confirm User | The Biergarten App</title>
</Head> </Head>
<div className="flex h-full flex-col items-center justify-center space-y-4"> <div className="flex h-full flex-col items-center justify-center space-y-4">
{!confirmationResent ? ( {needsToLogin && (
<p className="text-center text-xl font-bold">
Please login to confirm your account.
</p>
)}
{!needsToLogin && tokenInvalid && !confirmationResent && (
<> <>
<p className="text-center text-2xl font-bold"> <p className="text-center text-2xl font-bold">
Your confirmation token is expired. Your confirmation token is invalid or is expired.
</p> </p>
<button <button
className="btn-outline btn-sm btn normal-case" className="btn-outline btn-sm btn normal-case"
@@ -106,7 +55,9 @@ const ConfirmUserPage: FC = () => {
Click here to request a new token. Click here to request a new token.
</button> </button>
</> </>
) : ( )}
{!needsToLogin && tokenInvalid && confirmationResent && (
<> <>
<p className="text-center text-2xl font-bold"> <p className="text-center text-2xl font-bold">
Resent your confirmation link. Resent your confirmation link.
@@ -117,9 +68,6 @@ const ConfirmUserPage: FC = () => {
</div> </div>
</> </>
); );
}
return null;
}; };
export default ConfirmUserPage; export default ConfirmUserPage;

View File

@@ -20,7 +20,7 @@ const ProtectedPage: NextPage = () => {
return ( return (
<> <>
<Head> <Head>
<title>Hello, {user?.firstName}! | The Biergarten App</title> <title>Hello! | The Biergarten App</title>
</Head> </Head>
<div className="flex h-full flex-col items-center justify-center space-y-3 bg-primary text-center"> <div className="flex h-full flex-col items-center justify-center space-y-3 bg-primary text-center">
{isLoading && <Spinner size={isDesktop ? 'xl' : 'md'} />} {isLoading && <Spinner size={isDesktop ? 'xl' : 'md'} />}

View File

@@ -4,6 +4,8 @@ import sub from 'date-fns/sub';
import { z } from 'zod'; import { z } from 'zod';
const MINIMUM_DATE_OF_BIRTH = sub(new Date(), { years: 19 }); const MINIMUM_DATE_OF_BIRTH = sub(new Date(), { years: 19 });
const NAME_REGEX =
/^[a-zA-ZàáâäãåąčćęèéêëėįìíîïłńòóôöõøùúûüųūÿýżźçčšžÀÁÂÄÃÅĄĆČĖĘÈÉÊËÌÍÎÏĮŁŃÒÓÔÖÕØÙÚÛÜŲŪŸÝŻŹÑßÇŒÆČŠŽðæ ,.'-]+$/u;
export const BaseCreateUserSchema = z.object({ export const BaseCreateUserSchema = z.object({
password: z password: z
@@ -23,14 +25,14 @@ export const BaseCreateUserSchema = z.object({
.string() .string()
.min(1, { message: 'First name must not be empty.' }) .min(1, { message: 'First name must not be empty.' })
.max(20, { message: 'First name must be less than 20 characters.' }) .max(20, { message: 'First name must be less than 20 characters.' })
.refine((firstName) => /^[a-zA-Z]+$/.test(firstName), { .refine((firstName) => NAME_REGEX.test(firstName), {
message: 'First name must only contain letters.', message: 'First name must only contain letters or hyphens.',
}), }),
lastName: z lastName: z
.string() .string()
.min(1, { message: 'Last name must not be empty.' }) .min(1, { message: 'Last name must not be empty.' })
.max(20, { message: 'Last name must be less than 20 characters.' }) .max(20, { message: 'Last name must be less than 20 characters.' })
.refine((lastName) => /^[a-zA-Z]+$/.test(lastName), { .refine((lastName) => NAME_REGEX.test(lastName), {
message: 'Last name must only contain letters.', message: 'Last name must only contain letters.',
}), }),
dateOfBirth: z dateOfBirth: z