Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
- Add validation in onSubmit to check for invite code when required - Display user-friendly error message instead of API error - Prevent form submission until invite code is provided 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
559 lines
20 KiB
TypeScript
559 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Box,
|
|
TextField,
|
|
Button,
|
|
Typography,
|
|
Paper,
|
|
InputAdornment,
|
|
IconButton,
|
|
Alert,
|
|
CircularProgress,
|
|
Link as MuiLink,
|
|
Checkbox,
|
|
FormControlLabel,
|
|
} from '@mui/material';
|
|
import { Visibility, VisibilityOff } from '@mui/icons-material';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { motion } from 'framer-motion';
|
|
import * as z from 'zod';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import Link from 'next/link';
|
|
import { useTheme } from '@mui/material/styles';
|
|
import apiClient from '@/lib/api/client';
|
|
|
|
// Create a function to generate schema dynamically based on requireInviteCode
|
|
const createRegisterSchema = (requireInviteCode: boolean) => z.object({
|
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
|
email: z.string().email('Invalid email address'),
|
|
password: z.string()
|
|
.min(8, 'Password must be at least 8 characters')
|
|
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
|
.regex(/[0-9]/, 'Password must contain at least one number'),
|
|
confirmPassword: z.string(),
|
|
inviteCode: requireInviteCode
|
|
? z.string().min(1, 'Invite code is required')
|
|
: z.string().optional(),
|
|
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
|
parentalEmail: z.string().email('Invalid email address').optional().or(z.literal('')),
|
|
agreeToTerms: z.boolean().refine(val => val === true, {
|
|
message: 'You must agree to the Terms of Service',
|
|
}),
|
|
agreeToPrivacy: z.boolean().refine(val => val === true, {
|
|
message: 'You must agree to the Privacy Policy',
|
|
}),
|
|
coppaConsent: z.boolean().optional(),
|
|
}).refine((data) => data.password === data.confirmPassword, {
|
|
message: 'Passwords do not match',
|
|
path: ['confirmPassword'],
|
|
}).refine((data) => {
|
|
// Check if user is under 18 and requires parental consent
|
|
const birthDate = new Date(data.dateOfBirth);
|
|
const today = new Date();
|
|
const age = today.getFullYear() - birthDate.getFullYear() -
|
|
(today.getMonth() < birthDate.getMonth() ||
|
|
(today.getMonth() === birthDate.getMonth() && today.getDate() < birthDate.getDate()) ? 1 : 0);
|
|
|
|
if (age < 13) {
|
|
return false; // Users under 13 cannot register (COPPA compliance)
|
|
}
|
|
|
|
if (age >= 13 && age < 18) {
|
|
// Users 13-17 need parental email and consent
|
|
return !!data.parentalEmail && data.parentalEmail.length > 0 && data.coppaConsent === true;
|
|
}
|
|
|
|
return true;
|
|
}, {
|
|
message: 'Users under 13 cannot create an account. Users 13-17 require parental consent and email.',
|
|
path: ['dateOfBirth'],
|
|
});
|
|
|
|
type RegisterFormData = z.infer<ReturnType<typeof createRegisterSchema>>;
|
|
|
|
export default function RegisterPage() {
|
|
const theme = useTheme();
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [userAge, setUserAge] = useState<number | null>(null);
|
|
const [requiresParentalConsent, setRequiresParentalConsent] = useState(false);
|
|
const [requireInviteCode, setRequireInviteCode] = useState(false);
|
|
const [loadingConfig, setLoadingConfig] = useState(true);
|
|
const { register: registerUser } = useAuth();
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
watch,
|
|
formState: { errors },
|
|
setValue,
|
|
trigger,
|
|
} = useForm<RegisterFormData>({
|
|
resolver: zodResolver(createRegisterSchema(requireInviteCode)),
|
|
defaultValues: {
|
|
agreeToTerms: false,
|
|
agreeToPrivacy: false,
|
|
coppaConsent: false,
|
|
},
|
|
});
|
|
|
|
// Fetch registration configuration on mount
|
|
useEffect(() => {
|
|
const fetchRegistrationConfig = async () => {
|
|
try {
|
|
const response = await apiClient.get('/api/v1/auth/registration/config');
|
|
if (response.data?.success && response.data?.data) {
|
|
setRequireInviteCode(response.data.data.requireInviteCode);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch registration config:', error);
|
|
// Default to not requiring invite code if fetch fails
|
|
setRequireInviteCode(false);
|
|
} finally {
|
|
setLoadingConfig(false);
|
|
}
|
|
};
|
|
|
|
fetchRegistrationConfig();
|
|
}, []);
|
|
|
|
// Watch date of birth to calculate age and show parental consent if needed
|
|
const dateOfBirth = watch('dateOfBirth');
|
|
|
|
// Calculate age when date of birth changes
|
|
const calculateAge = (dob: string): number | null => {
|
|
if (!dob) return null;
|
|
const birthDate = new Date(dob);
|
|
const today = new Date();
|
|
const age = today.getFullYear() - birthDate.getFullYear() -
|
|
(today.getMonth() < birthDate.getMonth() ||
|
|
(today.getMonth() === birthDate.getMonth() && today.getDate() < birthDate.getDate()) ? 1 : 0);
|
|
return age;
|
|
};
|
|
|
|
// Update age and parental consent requirement when DOB changes
|
|
React.useEffect(() => {
|
|
if (dateOfBirth) {
|
|
const age = calculateAge(dateOfBirth);
|
|
setUserAge(age);
|
|
setRequiresParentalConsent(age !== null && age >= 13 && age < 18);
|
|
} else {
|
|
setUserAge(null);
|
|
setRequiresParentalConsent(false);
|
|
}
|
|
}, [dateOfBirth]);
|
|
|
|
const onSubmit = async (data: RegisterFormData) => {
|
|
setError(null);
|
|
|
|
// Validate invite code if required
|
|
if (requireInviteCode && (!data.inviteCode || data.inviteCode.trim() === '')) {
|
|
setError('Invite code is required to register');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await registerUser({
|
|
name: data.name,
|
|
email: data.email,
|
|
password: data.password,
|
|
inviteCode: data.inviteCode || undefined,
|
|
dateOfBirth: data.dateOfBirth,
|
|
parentalEmail: data.parentalEmail || undefined,
|
|
coppaConsentGiven: data.coppaConsent || false,
|
|
});
|
|
// Navigation to onboarding is handled in the register function
|
|
} catch (err: any) {
|
|
setError(err.message || 'Failed to register. Please try again.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
minHeight: '100vh',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'center',
|
|
px: 3,
|
|
py: 6,
|
|
background: `linear-gradient(135deg, ${theme.palette.primary.light} 0%, ${theme.palette.secondary.light} 100%)`,
|
|
}}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.5 }}
|
|
>
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
p: 4,
|
|
borderRadius: 4,
|
|
maxWidth: 440,
|
|
mx: 'auto',
|
|
background: 'rgba(255, 255, 255, 0.95)',
|
|
backdropFilter: 'blur(10px)',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="h4"
|
|
gutterBottom
|
|
align="center"
|
|
fontWeight="600"
|
|
color="primary.main"
|
|
>
|
|
Create Account ✨
|
|
</Typography>
|
|
<Typography
|
|
variant="body2"
|
|
align="center"
|
|
color="text.secondary"
|
|
sx={{ mb: 3 }}
|
|
>
|
|
Start your journey to organized parenting
|
|
</Typography>
|
|
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
|
|
<TextField
|
|
fullWidth
|
|
label="Full Name"
|
|
margin="normal"
|
|
error={!!errors.name}
|
|
helperText={errors.name?.message}
|
|
{...register('name')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.name,
|
|
'aria-describedby': errors.name ? 'name-error' : undefined,
|
|
}}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.name ? 'name-error' : undefined,
|
|
role: errors.name ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
|
|
<TextField
|
|
fullWidth
|
|
label="Email"
|
|
type="email"
|
|
margin="normal"
|
|
error={!!errors.email}
|
|
helperText={errors.email?.message}
|
|
{...register('email')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
autoComplete: 'username',
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.email,
|
|
'aria-describedby': errors.email ? 'email-error' : undefined,
|
|
}}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.email ? 'email-error' : undefined,
|
|
role: errors.email ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
|
|
{requireInviteCode && (
|
|
<TextField
|
|
fullWidth
|
|
label="Invite Code"
|
|
margin="normal"
|
|
error={!!errors.inviteCode}
|
|
helperText={errors.inviteCode?.message || 'Enter your invite code to register'}
|
|
{...register('inviteCode')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.inviteCode,
|
|
'aria-describedby': errors.inviteCode ? 'invite-code-error' : 'invite-code-helper',
|
|
}}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.inviteCode ? 'invite-code-error' : 'invite-code-helper',
|
|
role: errors.inviteCode ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<TextField
|
|
fullWidth
|
|
label="Password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
margin="normal"
|
|
error={!!errors.password}
|
|
helperText={errors.password?.message}
|
|
{...register('password')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
autoComplete: 'new-password',
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.password,
|
|
'aria-describedby': errors.password ? 'password-error' : undefined,
|
|
}}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
endAdornment: (
|
|
<InputAdornment position="end">
|
|
<IconButton
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
edge="end"
|
|
disabled={isLoading}
|
|
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
>
|
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
|
</IconButton>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.password ? 'password-error' : undefined,
|
|
role: errors.password ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
|
|
<TextField
|
|
fullWidth
|
|
label="Confirm Password"
|
|
type={showConfirmPassword ? 'text' : 'password'}
|
|
margin="normal"
|
|
error={!!errors.confirmPassword}
|
|
helperText={errors.confirmPassword?.message}
|
|
{...register('confirmPassword')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
autoComplete: 'new-password',
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.confirmPassword,
|
|
'aria-describedby': errors.confirmPassword ? 'confirm-password-error' : undefined,
|
|
}}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
endAdornment: (
|
|
<InputAdornment position="end">
|
|
<IconButton
|
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
|
edge="end"
|
|
disabled={isLoading}
|
|
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
|
|
>
|
|
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
|
</IconButton>
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.confirmPassword ? 'confirm-password-error' : undefined,
|
|
role: errors.confirmPassword ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
|
|
<TextField
|
|
fullWidth
|
|
label="Date of Birth"
|
|
type="date"
|
|
margin="normal"
|
|
error={!!errors.dateOfBirth}
|
|
helperText={errors.dateOfBirth?.message || 'Required for COPPA compliance (users under 13 cannot register)'}
|
|
{...register('dateOfBirth')}
|
|
disabled={isLoading}
|
|
required
|
|
InputLabelProps={{
|
|
shrink: true,
|
|
}}
|
|
inputProps={{
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.dateOfBirth,
|
|
'aria-describedby': errors.dateOfBirth ? 'dob-error' : 'dob-helper',
|
|
}}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.dateOfBirth ? 'dob-error' : 'dob-helper',
|
|
role: errors.dateOfBirth ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
|
|
{userAge !== null && userAge < 13 && (
|
|
<Alert severity="error" sx={{ mt: 2 }}>
|
|
Users under 13 years old cannot create an account per COPPA regulations.
|
|
</Alert>
|
|
)}
|
|
|
|
{requiresParentalConsent && (
|
|
<Box sx={{ mt: 2 }}>
|
|
<Alert severity="info" sx={{ mb: 2 }}>
|
|
As you are under 18, parental consent is required to create an account.
|
|
</Alert>
|
|
<TextField
|
|
fullWidth
|
|
label="Parent/Guardian Email"
|
|
type="email"
|
|
margin="normal"
|
|
error={!!errors.parentalEmail}
|
|
helperText={errors.parentalEmail?.message || 'We will send a consent email to your parent/guardian'}
|
|
{...register('parentalEmail')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.parentalEmail,
|
|
'aria-describedby': errors.parentalEmail ? 'parental-email-error' : 'parental-email-helper',
|
|
}}
|
|
InputProps={{
|
|
sx: { borderRadius: 3 },
|
|
}}
|
|
FormHelperTextProps={{
|
|
id: errors.parentalEmail ? 'parental-email-error' : 'parental-email-helper',
|
|
role: errors.parentalEmail ? 'alert' : undefined,
|
|
}}
|
|
/>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
{...register('coppaConsent')}
|
|
disabled={isLoading}
|
|
required
|
|
inputProps={{
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.coppaConsent,
|
|
'aria-describedby': errors.coppaConsent ? 'coppa-consent-error' : undefined,
|
|
} as any}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body2" color="text.secondary">
|
|
I confirm that I have my parent/guardian's permission to create this account
|
|
</Typography>
|
|
}
|
|
sx={{ mt: 1 }}
|
|
/>
|
|
{errors.coppaConsent && (
|
|
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 1 }} id="coppa-consent-error" role="alert">
|
|
Parental consent is required for users under 18
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
<Box sx={{ mt: 2 }}>
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
{...register('agreeToTerms')}
|
|
disabled={isLoading}
|
|
inputProps={{
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.agreeToTerms,
|
|
'aria-describedby': errors.agreeToTerms ? 'terms-error' : undefined,
|
|
} as any}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body2" color="text.secondary" component="span">
|
|
I agree to the{' '}
|
|
<MuiLink href="/terms" target="_blank" underline="hover">
|
|
Terms of Service
|
|
</MuiLink>
|
|
{' '}<Typography component="span" color="error.main">*</Typography>
|
|
</Typography>
|
|
}
|
|
sx={{ alignItems: 'flex-start', mb: 1 }}
|
|
/>
|
|
{errors.agreeToTerms && (
|
|
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5, ml: 4 }} id="terms-error" role="alert">
|
|
{errors.agreeToTerms.message}
|
|
</Typography>
|
|
)}
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
{...register('agreeToPrivacy')}
|
|
disabled={isLoading}
|
|
inputProps={{
|
|
'aria-required': 'true',
|
|
'aria-invalid': !!errors.agreeToPrivacy,
|
|
'aria-describedby': errors.agreeToPrivacy ? 'privacy-error' : undefined,
|
|
} as any}
|
|
/>
|
|
}
|
|
label={
|
|
<Typography variant="body2" color="text.secondary" component="span">
|
|
I agree to the{' '}
|
|
<MuiLink href="/privacy" target="_blank" underline="hover">
|
|
Privacy Policy
|
|
</MuiLink>
|
|
{' '}<Typography component="span" color="error.main">*</Typography>
|
|
</Typography>
|
|
}
|
|
sx={{ alignItems: 'flex-start', mb: 1 }}
|
|
/>
|
|
{errors.agreeToPrivacy && (
|
|
<Typography variant="caption" color="error" sx={{ display: 'block', mt: 0.5, ml: 4 }} id="privacy-error" role="alert">
|
|
{errors.agreeToPrivacy.message}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
|
|
<Button
|
|
fullWidth
|
|
type="submit"
|
|
variant="contained"
|
|
size="large"
|
|
disabled={isLoading}
|
|
sx={{ mt: 3, mb: 2 }}
|
|
>
|
|
{isLoading ? (
|
|
<CircularProgress size={24} color="inherit" />
|
|
) : (
|
|
'Create Account'
|
|
)}
|
|
</Button>
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 3, textAlign: 'center' }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Already have an account?{' '}
|
|
<MuiLink component={Link} href="/login" sx={{ cursor: 'pointer', fontWeight: 600 }}>
|
|
Sign in
|
|
</MuiLink>
|
|
</Typography>
|
|
</Box>
|
|
</Paper>
|
|
</motion.div>
|
|
</Box>
|
|
);
|
|
}
|