diff --git a/client/src/App.tsx b/client/src/App.tsx index df7c30d5..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,8 +23,6 @@ import { BrowserRouter as Router, Routes } from 'react-router-dom'; -import { ProtectedRoute } from '@/components'; -import { muiTheme } from '@/theme/muiTheme'; function App() { return ( @@ -32,7 +33,14 @@ function App() { } + element={ + + } /> } /> { 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..84c74d70 100644 --- a/client/src/components/forms/PhoneInput.tsx +++ b/client/src/components/forms/PhoneInput.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { TextField, TextFieldProps } from '@mui/material'; import { PermissionTooltip } from './PermissionTooltip'; @@ -11,9 +13,19 @@ 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 +52,24 @@ export const PhoneInput = ({ onChange(syntheticEvent as React.ChangeEvent); }; + const hasError = + touched && (!value || (typeof value === 'string' && !value.trim())); + const input = ( { + 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 +285,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..472c481f 100644 --- a/client/src/pages/Signup/Signup.tsx +++ b/client/src/pages/Signup/Signup.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { saveAuthToken } from '@/utils/authTokenHandler'; +import { deleteAuthToken, isTokenValid } from '@/utils/authTokenHandler'; import { Alert, Box, Button, Link, Stack, Typography } from '@mui/material'; import { useNavigate } from 'react-router-dom'; @@ -13,6 +13,16 @@ import { 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 +33,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 +61,35 @@ 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,13 +98,24 @@ 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']); } }; @@ -119,10 +148,38 @@ export default function Signup() { textAlign="center" sx={{ mb: 3 }} > - {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 +302,14 @@ export default function Signup() { )} - {errorMessage && ( - - {errorMessage} - + {errorMessages.length > 0 && ( + + {errorMessages.map((error, index) => ( + + {error} + + ))} + )} diff --git a/client/src/stores/useAuthStore.tsx b/client/src/stores/useAuthStore.tsx index 99d51f2e..94f7e58c 100644 --- a/client/src/stores/useAuthStore.tsx +++ b/client/src/stores/useAuthStore.tsx @@ -9,11 +9,25 @@ type AuthStore = { export const useAuthStore = create()( persist( - (set) => ({ + set => ({ token: '', 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); + } + } + } ) ); diff --git a/client/src/utils/authTokenHandler.ts b/client/src/utils/authTokenHandler.ts index 298055d7..13b11670 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; + } +} 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..41a3c267 --- /dev/null +++ b/server/src/database/user/zod/auth.validator.ts @@ -0,0 +1,34 @@ +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..642b88d3 100644 --- a/server/src/database/user/zod/user.base.ts +++ b/server/src/database/user/zod/user.base.ts @@ -13,19 +13,25 @@ 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..769f700d 100644 --- a/server/src/routes/v1/auth.ts +++ b/server/src/routes/v1/auth.ts @@ -1,19 +1,17 @@ -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'; + sendOtpLoginSchema, + sendOtpSignupSchema, + verifyOtpLoginSchema, + verifyOtpSignupSchema +} from '@/database/user/zod/auth.validator'; +import { ApprovalStatus } from '@/database/utils/constants'; +import { validate } from '@/middleware/validate'; import { generateAuthToken } from '@/utils/authTokenHandler'; 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;