From ef451bb67d3985f41d2754d24fbefe6f7c2123af Mon Sep 17 00:00:00 2001 From: robbinat Date: Fri, 14 Nov 2025 15:08:29 -0800 Subject: [PATCH 1/4] chore: constrain OTP flow --- .../src/database/user/zod/auth.validator.ts | 35 ++ server/src/database/user/zod/user.base.ts | 23 +- server/src/routes/v1/auth.ts | 298 ++---------------- 3 files changed, 74 insertions(+), 282 deletions(-) create mode 100644 server/src/database/user/zod/auth.validator.ts diff --git a/server/src/database/user/zod/auth.validator.ts b/server/src/database/user/zod/auth.validator.ts new file mode 100644 index 00000000..29211c29 --- /dev/null +++ b/server/src/database/user/zod/auth.validator.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { createUserSchema } from '@/database/user/zod/user.validator'; + +// OTP code validation +const otpCodeSchema = z + .string() + .length(6, 'OTP code must be 6 digits') + .regex(/^\d{6}$/, 'OTP code must contain only digits'); + +export const sendOtpSignupSchema = createUserSchema.strict(); + +export const sendOtpLoginSchema = createUserSchema + .pick({ + phone: true, + email: true + }) + .partial({ email: true }) + .strict(); + +export const verifyOtpSignupSchema = createUserSchema + .extend({ + code: otpCodeSchema + }) + .strict(); + +export const verifyOtpLoginSchema = createUserSchema + .pick({ + phone: true + }) + .extend({ + code: otpCodeSchema + }) + .strict(); + diff --git a/server/src/database/user/zod/user.base.ts b/server/src/database/user/zod/user.base.ts index 786cd3a9..26ff7933 100644 --- a/server/src/database/user/zod/user.base.ts +++ b/server/src/database/user/zod/user.base.ts @@ -13,19 +13,28 @@ export const baseUserSchema = z .object({ firstName: z.string().min(1, 'First name is required'), lastName: z.string().min(1, 'Last name is required'), - email: z.email(), + email: z.email({message: 'Please provide a valid email address'}), phone: z .string() - .length(12, 'Phone number must be exactly 12 characters (+1 and 10 digits)') - .regex(/^\+1\d{10}$/, 'Phone number must be in format +1XXXXXXXXXX, only digits'), - role: z.enum(ROLE_ENUM), - approvalStatus: z.enum(ApprovalStatus), + .regex(/^\+1\d{10}$/, 'Please provide a valid 10-digit phone number with country code (+1XXXXXXXXXX)'), + role: z.enum(ROLE_ENUM, { + message: 'Please provide a valid role' + }), + approvalStatus: z.enum(ApprovalStatus, { + message: 'Invalid approval status' + }), approvedByUserObjectId: z .string() - .refine(Types.ObjectId.isValid, 'Invalid user objectId'), + .refine( + Types.ObjectId.isValid, + 'Please provide a valid user ID' + ), locationObjectId: z .string() - .refine(Types.ObjectId.isValid, 'Invalid location objectId'), + .refine( + Types.ObjectId.isValid, + 'Please provide a valid location' + ), permissions: z.array( z.object({ action: z.enum(ACTION_ENUM), diff --git a/server/src/routes/v1/auth.ts b/server/src/routes/v1/auth.ts index 3bcfcab2..5cf6fdf6 100644 --- a/server/src/routes/v1/auth.ts +++ b/server/src/routes/v1/auth.ts @@ -1,20 +1,18 @@ -import { subject } from '@casl/ability'; -import { accessibleBy } from '@casl/mongoose'; import express, { Request, Response } from 'express'; -import { Schema } from 'mongoose'; +import { Types } from 'mongoose'; import twilio from 'twilio'; +import { z } from 'zod'; import User from '@/database/user/mongoose/user.model'; import { ApprovalStatus } from '@/database/utils/constants'; -import { auth } from '@/middleware/auth'; -import { ACTIONS, SUBJECTS } from '@/permissions/constants'; -import { - AuthenticatedRequest, - OTPRequest, - SignupRequest, - VerifyOTPRequest -} from '@/types/auth'; import { generateAuthToken } from '@/utils/authTokenHandler'; +import { validate } from '@/middleware/validate'; +import { + sendOtpLoginSchema, + sendOtpSignupSchema, + verifyOtpLoginSchema, + verifyOtpSignupSchema +} from '@/database/user/zod/auth.validator'; const router = express.Router(); @@ -25,13 +23,12 @@ const verifySid = process.env.TWILIO_VERIFY_SID as string; const client = twilio(accountSid, authToken); const verifyService = client.verify.v2.services(verifySid); -// REVIEW: Implement Zod validation schemas for request bodies and delete stale APIs. - // ─── Send OTP for SIGNUP ───────────────────────────────────────────── router.post( '/send-otp-signup', + [validate(sendOtpSignupSchema)], async (req: Request, res: Response): Promise => { - const { phone, email }: OTPRequest = req.body; + const { phone, email }: z.infer = req.body; try { const existingUser = await User.findOne({ $or: [{ phone }, { email }] @@ -57,8 +54,9 @@ router.post( // ─── Send OTP for LOGIN ────────────────────────────────────────────── router.post( '/send-otp-login', + [validate(sendOtpLoginSchema)], async (req: Request, res: Response): Promise => { - const { phone }: OTPRequest = req.body; + const { phone }: z.infer = req.body; try { const user = await User.findOne({ phone }); if (!user) { @@ -82,6 +80,7 @@ router.post( // ─── Verify OTP for SIGNUP ────────────────────────────────────────── router.post( '/verify-otp-signup', + [validate(verifyOtpSignupSchema)], async (req: Request, res: Response): Promise => { const { phone, @@ -91,12 +90,7 @@ router.post( email, role, locationObjectId - }: SignupRequest & { - firstName: string; - lastName: string; - role: string; - locationObjectId: Schema.Types.ObjectId; - } = req.body; + }: z.infer = req.body; try { const check = await verifyService.verificationChecks.create({ @@ -113,21 +107,20 @@ router.post( }); return; } - const newUser = new User({ + + const newUser = await User.create({ firstName, lastName, email, phone, role, - locationObjectId + locationObjectId: new Types.ObjectId(locationObjectId) }); - await newUser.save(); const token = generateAuthToken(newUser.id); res.json({ - message: 'Signup successful!', - token: token, - redirectTo: '/dashboard' + message: 'Signup successful! Your account is pending approval.', + token: token }); } catch (err) { console.error('Verify OTP signup error:', err); @@ -141,8 +134,9 @@ router.post( // ─── Verify OTP for LOGIN ──────────────────────────────────────────── router.post( '/verify-otp-login', + [validate(verifyOtpLoginSchema)], async (req: Request, res: Response): Promise => { - const { phone, code }: VerifyOTPRequest = req.body; + const { phone, code }: z.infer = req.body; try { const check = await verifyService.verificationChecks.create({ to: phone, @@ -162,7 +156,7 @@ router.post( if (user.approvalStatus !== ApprovalStatus.APPROVED) { res.status(403).json({ message: - 'Account not approved yet. Please contact your admin.' + 'Account not approved yet. Please contact your admin and log in again once approved.' }); return; } @@ -182,250 +176,4 @@ router.post( } ); -// ─── Admin Approvals ──────────────────────────────────────────────── -router.get( - '/users', - [auth], - async (req: AuthenticatedRequest, res: Response): Promise => { - if (!req.authorization) { - res.sendStatus(403); - return; - } - try { - const users = await User.find( - accessibleBy(req.authorization).ofType(SUBJECTS.USER), - 'firstName lastName role approvalStatus' - ); - res.json(users); - } catch (err) { - console.error('Error fetching users:', err); - res.status(500).json({ - message: 'Server error: Unable to fetch users' - }); - } - } -); - -router.put( - '/users/:id/approve', - [auth], - async (req: AuthenticatedRequest, res: Response): Promise => { - if ( - !req.authorization?.can( - ACTIONS.CUSTOM.APPROVE, - subject(SUBJECTS.USER, { _id: req.params.id }) - ) - ) { - res.sendStatus(403); - return; - } - try { - const { status } = req.body; - if (!['Approved', 'Rejected'].includes(status)) { - res.status(400).json({ message: 'Invalid status update.' }); - return; - } - const updatedUser = await User.findByIdAndUpdate( - req.params.id, - { approvalStatus: status }, - { new: true } - ); - if (!updatedUser) { - res.status(404).json({ message: 'User not found.' }); - return; - } - res.json({ message: `User ${status}`, user: updatedUser }); - } catch (err) { - console.error('Error updating approval status:', err); - res.status(500).json({ - message: 'Server error: Unable to update approval status.' - }); - } - } -); - -// ─── For Admin to PreAuthorize ─────────────────────────────────────── -router.post( - '/preapprove', - auth, - async (req: AuthenticatedRequest, res: Response): Promise => { - if (!req.authorization?.can(ACTIONS.CUSTOM.PREAPPROVE, SUBJECTS.USER)) { - res.sendStatus(403); - return; - } - try { - const { firstName, lastName, email, phone, role } = req.body; - if (await User.findOne({ phone })) { - res.status(400).json({ - message: 'User already exists with this phone' - }); - return; - } - const newUser = new User({ - firstName, - lastName, - email, - phone, - role, - approvalStatus: 'Approved' - }); - await newUser.save(); - res.status(201).json({ message: 'User registered successfully!' }); - } catch (error) { - console.error(error); - res.status(500).json({ - message: 'Server Error: Could not register user' - }); - } - } -); - -// ─── View Profile ─────────────────────────────────────── -router.get( - '/users/:employeeId', - auth, - async (req: AuthenticatedRequest, res: Response): Promise => { - if ( - !req.authorization?.can( - ACTIONS.CASL.READ, - subject(SUBJECTS.USER, { employeeId: req.params.employeeId }) - ) - ) { - res.sendStatus(403); - return; - } - try { - const user = await User.findOne({ - employeeId: req.params.employeeId - }); - console.log('User found:', user); - if (!user) { - res.status(404).json({ message: 'User not found' }); - return; - } - - res.json(user); - } catch (err) { - console.error('Error fetching user profile:', err); - res.status(500).json({ - message: 'Server error: Unable to fetch user profile' - }); - } - } -); - -// ─── Edit User Profile ─────────────────────────────────────── -router.put( - '/users/:employeeId', - auth, - async (req: AuthenticatedRequest, res: Response): Promise => { - if ( - !req.authorization?.can( - ACTIONS.CASL.UPDATE, - subject(SUBJECTS.USER, { employeeId: req.params.employeeId }) - ) - ) { - res.sendStatus(403); - return; - } - try { - const { firstName, lastName, email, phone, role } = req.body; - - const updatedUser = await User.findOneAndUpdate( - { employeeId: req.params.employeeId }, - { firstName, lastName, email, phone, role }, - { new: true } - ); - - if (!updatedUser) { - res.status(404).json({ message: 'User not found' }); - return; - } - - res.json({ - message: 'Profile updated successfully', - user: updatedUser - }); - } catch (err) { - console.error('Error updating user profile:', err); - res.status(500).json({ - message: 'Server error: Unable to update user profile' - }); - } - } -); - -// ─── View Profile by _id ─────────────────────────────────────── -router.get( - '/users/by-id/:id', - auth, - async (req: AuthenticatedRequest, res: Response): Promise => { - if ( - !req.authorization?.can( - ACTIONS.CASL.READ, - subject(SUBJECTS.USER, { _id: req.params.id }) - ) - ) { - res.sendStatus(403); - return; - } - try { - const user = await User.findById(req.params.id); - console.log('User found:', user); - if (!user) { - res.status(404).json({ message: 'User not found' }); - return; - } - - res.json(user); - } catch (err) { - console.error('Error fetching user profile by _id:', err); - res.status(500).json({ - message: 'Server error: Unable to fetch user profile' - }); - } - } -); - -// ─── Edit User Profile by _id ─────────────────────────────────────── -router.put( - '/users/by-id/:id', - auth, - async (req: AuthenticatedRequest, res: Response): Promise => { - if ( - !req.authorization?.can( - ACTIONS.CASL.UPDATE, - subject(SUBJECTS.USER, { _id: req.params.id }) - ) - ) { - res.sendStatus(403); - return; - } - try { - const { firstName, lastName, email, phone, role } = req.body; - - const updatedUser = await User.findByIdAndUpdate( - req.params.id, - { firstName, lastName, email, phone, role }, - { new: true } - ); - - if (!updatedUser) { - res.status(404).json({ message: 'User not found' }); - return; - } - - res.json({ - message: 'Profile updated successfully', - user: updatedUser - }); - } catch (err) { - console.error('Error updating user profile by _id:', err); - res.status(500).json({ - message: 'Server error: Unable to update user profile' - }); - } - } -); - export default router; From 37b773c4bf55f4a5b00354e94c2b8fb52e2a5831 Mon Sep 17 00:00:00 2001 From: robbinat Date: Fri, 14 Nov 2025 15:09:04 -0800 Subject: [PATCH 2/4] chore: update client login/signup --- client/src/App.tsx | 6 +- client/src/pages/Login/Login.tsx | 39 +++++++---- client/src/pages/Signup/Signup.tsx | 96 ++++++++++++++++++++++------ client/src/utils/authTokenHandler.ts | 19 ++++++ 4 files changed, 123 insertions(+), 37 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index df7c30d5..1831da64 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,6 +22,7 @@ import { } from 'react-router-dom'; import { ProtectedRoute } from '@/components'; import { muiTheme } from '@/theme/muiTheme'; +import { isTokenValid } from '@/utils/authTokenHandler'; function App() { return ( @@ -30,10 +31,7 @@ function App() { - } - /> + } /> } /> { + if (isTokenValid()) { + navigate('/dashboard'); + } else { + // Token is invalid or expired, clean it up + deleteAuthToken(); + } + }, [navigate]); // const [email, setEmail] = useState(''); const [phone, setPhone] = useState(''); const [otp, setOtp] = useState(''); @@ -267,22 +288,14 @@ export default function Login() { )} {errorMessage && ( - + {errorMessage} - + )} {successMessage && ( - + {successMessage} - + )} diff --git a/client/src/pages/Signup/Signup.tsx b/client/src/pages/Signup/Signup.tsx index e70376bf..8f7feac6 100644 --- a/client/src/pages/Signup/Signup.tsx +++ b/client/src/pages/Signup/Signup.tsx @@ -1,6 +1,5 @@ import { useEffect, useState } from 'react'; -import { saveAuthToken } from '@/utils/authTokenHandler'; import { Alert, Box, Button, Link, Stack, Typography } from '@mui/material'; import { useNavigate } from 'react-router-dom'; @@ -10,9 +9,23 @@ import { PhoneInput, RoleSelect } from '@/components/forms'; +import { + deleteAuthToken, + isTokenValid +} from '@/utils/authTokenHandler'; export default function Signup() { const navigate = useNavigate(); + + // Redirect if user already has a valid token + useEffect(() => { + if (isTokenValid()) { + navigate('/dashboard'); + } else { + // Token is invalid or expired, clean it up + deleteAuthToken(); + } + }, [navigate]); const [userData, setUserData] = useState({ firstName: '', lastName: '', @@ -23,8 +36,10 @@ export default function Signup() { }); const [otp, setOtp] = useState(''); const [otpSent, setOtpSent] = useState(false); - const [errorMessage, setErrorMessage] = useState(''); + const [errorMessages, setErrorMessages] = useState([]); const [countdown, setCountdown] = useState(0); + const [pendingApproval, setPendingApproval] = useState(false); + const [signupSuccess, setSignupSuccess] = useState(false); useEffect(() => { let timer: string | number | NodeJS.Timeout | undefined; @@ -49,29 +64,33 @@ export default function Signup() { }; const sendOtp = async () => { - setErrorMessage(''); + setErrorMessages([]); try { const response = await fetch('/api/auth/send-otp-signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phone: userData.phone, - email: userData.email - }) + body: JSON.stringify(userData) }); const data = await response.json(); if (response.ok) { setOtpSent(true); setCountdown(60); } else { - setErrorMessage(data.message); + // Extract individual error messages from validation errors + if (data.errors && Array.isArray(data.errors)) { + const messages = data.errors.map((err: { message: string }) => err.message); + setErrorMessages(messages); + } else { + setErrorMessages([data.message || 'Failed to send OTP']); + } } } catch { - setErrorMessage('Failed to send OTP'); + setErrorMessages(['Failed to send OTP']); } }; const verifyOtp = async () => { + setErrorMessages([]); try { const response = await fetch('/api/auth/verify-otp-signup', { method: 'POST', @@ -80,16 +99,26 @@ export default function Signup() { }); const data = await response.json(); if (response.ok) { - saveAuthToken(data.token); - navigate(data.redirectTo); + // New users always need approval, show pending message that instructs user to sign in again once approved + setSignupSuccess(true); + setPendingApproval(true); + setOtpSent(false); + setOtp(''); } else { - setErrorMessage(data.message); + // Extract individual error messages from validation errors + if (data.errors && Array.isArray(data.errors)) { + const messages = data.errors.map((err: { message: string }) => err.message); + setErrorMessages(messages); + } else { + setErrorMessages([data.message || 'Failed to verify OTP']); + } } } catch { - setErrorMessage('Failed to verify OTP'); + setErrorMessages(['Failed to verify OTP']); } }; + return ( - {otpSent ? 'Verify OTP' : 'Welcome! Sign Up Here!'} + {signupSuccess + ? 'Signup Successful!' + : otpSent + ? 'Verify OTP' + : 'Welcome! Sign Up Here!'} - {!otpSent ? ( + {signupSuccess && pendingApproval ? ( + + + Your account has been created successfully! However, your account + is pending approval from an administrator. + + + Once your account has been approved, please log back in to access + the application. + + + + ) : !otpSent ? ( @@ -245,10 +297,14 @@ export default function Signup() { )} - {errorMessage && ( - - {errorMessage} - + {errorMessages.length > 0 && ( + + {errorMessages.map((error, index) => ( + + {error} + + ))} + )} diff --git a/client/src/utils/authTokenHandler.ts b/client/src/utils/authTokenHandler.ts index 298055d7..86dc26e3 100644 --- a/client/src/utils/authTokenHandler.ts +++ b/client/src/utils/authTokenHandler.ts @@ -35,3 +35,22 @@ export function hasAuthToken(): boolean { const token = getAuthToken(); return token != null && token !== ''; } + +export function isTokenValid(): boolean { + const token = getAuthToken(); + if (!token) return false; + + try { + const decoded = jwtDecode(token); + // Check if token has expiration and if it's expired + if (decoded.exp) { + const currentTime = Math.floor(Date.now() / 1000); + return decoded.exp > currentTime; + } + // If no expiration date, treat as invalid for security + return false; + } catch (error) { + // Token is invalid or malformed + return false; + } +} From 3b8ef89e69becffeca81b980f6a4113d3a32bd75 Mon Sep 17 00:00:00 2001 From: robbinat Date: Fri, 14 Nov 2025 15:09:17 -0800 Subject: [PATCH 3/4] chore: form constraints and auth redirects --- client/src/components/forms/FormInput.tsx | 24 ++++++++++++++++++++++ client/src/components/forms/FormSelect.tsx | 23 +++++++++++++++++++-- client/src/components/forms/PhoneInput.tsx | 19 +++++++++++++++++ client/src/stores/useAuthStore.tsx | 16 ++++++++++++++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/client/src/components/forms/FormInput.tsx b/client/src/components/forms/FormInput.tsx index 4e8e32f1..18122678 100644 --- a/client/src/components/forms/FormInput.tsx +++ b/client/src/components/forms/FormInput.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { TextField, TextFieldProps } from '@mui/material'; import { PermissionTooltip } from './PermissionTooltip'; @@ -10,12 +11,35 @@ interface FormInputProps extends Omit { export const FormInput = ({ canEdit = true, showTooltip = false, + required, + value, + error, + helperText, + onBlur, ...props }: FormInputProps) => { + const [touched, setTouched] = useState(false); + + const handleBlur = (e: React.FocusEvent) => { + setTouched(true); + onBlur?.(e); + }; + + // Show error if required and value is empty, but only after field has been touched + const hasError = error || (touched && required && (!value || (typeof value === 'string' && !value.trim()))); const input = ( { const labelId = `${props.name ?? 'select'}-label`; + const [touched, setTouched] = useState(false); + + const handleBlur = (e: React.FocusEvent) => { + setTouched(true); + onBlur?.(e); + }; + + // Show error if required and value is empty, but only after field has been touched + const hasError = error || (touched && required && !value); const selectControl = ( - + {label} - {helperText && {helperText}} + {hasError && touched && required && !value && ( + This field is required + )} + {helperText && !hasError && {helperText}} ); diff --git a/client/src/components/forms/PhoneInput.tsx b/client/src/components/forms/PhoneInput.tsx index e2cd35f2..6e018524 100644 --- a/client/src/components/forms/PhoneInput.tsx +++ b/client/src/components/forms/PhoneInput.tsx @@ -1,6 +1,7 @@ import { TextField, TextFieldProps } from '@mui/material'; import { PermissionTooltip } from './PermissionTooltip'; +import { useState } from 'react'; interface PhoneInputProps extends Omit { canEdit?: boolean; @@ -11,9 +12,17 @@ export const PhoneInput = ({ canEdit = true, showTooltip = false, value, + helperText, onChange, + onBlur, ...props }: PhoneInputProps) => { + const [touched, setTouched] = useState(false); + + const handleBlur = (e: React.FocusEvent) => { + setTouched(true); + onBlur?.(e); + }; const handlePhoneChange = (e: React.ChangeEvent) => { if (!onChange) return; @@ -40,12 +49,22 @@ export const PhoneInput = ({ onChange(syntheticEvent as React.ChangeEvent); }; + const hasError = (touched && (!value || (typeof value === 'string' && !value.trim()))); + + const input = ( ()( setToken: (token: string) => set({ token }), clearToken: () => set({ token: '' }) }), - { name: 'auth-storage' } + { + name: 'auth-storage', + storage: { + getItem: (name) => { + const value = sessionStorage.getItem(name); + return value ? JSON.parse(value) : null; + }, + setItem: (name, value) => { + sessionStorage.setItem(name, JSON.stringify(value)); + }, + removeItem: (name) => { + sessionStorage.removeItem(name); + } + } + } ) ); From dcccda71e3f21fcdb9f09c34d6a688a4c309f853 Mon Sep 17 00:00:00 2001 From: robbinat Date: Fri, 14 Nov 2025 15:40:24 -0800 Subject: [PATCH 4/4] style --- client/src/App.tsx | 30 ++++++++++++------- client/src/components/forms/FormInput.tsx | 16 ++++++++-- client/src/components/forms/FormSelect.tsx | 12 ++++++-- client/src/components/forms/PhoneInput.tsx | 15 ++++++---- client/src/pages/Login/Login.tsx | 5 +--- client/src/pages/Signup/Signup.tsx | 29 ++++++++++-------- client/src/stores/useAuthStore.tsx | 6 ++-- client/src/utils/authTokenHandler.ts | 2 +- .../src/database/user/zod/auth.validator.ts | 1 - server/src/database/user/zod/user.base.ts | 17 +++++------ server/src/routes/v1/auth.ts | 6 ++-- 11 files changed, 85 insertions(+), 54 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 1831da64..6aae8bb4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,17 +1,20 @@ +import { ProtectedRoute } from '@/components'; import { AuthProvider } from '@/contexts'; import { - Survey, - SurveyDetails, - SurveyEntryDashboard, - QrPage, + ApplyReferral, LandingPage, Login, + NewUser, Profile, - ApplyReferral, + QrPage, Signup, StaffDashboard, - NewUser + Survey, + SurveyDetails, + SurveyEntryDashboard } from '@/pages'; +import { muiTheme } from '@/theme/muiTheme'; +import { isTokenValid } from '@/utils/authTokenHandler'; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider } from '@mui/material/styles'; import { @@ -20,9 +23,6 @@ import { BrowserRouter as Router, Routes } from 'react-router-dom'; -import { ProtectedRoute } from '@/components'; -import { muiTheme } from '@/theme/muiTheme'; -import { isTokenValid } from '@/utils/authTokenHandler'; function App() { return ( @@ -31,7 +31,17 @@ function App() { - } /> + + } + /> } /> { const [touched, setTouched] = useState(false); - const handleBlur = (e: React.FocusEvent) => { + const handleBlur = ( + e: React.FocusEvent + ) => { setTouched(true); onBlur?.(e); }; // Show error if required and value is empty, but only after field has been touched - const hasError = error || (touched && required && (!value || (typeof value === 'string' && !value.trim()))); + const hasError = + error || + (touched && + required && + (!value || (typeof value === 'string' && !value.trim()))); const input = ( + {label}