diff --git a/src/app/admin/billing/components/BillingSection.tsx b/src/app/admin/billing/components/BillingSection.tsx index 010f570..799794d 100644 --- a/src/app/admin/billing/components/BillingSection.tsx +++ b/src/app/admin/billing/components/BillingSection.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from 'react'; import CancelConfirmModal from '@/components/ui/CancelConfirmModal'; import { useGetPlansQuery } from '@/features/public/publicApiSlice'; +import { useVerificationCheck } from '@/features/settings/hooks/useVerificationCheck'; import { useChangePlan, useCreateSubscription, @@ -71,6 +72,7 @@ export default function BillingSection() { const { downgrade } = useDowngradeToFree(); const { subscription, isSubscribed, isCancelled, currentPlanId } = useSubscription(); + const { blockOperationWithAlert } = useVerificationCheck(); const tierOrder = { FREE: 0, BASIC: 1, PRO: 2 }; const sortedPlans = [...plans].sort( @@ -108,6 +110,11 @@ export default function BillingSection() { tier: 'FREE' | 'BASIC' | 'PRO', planId: string, ): Promise => { + // Check verification before any plan operations with detailed message + if (!blockOperationWithAlert('switch plans')) { + return; + } + if (label.startsWith('Go with')) { if (!subscription || subscription.status === 'cancelled') { await create(planId); diff --git a/src/app/admin/booking/components/TaskManager/BookingModal.tsx b/src/app/admin/booking/components/TaskManager/BookingModal.tsx index b36aa5d..d37a5aa 100644 --- a/src/app/admin/booking/components/TaskManager/BookingModal.tsx +++ b/src/app/admin/booking/components/TaskManager/BookingModal.tsx @@ -27,6 +27,7 @@ import { type Service } from '@/features/service/serviceApi'; import { useCreateServiceBookingMutation } from '@/features/service/serviceBookingApi'; import type { ServiceManagement } from '@/features/service-management/serviceManagementApi'; import { useGetServiceFormFieldsQuery } from '@/features/service-management/serviceManagementApi'; +import { useVerificationCheck } from '@/features/settings/hooks/useVerificationCheck'; import { useAppSelector } from '@/redux/hooks'; interface Props { onClose: () => void; @@ -297,6 +298,7 @@ const BookingModal: React.FC = ({ >({}); const [createServiceBooking] = useCreateServiceBookingMutation(); const user = useAppSelector(state => state.auth.user); + const { blockOperationWithAlert } = useVerificationCheck(); // Get custom form fields for the selected service const { data: customFormFields = [] } = useGetServiceFormFieldsQuery( @@ -445,6 +447,11 @@ const BookingModal: React.FC = ({ const handleCreate = async (): Promise => { try { + // Check verification before creating booking + if (!blockOperationWithAlert('create a booking')) { + return; + } + if (!user) { throw new Error('User is missing, please login again.'); } diff --git a/src/app/admin/overview/components/VerificationReminder.tsx b/src/app/admin/overview/components/VerificationReminder.tsx new file mode 100644 index 0000000..815f569 --- /dev/null +++ b/src/app/admin/overview/components/VerificationReminder.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { + Alert, + AlertTitle, + Box, + Button, + Chip, + Typography, +} from '@mui/material'; +import { useRouter } from 'next/navigation'; +import React from 'react'; + +import { useGetVerificationQuery } from '@/features/settings/settingsApi'; + +interface VerificationReminderProps { + userId: string; +} + +export default function VerificationReminder({ + userId, +}: VerificationReminderProps) { + const router = useRouter(); + const { data: verificationData, isLoading } = useGetVerificationQuery( + userId, + { + skip: !userId, + }, + ); + + if (isLoading || !verificationData) { + return null; + } + + // Check if both email and phone are verified + const isFullyVerified = + verificationData.emailVerified && verificationData.mobileVerified; + + if (isFullyVerified) { + return null; // Don't show reminder if fully verified + } + + const handleGoToSettings = () => { + router.push('/admin/settings'); + }; + + const unverifiedCount = [ + !verificationData.emailVerified, + !verificationData.mobileVerified, + ].filter(Boolean).length; + + return ( + + + + ⚠️ Account Verification Required - {unverifiedCount} Item + {unverifiedCount > 1 ? 's' : ''} Pending + + + + Important: Your account requires verification to + access all features and ensure security. +
+ Impact: Some operations are currently blocked until + verification is complete. +
+ + + {!verificationData.emailVerified && ( + + + + Email: {verificationData.email ?? 'Not provided'} + + + )} + {!verificationData.mobileVerified && ( + + + + Phone: {verificationData.mobile ?? 'Not provided'} + + + )} + + + + + + + +
+
+ ); +} + diff --git a/src/app/admin/overview/page.tsx b/src/app/admin/overview/page.tsx index 6220b81..d642f02 100644 --- a/src/app/admin/overview/page.tsx +++ b/src/app/admin/overview/page.tsx @@ -7,11 +7,13 @@ import React, { useEffect } from 'react'; import { AdminPageLayout } from '@/components/layout/admin-layout'; import ProFeatureModal from '@/components/ui/ProFeatureModal'; import { useSubscription } from '@/features/subscription/useSubscription'; +import { useAppSelector } from '@/redux/hooks'; import { getPlanTier, isFreeOrBasicPlan, isProPlan } from '@/utils/planUtils'; import ActivitySection from './components/ActivitySection'; import CampaignProgressSection from './components/CompaignProgressSection'; import RecentService from './components/RecentService'; +import VerificationReminder from './components/VerificationReminder'; const styles = { contentContainer: { @@ -36,6 +38,7 @@ const styles = { }; export default function OverviewPage() { + const user = useAppSelector(state => state.auth.user); const { subscription } = useSubscription(); const params = useSearchParams(); const router = useRouter(); @@ -66,6 +69,9 @@ export default function OverviewPage() { return ( <> + {/* Verification Reminder */} + {user?._id && } + diff --git a/src/app/admin/service-management/components/EditServiceModal.tsx b/src/app/admin/service-management/components/EditServiceModal.tsx index 379cdff..856ed8e 100644 --- a/src/app/admin/service-management/components/EditServiceModal.tsx +++ b/src/app/admin/service-management/components/EditServiceModal.tsx @@ -28,6 +28,7 @@ import { useSaveServiceFormFieldsMutation, useUpdateServiceMutation, } from '@/features/service-management/serviceManagementApi'; +import { useVerificationCheck } from '@/features/settings/hooks/useVerificationCheck'; import { useAppSelector } from '@/redux/hooks'; import theme from '@/theme'; @@ -232,6 +233,7 @@ export default function EditServiceModal({ const [createService, { isLoading: isCreating }] = useCreateServiceMutation(); const [updateService, { isLoading: isUpdating }] = useUpdateServiceMutation(); const [saveServiceFormFields] = useSaveServiceFormFieldsMutation(); + const { blockOperationWithAlert } = useVerificationCheck(); // 获取现有的表单字段 const { data: existingFormFields = [] } = useGetServiceFormFieldsQuery( @@ -309,6 +311,11 @@ export default function EditServiceModal({ const handleSubmit = async (): Promise => { try { + // Check verification before creating/updating service + if (!blockOperationWithAlert('create or update a service')) { + return; + } + // Validation before submission if (!formData.name.trim()) { alert('Please enter a service name'); diff --git a/src/app/admin/settings/VerificationSection.tsx b/src/app/admin/settings/VerificationSection.tsx index 860f15b..3653454 100644 --- a/src/app/admin/settings/VerificationSection.tsx +++ b/src/app/admin/settings/VerificationSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Box } from '@mui/material'; +import { Alert, AlertTitle, Box, Chip, Typography } from '@mui/material'; import React, { useState } from 'react'; import EditModal from '@/app/admin/settings/components/EditModal'; @@ -12,15 +12,29 @@ import VerificationForm from '@/app/admin/settings/components/Verification/Verif import { useGetUserProfileQuery, useGetVerificationQuery, + useSendEmailVerificationMutation, + useSendSmsVerificationMutation, useUpdateVerificationMutation, useVerifyEmailMutation, - useVerifyMobileMutation, + useVerifySmsMutation, } from '@/features/settings/settingsApi'; +import { useCountdown } from '@/hooks/useCountdown'; import { useAppSelector } from '@/redux/hooks'; import { validateVerificationForm } from '@/utils/validationSettings'; export default function VerificationSection() { const user = useAppSelector(state => state.auth.user); + const { + countdown: emailCountdown, + isActive: isEmailCountdownActive, + startCountdown: startEmailCountdown, + } = useCountdown(); + const { + countdown: mobileCountdown, + isActive: isMobileCountdownActive, + startCountdown: startMobileCountdown, + } = useCountdown(); + // Get verification data from API const { data: verificationData, isLoading: isVerificationLoading } = useGetVerificationQuery(user?._id ?? '', { @@ -31,8 +45,10 @@ export default function VerificationSection() { skip: !user?._id, }); const [updateVerification] = useUpdateVerificationMutation(); - const [verifyMobile] = useVerifyMobileMutation(); + const [sendEmailVerification] = useSendEmailVerificationMutation(); const [verifyEmail] = useVerifyEmailMutation(); + const [sendSmsVerification] = useSendSmsVerificationMutation(); + const [verifySms] = useVerifySmsMutation(); const [open, setOpen] = useState(false); const [verificationModal, setVerificationModal] = useState<{ open: boolean; @@ -112,6 +128,20 @@ export default function VerificationSection() { return; } + // Check if country code is selected for mobile verification + if ( + (formValues.type === 'SMS' || formValues.type === 'Both') && + formValues.mobile + ) { + const match = /^(\+\d+)?\s*(.*)$/.exec(formValues.mobile); + const countryCode = match?.[1]; + + if (!countryCode || countryCode === '') { + setError('Please select a country code for mobile number'); + return; + } + } + // Check if mobile or email has changed const mobileChanged = formValues.mobile !== values.mobile; const emailChanged = formValues.email !== values.email; @@ -139,18 +169,36 @@ export default function VerificationSection() { throw new Error('Mobile number not available'); } - await verifyMobile({ + // Check if country code is selected + const match = /^(\+\d+)?\s*(.*)$/.exec(values.mobile); + const countryCode = match?.[1]; + + if (!countryCode || countryCode === '') { + setError( + 'Please select a country code before sending SMS verification', + ); + return; + } + + await sendSmsVerification({ userId: user._id, mobile: values.mobile, }).unwrap(); + // Start 60-second countdown + startMobileCountdown(60); + setVerificationModal({ open: true, type: 'mobile', contact: values.mobile, }); - } catch { - setError('Failed to verify mobile number'); + } catch (error: unknown) { + const errorMessage = + error && typeof error === 'object' && 'data' in error + ? (error.data as { message?: string })?.message + : null; + setError(errorMessage ?? 'Failed to send SMS verification'); } }; @@ -160,18 +208,25 @@ export default function VerificationSection() { throw new Error('Email not available'); } - await verifyEmail({ + await sendEmailVerification({ userId: user._id, email: values.email, }).unwrap(); + // Start 60-second countdown + startEmailCountdown(60); + setVerificationModal({ open: true, type: 'email', contact: values.email, }); - } catch { - setError('Failed to verify email address'); + } catch (error: unknown) { + const errorMessage = + error && typeof error === 'object' && 'data' in error + ? (error.data as { message?: string })?.message + : null; + setError(errorMessage ?? 'Failed to send verification email'); } }; @@ -183,6 +238,26 @@ export default function VerificationSection() { }); }; + const handleVerifyCode = async (code: string) => { + if (!user?._id || !verificationModal.contact) { + throw new Error('User or contact not available'); + } + + if (verificationModal.type === 'email') { + await verifyEmail({ + userId: user._id, + email: verificationModal.contact, + code, + }).unwrap(); + } else if (verificationModal.type === 'mobile') { + await verifySms({ + userId: user._id, + mobile: verificationModal.contact, + code, + }).unwrap(); + } + }; + const handleMarketingPromotionsChange = async (checked: boolean) => { try { if (!user?._id) { @@ -229,6 +304,8 @@ export default function VerificationSection() { type="SMS" mobile={values.mobile} mobileVerified={values.mobileVerified} + mobileCountdown={mobileCountdown} + isMobileCountdownActive={isMobileCountdownActive} onVerifyMobile={() => { void handleVerifyMobile(); }} @@ -241,6 +318,8 @@ export default function VerificationSection() { type="Email" email={values.email} emailVerified={values.emailVerified} + emailCountdown={emailCountdown} + isEmailCountdownActive={isEmailCountdownActive} marketingPromotions={values.marketingPromotions} showMarketingPromotions onVerifyEmail={() => { @@ -268,6 +347,14 @@ export default function VerificationSection() { emailVerified={ values.type === 'Email' ? values.emailVerified : undefined } + mobileCountdown={values.type === 'SMS' ? mobileCountdown : undefined} + emailCountdown={values.type === 'Email' ? emailCountdown : undefined} + isMobileCountdownActive={ + values.type === 'SMS' ? isMobileCountdownActive : undefined + } + isEmailCountdownActive={ + values.type === 'Email' ? isEmailCountdownActive : undefined + } marketingPromotions={ values.type === 'Email' ? values.marketingPromotions : undefined } @@ -291,11 +378,103 @@ export default function VerificationSection() { ); }; + // Check verification status + const isFullyVerified = + verificationData?.emailVerified && verificationData?.mobileVerified; + const unverifiedCount = [ + !verificationData?.emailVerified, + !verificationData?.mobileVerified, + ].filter(Boolean).length; + return ( <> + + {/* Verification Status Alert */} + {!isFullyVerified && verificationData && ( + + + ⚠️ Account Verification Required - {unverifiedCount} Item + {unverifiedCount > 1 ? 's' : ''} Pending + + + Impact: Some operations are blocked until + verification is complete. + + + {!verificationData.emailVerified && ( + + )} + {!verificationData.mobileVerified && ( + + )} + + + )} + + {/* Verification Success Alert */} + {isFullyVerified && verificationData && ( + + + ✅ Account Fully Verified + + + All contact information has been verified. You have full access to + all features. + + + )} + + {/* Error Message Display */} + {error && ( + setError(null)} + > + Error + {error} + + )} + {/* Display Mode */} {isVerificationLoading ? ( @@ -327,6 +506,7 @@ export default function VerificationSection() { type={verificationModal.type} contact={verificationModal.contact} onClose={handleCloseVerificationModal} + onVerify={handleVerifyCode} /> ); diff --git a/src/app/admin/settings/components/Verification/VerificationCard.tsx b/src/app/admin/settings/components/Verification/VerificationCard.tsx index 564db8b..b699759 100644 --- a/src/app/admin/settings/components/Verification/VerificationCard.tsx +++ b/src/app/admin/settings/components/Verification/VerificationCard.tsx @@ -76,6 +76,10 @@ interface VerificationCardProps { email?: string; mobileVerified?: boolean; emailVerified?: boolean; + mobileCountdown?: number; + emailCountdown?: number; + isMobileCountdownActive?: boolean; + isEmailCountdownActive?: boolean; marketingPromotions?: boolean; showMarketingPromotions?: boolean; onVerifyMobile?: () => void; diff --git a/src/app/admin/settings/components/Verification/VerificationCodeModal.tsx b/src/app/admin/settings/components/Verification/VerificationCodeModal.tsx index 1c7b9ec..90b9945 100644 --- a/src/app/admin/settings/components/Verification/VerificationCodeModal.tsx +++ b/src/app/admin/settings/components/Verification/VerificationCodeModal.tsx @@ -18,7 +18,7 @@ interface VerificationCodeModalProps { type: 'mobile' | 'email'; contact: string; // mobile number or email onClose: () => void; - // onVerify: (code: string) => Promise; + onVerify: (code: string) => Promise; } const VerificationCodeModal: React.FC = ({ @@ -26,7 +26,7 @@ const VerificationCodeModal: React.FC = ({ type, contact, onClose, - // onVerify, + onVerify, }) => { const [code, setCode] = useState(''); const [error, setError] = useState(null); @@ -38,7 +38,7 @@ const VerificationCodeModal: React.FC = ({ onClose(); }; - const handleVerify = () => { + const handleVerify = async () => { if (!code.trim()) { setError('Please enter verification code'); return; @@ -48,7 +48,7 @@ const VerificationCodeModal: React.FC = ({ setError(null); try { - // await onVerify(code); + await onVerify(code); handleClose(); } catch { setError('Invalid verification code. Please try again.'); diff --git a/src/app/auth/callback/AuthCallbackContent.tsx b/src/app/auth/callback/AuthCallbackContent.tsx index 12afc63..12e7ba7 100644 --- a/src/app/auth/callback/AuthCallbackContent.tsx +++ b/src/app/auth/callback/AuthCallbackContent.tsx @@ -49,6 +49,7 @@ export default function AuthCallbackContent() { // Clear any persisted auth state to prevent old user ID from being used localStorage.removeItem('persist:root'); + // eslint-disable-next-line no-console console.log('[AuthCallback] Setting user with ID:', parsedUser._id); dispatch( diff --git a/src/features/settings/hooks/useVerificationCheck.ts b/src/features/settings/hooks/useVerificationCheck.ts new file mode 100644 index 0000000..9303dd2 --- /dev/null +++ b/src/features/settings/hooks/useVerificationCheck.ts @@ -0,0 +1,94 @@ +import { useRouter } from 'next/navigation'; +import { useCallback } from 'react'; + +import { useGetVerificationQuery } from '@/features/settings/settingsApi'; +import { useAppSelector } from '@/redux/hooks'; + +export const useVerificationCheck = () => { + const router = useRouter(); + const user = useAppSelector(state => state.auth.user); + + const { data: verificationData, isLoading } = useGetVerificationQuery( + user?._id ?? '', + { + skip: !user?._id, + }, + ); + + const isFullyVerified = + verificationData?.emailVerified && verificationData?.mobileVerified; + const isEmailVerified = verificationData?.emailVerified ?? false; + const isPhoneVerified = verificationData?.mobileVerified ?? false; + + const checkVerificationAndRedirect = useCallback(() => { + if (isLoading) return false; + + if (!isFullyVerified) { + router.push('/admin/settings'); + return false; + } + + return true; + }, [isLoading, isFullyVerified, router]); + + const showVerificationModal = useCallback(() => { + if (!isFullyVerified) { + router.push('/admin/settings'); + return true; + } + return false; + }, [isFullyVerified, router]); + + // New function to check verification with detailed error message + const checkVerificationWithMessage = useCallback( + (operation: string) => { + if (isLoading) { + return { allowed: false, message: 'Verification status is loading...' }; + } + + if (!isFullyVerified) { + const unverifiedItems = []; + if (!isEmailVerified) unverifiedItems.push('email'); + if (!isPhoneVerified) unverifiedItems.push('phone'); + + return { + allowed: false, + message: `Cannot ${operation}. Please verify your ${unverifiedItems.join(' and ')} first.`, + unverifiedItems, + redirect: () => router.push('/admin/settings'), + }; + } + + return { allowed: true }; + }, + [isLoading, isFullyVerified, isEmailVerified, isPhoneVerified, router], + ); + + // Function to block operations with alert + const blockOperationWithAlert = useCallback( + (operation: string) => { + const result = checkVerificationWithMessage(operation); + if (!result.allowed) { + alert( + `🚨 Verification Required\n\n${result.message}\n\nYou will be redirected to Settings to complete verification.`, + ); + result.redirect?.(); + } + return result.allowed; + }, + [checkVerificationWithMessage], + ); + + return { + isFullyVerified, + isEmailVerified, + isPhoneVerified, + isLoading, + verificationData, + checkVerificationAndRedirect, + showVerificationModal, + checkVerificationWithMessage, + blockOperationWithAlert, + }; +}; + diff --git a/src/features/settings/settingsApi.ts b/src/features/settings/settingsApi.ts index b858b21..785f4f4 100644 --- a/src/features/settings/settingsApi.ts +++ b/src/features/settings/settingsApi.ts @@ -138,7 +138,7 @@ export const settingsApi = createApi({ }), getVerification: builder.query({ query: userId => ({ - url: `/api/settings/user/${userId}/verification`, + url: `/verification/user/${userId}`, method: 'GET', }), providesTags: ['Verification'], @@ -148,34 +148,56 @@ export const settingsApi = createApi({ { userId: string } & VerificationSettings >({ query: ({ userId, ...verificationData }) => ({ - url: `/api/settings/user/${userId}/verification`, + url: `/verification/user/${userId}`, method: 'PUT', data: verificationData, }), invalidatesTags: ['Verification', 'UserProfile'], }), - verifyMobile: builder.mutation< - { success: boolean; message: string }, + sendSmsVerification: builder.mutation< + { success: boolean; message?: string }, { userId: string; mobile: string } >({ query: ({ userId, mobile }) => ({ - url: `/api/settings/user/${userId}/verification/mobile`, + url: `/verification/user/${userId}/mobile/send`, method: 'POST', data: { mobile }, }), invalidatesTags: ['Verification'], }), - verifyEmail: builder.mutation< + verifySms: builder.mutation< { success: boolean; message: string }, + { userId: string; mobile: string; code: string } + >({ + query: ({ userId, mobile, code }) => ({ + url: `/verification/user/${userId}/mobile/verify`, + method: 'POST', + data: { mobile, code }, + }), + invalidatesTags: ['Verification'], + }), + sendEmailVerification: builder.mutation< + { success: boolean; message?: string }, { userId: string; email: string } >({ query: ({ userId, email }) => ({ - url: `/api/settings/user/${userId}/verification/email`, + url: `/verification/user/${userId}/email/send`, method: 'POST', data: { email }, }), invalidatesTags: ['Verification'], }), + verifyEmail: builder.mutation< + { success: boolean; message: string }, + { userId: string; email: string; code: string } + >({ + query: ({ userId, email, code }) => ({ + url: `/verification/user/${userId}/email/verify`, + method: 'POST', + data: { email, code }, + }), + invalidatesTags: ['Verification'], + }), checkABNExists: builder.mutation< { exists: boolean }, { abn: string; userId: string } @@ -219,8 +241,10 @@ export const { useUpdateGreetingMutation, useGetVerificationQuery, useUpdateVerificationMutation, - useVerifyMobileMutation, + useSendEmailVerificationMutation, + useSendSmsVerificationMutation, useVerifyEmailMutation, + useVerifySmsMutation, useGetAddressQuery, useUpdateAddressMutation, } = settingsApi; diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..27afeac --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -0,0 +1,53 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface UseCountdownReturn { + countdown: number; + isActive: boolean; + startCountdown: (seconds: number) => void; + stopCountdown: () => void; +} + +export const useCountdown = (): UseCountdownReturn => { + const [countdown, setCountdown] = useState(0); + const [isActive, setIsActive] = useState(false); + + useEffect(() => { + let interval: NodeJS.Timeout | null = null; + + if (isActive && countdown > 0) { + interval = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + setIsActive(false); + return 0; + } + return prev - 1; + }); + }, 1000); + } + + return () => { + if (interval) { + clearInterval(interval); + } + }; + }, [isActive, countdown]); + + const startCountdown = useCallback((seconds: number) => { + setCountdown(seconds); + setIsActive(true); + }, []); + + const stopCountdown = useCallback(() => { + setIsActive(false); + setCountdown(0); + }, []); + + return { + countdown, + isActive, + startCountdown, + stopCountdown, + }; +}; +