admin/features/auth/useAuthFlow.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

312 lines
11 KiB
TypeScript

'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { addToast } from '@/lib/toast'
import type { ServiceError } from '@/services/errorHandler'
import { RESEND_COOLDOWN_SECONDS } from '@/context/AuthContext'
import { texts } from '@/texts'
import { convertPersianToEnglish } from '@/helpers'
import useAuth from '@/hooks/useAuth'
import { useSmsOtpAutofill } from '@/hooks/useSmsOtpAutofill'
import { getDefaultPostLoginPath, getSafeInternalRedirect } from '@/lib/authRouting'
import { ANALYTICS_EVENTS, trackAnalyticsEvent } from '@/lib/analytics'
import {
CheckOtpFormValidation,
CompleteProfileFormValidation,
type CompleteProfileFormValues,
OTP_LENGTH,
SendOtpFormValidation,
} from '@/validation/auth'
type AuthStep = 'mobile' | 'otp' | 'profile'
type OtpPurpose = 'login' | 'register'
const OTP_EXPIRES_IN_SECONDS = 120
const BLOCK_MESSAGES = {
ACCOUNT_SUSPENDED: texts.auth.accountSuspended,
ACCOUNT_DELETED: texts.auth.accountDeleted,
} as const
function apiErrorCode(error: unknown): string | undefined {
return error && typeof error === 'object' && 'apiCode' in error ? (error as ServiceError).apiCode : undefined
}
function postLoginRedirect(searchParams: URLSearchParams) {
const explicit = searchParams.get('redirect')
let fallback = getDefaultPostLoginPath('user')
try {
const raw = localStorage.getItem('user')
if (raw) fallback = getDefaultPostLoginPath((JSON.parse(raw) as { role?: 'user' | 'admin' }).role)
} catch {
// Ignore corrupted legacy storage and use the safe consumer home.
}
return getSafeInternalRedirect(explicit, fallback)
}
export function useAuthFlow(
options: {
redirectTo?: string
navigateOnSuccess?: boolean
onAuthenticated?: () => void
requireTermsAcceptance?: boolean
} = {}
) {
const requireTermsAcceptance = options.requireTermsAcceptance ?? true
const authSource = useRef(options.navigateOnSuccess === false ? 'auth_gate' : 'auth_page')
const { sendLoginOtp, checkLoginOtp, completeProfile, user } = useAuth()
const router = useRouter()
const searchParams = useSearchParams()
const [loading, setLoading] = useState(false)
const [step, setStep] = useState<AuthStep>('mobile')
const [mobile, setMobile] = useState('')
const [otpTtl, setOtpTtl] = useState(RESEND_COOLDOWN_SECONDS)
const [resendCodeTime, setResendCodeTime] = useState(0)
const [accountBlockMessage, setAccountBlockMessage] = useState<string | null>(null)
const [otpError, setOtpError] = useState<string | null>(null)
const [otpPurpose, setOtpPurpose] = useState<OtpPurpose | null>(null)
const [otpReused, setOtpReused] = useState(false)
const [termsAccepted, setTermsAccepted] = useState(false)
const [termsError, setTermsError] = useState<string | null>(null)
const [otpChallenge, setOtpChallenge] = useState(0)
const sendOtpForm = useForm({ resolver: zodResolver(SendOtpFormValidation), defaultValues: { mobile: '' } })
const checkOtpForm = useForm({ resolver: zodResolver(CheckOtpFormValidation), defaultValues: { code: '' } })
const completeProfileForm = useForm({
resolver: zodResolver(CompleteProfileFormValidation),
defaultValues: { firstName: '', lastName: '', gender: '', dateOfBirth: '', cityId: '' },
})
const applyAutofilledOtp = useCallback(
(code: string) => {
const digits = convertPersianToEnglish(code).replace(/\D/g, '').slice(0, OTP_LENGTH)
if (digits.length !== OTP_LENGTH) return
checkOtpForm.setValue('code', digits, { shouldDirty: true, shouldValidate: true })
},
[checkOtpForm]
)
useSmsOtpAutofill(step === 'otp', otpChallenge, applyAutofilledOtp)
useEffect(() => {
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_OPENED, { source: authSource.current })
}, [])
useEffect(() => {
if (user?.status === 'pending') setStep('profile')
}, [user?.status])
useEffect(() => {
if (step !== 'otp' || resendCodeTime <= 0) return
const timer = window.setTimeout(() => {
setResendCodeTime((value) => Math.max(0, value - 1))
}, 1000)
return () => {
window.clearTimeout(timer)
}
}, [resendCodeTime, step])
const sendOtp = useCallback(
async (data: { mobile: string }) => {
try {
setLoading(true)
setAccountBlockMessage(null)
const value = convertPersianToEnglish(data.mobile).trim()
const result = await sendLoginOtp({ mobile: value })
const alreadySent = result.alreadySent
// ارسال تازه همان پنجرهٔ ۱۲۰ ثانیه‌ای محصول است؛ اگر بک‌اند کد قبلی را
// نگه داشته، شمارش معکوس باید باقی‌ماندهٔ واقعی TTL باشد.
const resendIn = alreadySent ? Math.min(OTP_EXPIRES_IN_SECONDS, Math.max(1, result.expiresIn)) : OTP_EXPIRES_IN_SECONDS
const isResend = step === 'otp' && mobile === value
trackAnalyticsEvent(ANALYTICS_EVENTS.OTP_REQUESTED, {
purpose: result.purpose,
is_resend: isResend,
source: authSource.current,
})
setMobile(value)
setOtpPurpose(result.purpose)
setOtpReused(alreadySent)
setOtpTtl(resendIn)
setResendCodeTime(resendIn)
setOtpError(null)
if (!isResend) {
setTermsAccepted(false)
setTermsError(null)
}
setStep('otp')
setOtpChallenge((value) => value + 1)
checkOtpForm.reset({ code: '' })
} catch (error) {
const code = apiErrorCode(error)
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_FAILED, {
auth_stage: 'request_otp',
error_code: code ?? 'unknown',
source: authSource.current,
})
if (code === 'ACCOUNT_SUSPENDED' || code === 'ACCOUNT_DELETED') setAccountBlockMessage(BLOCK_MESSAGES[code])
else addToast({ title: texts.auth.sendOtpFailed, color: 'danger' })
} finally {
setLoading(false)
}
},
[checkOtpForm, mobile, sendLoginOtp, step]
)
const checkOtp = useCallback(
async (data: { code: string }) => {
if (requireTermsAcceptance && otpPurpose === 'register' && !termsAccepted) {
const message = texts.auth.termsRequiredMessage
setTermsError(message)
addToast({ title: texts.auth.termsRequiredTitle, description: message, color: 'danger' })
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_FAILED, {
auth_stage: 'terms_acceptance',
error_code: 'terms_not_accepted',
source: authSource.current,
})
return
}
try {
setLoading(true)
setOtpError(null)
setTermsError(null)
const status = await checkLoginOtp({
mobile,
code: convertPersianToEnglish(data.code).trim(),
// Server stamps terms version itself; client only asserts acceptance.
...(otpPurpose === 'register' ? { termsAccepted: true } : {}),
})
if (status === 'pending') setStep('profile')
if (status === 'active') {
trackAnalyticsEvent(ANALYTICS_EVENTS.LOGIN, { method: 'otp', source: authSource.current })
addToast({ title: texts.auth.welcome, color: 'success' })
options.onAuthenticated?.()
if (options.navigateOnSuccess !== false) router.replace(options.redirectTo ?? postLoginRedirect(searchParams))
}
} catch (error) {
const code = apiErrorCode(error)
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_FAILED, {
auth_stage: 'verify_otp',
error_code: code ?? 'unknown',
source: authSource.current,
})
if (code === 'ACCOUNT_SUSPENDED' || code === 'ACCOUNT_DELETED') {
setAccountBlockMessage(BLOCK_MESSAGES[code])
setStep('mobile')
} else if (code === 'OTP_INVALID' || code === 'OTP_EXPIRED') setOtpError(texts.auth.otpInvalidOrExpired)
else setOtpError(texts.auth.otpVerifyFailed)
} finally {
setLoading(false)
}
},
[checkLoginOtp, mobile, options, otpPurpose, requireTermsAcceptance, router, searchParams, termsAccepted]
)
const otpCode = checkOtpForm.watch('code')
const lastAutoSubmittedOtpRef = useRef<string | null>(null)
// با پر شدن رقم آخر، فرم را خودکار submit کن (دستی و SMS autofill)
useEffect(() => {
if (step !== 'otp' || loading) return
// watch('code') without a typed form can infer as {}; OTP field is always a string at runtime
const digits = convertPersianToEnglish(typeof otpCode === 'string' ? otpCode : '')
.replace(/\D/g, '')
.slice(0, OTP_LENGTH)
if (digits.length !== OTP_LENGTH) {
lastAutoSubmittedOtpRef.current = null
return
}
if (lastAutoSubmittedOtpRef.current === digits) return
lastAutoSubmittedOtpRef.current = digits
void checkOtpForm.handleSubmit(checkOtp)()
}, [checkOtp, checkOtpForm, loading, otpCode, step])
const submitProfile = useCallback(
async (data: CompleteProfileFormValues) => {
try {
setLoading(true)
await completeProfile({
firstName: data.firstName.trim(),
lastName: data.lastName.trim(),
gender: data.gender,
dateOfBirth: data.dateOfBirth,
cityId: Number(data.cityId),
})
addToast({ title: texts.auth.welcome, color: 'success' })
options.onAuthenticated?.()
if (options.navigateOnSuccess !== false) router.replace(options.redirectTo ?? postLoginRedirect(searchParams))
} catch {
trackAnalyticsEvent(ANALYTICS_EVENTS.AUTH_FAILED, {
auth_stage: 'complete_profile',
error_code: 'unknown',
source: authSource.current,
})
addToast({ title: texts.auth.profileCompleteFailed, color: 'danger' })
} finally {
setLoading(false)
}
},
[completeProfile, options, router, searchParams]
)
const resetToMobile = useCallback(() => {
setStep('mobile')
setResendCodeTime(0)
setOtpError(null)
setOtpPurpose(null)
setOtpReused(false)
setTermsAccepted(false)
setTermsError(null)
}, [])
const updateTermsAccepted = useCallback((accepted: boolean) => {
setTermsAccepted(accepted)
if (accepted) setTermsError(null)
}, [])
return {
accountBlockMessage,
checkOtp,
checkOtpForm,
completeProfileForm,
loading,
mobile,
isRegistration: otpPurpose === 'register',
otpError,
otpReused,
otpTtl,
resendCodeTime,
resetToMobile,
sendOtp,
sendOtpForm,
step,
submitProfile,
termsAccepted,
termsError,
updateTermsAccepted,
}
}