Files
tabungin/apps/web/src/components/pages/OtpVerification.tsx
dwindown 89f881e7cf feat: reorganize admin settings with tabbed interface and documentation
- Reorganized admin settings into tabbed interface (General, Security, Payment Methods)
- Vertical tabs on desktop, horizontal scrollable on mobile
- Moved Payment Methods from separate menu to Settings tab
- Fixed admin profile reuse and dashboard blocking
- Fixed maintenance mode guard to use AppConfig model
- Added admin auto-redirect after login (admins → /admin, users → /)
- Reorganized documentation into docs/ folder structure
- Created comprehensive README and documentation index
- Added PWA and Web Push notifications to to-do list
2025-10-13 09:28:12 +07:00

323 lines
11 KiB
TypeScript

import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { AuthLayout } from '@/components/layout/AuthLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2, Mail, Smartphone, AlertCircle, RefreshCw, Shield } from 'lucide-react'
import axios from 'axios'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
export function OtpVerification() {
const navigate = useNavigate()
const location = useLocation()
const { verifyOtp } = useAuth()
// Get params from either location.state (from login) or URL query params (from Google OAuth)
const searchParams = new URLSearchParams(location.search)
const urlToken = searchParams.get('token')
const urlMethods = searchParams.get('methods')
const tempToken = location.state?.tempToken || urlToken
const availableMethods = location.state?.availableMethods ||
(urlMethods ? JSON.parse(decodeURIComponent(urlMethods)) : null)
const [code, setCode] = useState('')
const [method, setMethod] = useState<'email' | 'whatsapp' | 'totp'>(
availableMethods?.totp ? 'totp' : availableMethods?.whatsapp ? 'whatsapp' : 'email'
)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [resendLoading, setResendLoading] = useState(false)
const [resendTimer, setResendTimer] = useState(30)
const [canResend, setCanResend] = useState(false)
// Countdown timer for resend button
useEffect(() => {
if (resendTimer > 0) {
const timer = setTimeout(() => setResendTimer(resendTimer - 1), 1000)
return () => clearTimeout(timer)
} else {
setCanResend(true)
}
}, [resendTimer])
if (!tempToken) {
navigate('/auth/login')
return null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const result = await verifyOtp(tempToken, code, method)
// Verification successful, redirect based on role
if (result.user?.role === 'admin') {
navigate('/admin')
} else {
navigate('/')
}
} catch (err) {
const error = err as { response?: { data?: { message?: string } } }
setError(error.response?.data?.message || 'Invalid OTP code. Please try again.')
} finally {
setLoading(false)
}
}
const handleResendEmail = async () => {
setResendLoading(true)
setError('')
try {
// Call backend to resend OTP with temp token
await axios.post(`${API_URL}/api/otp/email/resend`, {
tempToken
})
// Reset timer
setResendTimer(30)
setCanResend(false)
setError('')
} catch {
setError('Failed to resend code. Please try again.')
} finally {
setResendLoading(false)
}
}
const handleResendWhatsApp = async () => {
setResendLoading(true)
setError('')
try {
// Call backend to resend WhatsApp OTP with temp token
await axios.post(`${API_URL}/api/otp/whatsapp/resend`, {
tempToken
})
// Reset timer
setResendTimer(30)
setCanResend(false)
setError('')
} catch {
setError('Failed to resend code. Please try again.')
setResendLoading(false)
}
}
return (
<AuthLayout
title="Verify Your Identity"
description="Enter the verification code to continue"
>
<div className="space-y-6">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-center p-4 bg-primary/5 rounded-lg border border-primary/10">
<Shield className="h-5 w-5 text-primary mr-2" />
<p className="text-sm text-muted-foreground">
Two-factor authentication is enabled
</p>
</div>
<Tabs value={method} onValueChange={(v) => setMethod(v as 'email' | 'whatsapp' | 'totp')}>
<TabsList className={`grid w-full ${availableMethods?.whatsapp ? 'grid-cols-3' : 'grid-cols-2'}`}>
{availableMethods?.email && (
<TabsTrigger value="email" disabled={loading}>
<Mail className="mr-2 h-4 w-4" />
Email
</TabsTrigger>
)}
{availableMethods?.whatsapp && (
<TabsTrigger value="whatsapp" disabled={loading}>
<Smartphone className="mr-2 h-4 w-4" />
WhatsApp
</TabsTrigger>
)}
{availableMethods?.totp && (
<TabsTrigger value="totp" disabled={loading}>
<Shield className="mr-2 h-4 w-4" />
Authenticator
</TabsTrigger>
)}
</TabsList>
<TabsContent value="email" className="space-y-4">
<p className="text-sm text-muted-foreground">
A 6-digit code has been sent to your email address. Please check your inbox.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email-code">Email Code</Label>
<Input
id="email-code"
type="text"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Verify Code'
)}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleResendEmail}
disabled={!canResend || resendLoading || loading}
>
{resendLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : canResend ? (
<>
<Mail className="mr-2 h-4 w-4" />
Resend Code
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Resend in {resendTimer}s
</>
)}
</Button>
</form>
</TabsContent>
<TabsContent value="whatsapp" className="space-y-4">
<p className="text-sm text-gray-600">
A 6-digit code has been sent to your WhatsApp number. Please check your WhatsApp messages.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="whatsapp-code">WhatsApp Code</Label>
<Input
id="whatsapp-code"
type="text"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Verify Code'
)}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleResendWhatsApp}
disabled={!canResend || resendLoading || loading}
>
{resendLoading ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : canResend ? (
<>
<Smartphone className="mr-2 h-4 w-4" />
Resend Code
</>
) : (
<>
<Smartphone className="mr-2 h-4 w-4" />
Resend in {resendTimer}s
</>
)}
</Button>
</form>
</TabsContent>
<TabsContent value="totp" className="space-y-4">
<p className="text-sm text-gray-600">
Open your authenticator app and enter the 6-digit code.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="totp-code">Authenticator Code</Label>
<Input
id="totp-code"
type="text"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
required
disabled={loading}
className="text-center text-2xl tracking-widest"
/>
</div>
<Button type="submit" className="w-full" disabled={loading || code.length !== 6}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Verify Code'
)}
</Button>
</form>
</TabsContent>
</Tabs>
<Button
variant="ghost"
className="w-full"
onClick={() => navigate('/auth/login')}
disabled={loading}
>
Back to Login
</Button>
</div>
</AuthLayout>
)
}