``refactor Implement custom sign-up form component with Zod validation, OAuth integration, and i18n support following REQ0016 requirements
``
This commit is contained in:
254
002_source/cms/src/components/auth/custom/sign-up-form/index.tsx
Normal file
254
002_source/cms/src/components/auth/custom/sign-up-form/index.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// RULES:
|
||||||
|
// refer to ticket REQ0016 for login flow
|
||||||
|
import * as React from 'react';
|
||||||
|
import RouterLink from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { LoadingButton } from '@mui/lab';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import FormControl from '@mui/material/FormControl';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import FormHelperText from '@mui/material/FormHelperText';
|
||||||
|
import InputLabel from '@mui/material/InputLabel';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
|
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { z as zod } from 'zod';
|
||||||
|
|
||||||
|
import { paths } from '@/paths';
|
||||||
|
import { authClient } from '@/lib/auth/custom/client';
|
||||||
|
import { useUser } from '@/hooks/use-user';
|
||||||
|
import { DynamicLogo } from '@/components/core/logo';
|
||||||
|
import { toast } from '@/components/core/toaster';
|
||||||
|
|
||||||
|
import type { OAuthProvider } from '../OAuthProvider';
|
||||||
|
import { oAuthProviders } from '../oAuthProviders';
|
||||||
|
|
||||||
|
export function SignUpForm(): React.JSX.Element {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation(['sign_in']);
|
||||||
|
|
||||||
|
const { checkSession } = useUser();
|
||||||
|
|
||||||
|
const [isPending, setIsPending] = React.useState<boolean>(false);
|
||||||
|
|
||||||
|
const schema = zod.object({
|
||||||
|
firstName: zod.string().min(1, { message: t('first-name-is-required') }),
|
||||||
|
lastName: zod.string().min(1, { message: t('last-name-is-required') }),
|
||||||
|
email: zod
|
||||||
|
.string()
|
||||||
|
.min(1, { message: t('email-is-required') })
|
||||||
|
.email(),
|
||||||
|
password: zod.string().min(6, { message: t('password-should-be-at-least-6-characters') }),
|
||||||
|
terms: zod.boolean().refine((value) => value, t('you-must-accept-the-terms-and-conditions')),
|
||||||
|
});
|
||||||
|
type Values = zod.infer<typeof schema>;
|
||||||
|
const defaultValues = { firstName: '', lastName: '', email: '', password: '', terms: false } satisfies Values;
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setError,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<Values>({ defaultValues, resolver: zodResolver(schema) });
|
||||||
|
|
||||||
|
const onAuth = React.useCallback(async (providerId: OAuthProvider['id']): Promise<void> => {
|
||||||
|
setIsPending(true);
|
||||||
|
|
||||||
|
const { error } = await authClient.signInWithOAuth({ provider: providerId });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setIsPending(false);
|
||||||
|
toast.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPending(false);
|
||||||
|
|
||||||
|
// Redirect to OAuth provider
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSubmit = React.useCallback(
|
||||||
|
async (values: Values): Promise<void> => {
|
||||||
|
setIsPending(true);
|
||||||
|
|
||||||
|
const { error } = await authClient.signUp(values);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError('root', { type: 'server', message: error });
|
||||||
|
setIsPending(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the auth state
|
||||||
|
await checkSession?.();
|
||||||
|
|
||||||
|
// UserProvider, for this case, will not refresh the router
|
||||||
|
// After refresh, GuestGuard will handle the redirect
|
||||||
|
|
||||||
|
// TODO: resume me
|
||||||
|
// router.refresh();
|
||||||
|
},
|
||||||
|
[checkSession, router, setError]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<div>
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
href={paths.home}
|
||||||
|
sx={{ display: 'inline-block', fontSize: 0 }}
|
||||||
|
>
|
||||||
|
<DynamicLogo
|
||||||
|
colorDark="light"
|
||||||
|
colorLight="dark"
|
||||||
|
height={32}
|
||||||
|
width={122}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="h5">{t('sign-up')}</Typography>
|
||||||
|
<Typography
|
||||||
|
color="text.secondary"
|
||||||
|
variant="body2"
|
||||||
|
>
|
||||||
|
{t('already-have-an-account')}{' '}
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
href={paths.auth.custom.signIn}
|
||||||
|
variant="subtitle2"
|
||||||
|
>
|
||||||
|
{t('sign-in')}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{oAuthProviders.map(
|
||||||
|
(provider): React.JSX.Element => (
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
disabled={isPending}
|
||||||
|
startIcon={
|
||||||
|
<Box
|
||||||
|
alt=""
|
||||||
|
component="img"
|
||||||
|
height={24}
|
||||||
|
src={provider.logo}
|
||||||
|
width={24}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
key={provider.id}
|
||||||
|
onClick={(): void => {
|
||||||
|
onAuth(provider.id).catch(() => {
|
||||||
|
// noop
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
{t('continue-with_fh')} {provider.name} {t('continue-with_sh')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<Divider>{t('or')}</Divider>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="firstName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl error={Boolean(errors.firstName)}>
|
||||||
|
<InputLabel>{t('first-name')}:</InputLabel>
|
||||||
|
<OutlinedInput {...field} />
|
||||||
|
{errors.firstName ? <FormHelperText>{errors.firstName.message}</FormHelperText> : null}
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl error={Boolean(errors.lastName)}>
|
||||||
|
<InputLabel>{t('last-name')}:</InputLabel>
|
||||||
|
<OutlinedInput {...field} />
|
||||||
|
{errors.lastName ? <FormHelperText>{errors.lastName.message}</FormHelperText> : null}
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl error={Boolean(errors.email)}>
|
||||||
|
<InputLabel>{t('email-address')}:</InputLabel>
|
||||||
|
<OutlinedInput
|
||||||
|
{...field}
|
||||||
|
placeholder={t('e.g.') + ' name@example.com'}
|
||||||
|
type="email"
|
||||||
|
disabled={isPending}
|
||||||
|
/>
|
||||||
|
{errors.email ? <FormHelperText>{errors.email.message}</FormHelperText> : null}
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl error={Boolean(errors.password)}>
|
||||||
|
<InputLabel>{t('password')}:</InputLabel>
|
||||||
|
<OutlinedInput
|
||||||
|
{...field}
|
||||||
|
placeholder={t('password')}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
{errors.password ? <FormHelperText>{errors.password.message}</FormHelperText> : null}
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="terms"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Checkbox {...field} />}
|
||||||
|
label={
|
||||||
|
<React.Fragment>
|
||||||
|
{t('i-have-read-the')} <Link>{t('terms-and-conditions')}</Link>
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{errors.terms ? <FormHelperText error>{errors.terms.message}</FormHelperText> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.root ? <Alert color="error">{errors.root.message}</Alert> : null}
|
||||||
|
|
||||||
|
<LoadingButton
|
||||||
|
loading={isPending}
|
||||||
|
disabled={isPending}
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
{t('create-account')}
|
||||||
|
</LoadingButton>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
<Alert color="warning">{t('created-users-are-not-persisted')}</Alert>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user