2025-12-11 19:49:08 +00:00
|
|
|
import { useState, useEffect, useRef } from 'react'
|
2025-12-02 23:17:15 +00:00
|
|
|
import { useNavigate } from 'react-router-dom'
|
|
|
|
|
import { useMutation } from '@tanstack/react-query'
|
|
|
|
|
import { toast } from 'sonner'
|
|
|
|
|
import { useAuthStore } from '@/stores/authStore'
|
|
|
|
|
import { authApi } from '@/api/auth'
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
|
|
|
import { Label } from '@/components/ui/label'
|
2025-12-11 19:49:08 +00:00
|
|
|
import type { CodeStatusResponse } from '@/types/models'
|
|
|
|
|
|
|
|
|
|
const TELEGRAM_BOT_USERNAME = 'mnemo_cards_bot'
|
|
|
|
|
const TELEGRAM_BOT_DEEP_LINK_BASE = `https://t.me/${TELEGRAM_BOT_USERNAME}`
|
|
|
|
|
|
|
|
|
|
function telegramBotDeepLink(code: string): string {
|
|
|
|
|
return `${TELEGRAM_BOT_DEEP_LINK_BASE}?start=${code}`
|
|
|
|
|
}
|
2025-12-02 23:17:15 +00:00
|
|
|
|
|
|
|
|
export default function LoginPage() {
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
const { login } = useAuthStore()
|
|
|
|
|
const [code, setCode] = useState('')
|
2025-12-11 19:49:08 +00:00
|
|
|
const [codeStatus, setCodeStatus] = useState<CodeStatusResponse | null>(null)
|
|
|
|
|
const [countdown, setCountdown] = useState<number>(0)
|
2025-12-11 19:57:55 +00:00
|
|
|
const [isVerifying, setIsVerifying] = useState(false)
|
2025-12-11 19:49:08 +00:00
|
|
|
const statusPollIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
|
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
2025-12-02 23:17:15 +00:00
|
|
|
|
|
|
|
|
// Request code mutation
|
|
|
|
|
const requestCodeMutation = useMutation({
|
|
|
|
|
mutationFn: () => authApi.requestCode(),
|
|
|
|
|
onSuccess: (data) => {
|
2025-12-11 19:49:08 +00:00
|
|
|
if (data.success && data.code) {
|
|
|
|
|
setCode(data.code)
|
|
|
|
|
setCodeStatus({
|
|
|
|
|
success: true,
|
|
|
|
|
code: data.code,
|
|
|
|
|
status: data.status || 'pending',
|
|
|
|
|
expiresAt: data.expiresAt,
|
|
|
|
|
remainingSeconds: data.remainingSeconds || 600,
|
|
|
|
|
isClaimed: false,
|
|
|
|
|
isUsed: false,
|
|
|
|
|
})
|
|
|
|
|
toast.success('Code generated! Send it to the Telegram bot.')
|
|
|
|
|
|
|
|
|
|
// Open Telegram bot
|
|
|
|
|
window.open(telegramBotDeepLink(data.code), '_blank')
|
|
|
|
|
|
|
|
|
|
// Start polling for status
|
|
|
|
|
startStatusPolling(data.code)
|
|
|
|
|
startCountdown(data.remainingSeconds || 600)
|
2025-12-02 23:17:15 +00:00
|
|
|
} else {
|
2025-12-11 19:49:08 +00:00
|
|
|
toast.error(data.message || 'Failed to generate code')
|
2025-12-02 23:17:15 +00:00
|
|
|
}
|
|
|
|
|
},
|
2025-12-11 19:49:08 +00:00
|
|
|
onError: (error: unknown) => {
|
|
|
|
|
const errorMessage =
|
|
|
|
|
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
|
|
|
|
(error as { message?: string })?.message ||
|
|
|
|
|
'Failed to generate code'
|
|
|
|
|
toast.error(errorMessage)
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Verify code mutation
|
|
|
|
|
const verifyCodeMutation = useMutation({
|
|
|
|
|
mutationFn: (code: string) => authApi.verifyCode(code),
|
2025-12-11 19:49:08 +00:00
|
|
|
onSuccess: (data) => {
|
2025-12-11 19:57:55 +00:00
|
|
|
console.log('Verify code response:', data)
|
|
|
|
|
|
2025-12-11 19:49:08 +00:00
|
|
|
// Check if response has success field and it's false
|
|
|
|
|
if (data.success === false) {
|
2025-12-11 19:57:55 +00:00
|
|
|
const errorMsg = data.message || 'Verification failed'
|
|
|
|
|
console.error('Verification failed:', errorMsg)
|
|
|
|
|
toast.error(errorMsg)
|
|
|
|
|
setIsVerifying(false)
|
|
|
|
|
// Don't restart polling if verification failed - user needs to generate new code
|
2025-12-11 19:49:08 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate response structure
|
|
|
|
|
if (!data.token || !data.user) {
|
2025-12-11 19:57:55 +00:00
|
|
|
console.error('Invalid response structure:', data)
|
2025-12-11 19:49:08 +00:00
|
|
|
toast.error('Invalid response from server')
|
2025-12-11 19:57:55 +00:00
|
|
|
setIsVerifying(false)
|
|
|
|
|
// Don't restart polling - this is a server error
|
2025-12-11 19:49:08 +00:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Stop polling
|
|
|
|
|
stopStatusPolling()
|
|
|
|
|
stopCountdown()
|
2025-12-11 19:57:55 +00:00
|
|
|
setIsVerifying(false)
|
2025-12-11 19:49:08 +00:00
|
|
|
|
2025-12-11 19:57:55 +00:00
|
|
|
console.log('Login successful, setting token and user')
|
2025-12-02 23:17:15 +00:00
|
|
|
login(data.token, data.user)
|
|
|
|
|
toast.success('Welcome back!')
|
|
|
|
|
navigate('/')
|
|
|
|
|
},
|
2025-12-11 19:49:08 +00:00
|
|
|
onError: (error: unknown) => {
|
2025-12-11 19:57:55 +00:00
|
|
|
console.error('Verify code error:', error)
|
|
|
|
|
setIsVerifying(false)
|
|
|
|
|
const errorResponse = error as { response?: { data?: { message?: string }; status?: number } }
|
2025-12-11 19:49:08 +00:00
|
|
|
const errorMessage =
|
2025-12-11 19:57:55 +00:00
|
|
|
errorResponse?.response?.data?.message ||
|
2025-12-11 19:49:08 +00:00
|
|
|
(error as { message?: string })?.message ||
|
|
|
|
|
'Invalid code'
|
2025-12-11 19:57:55 +00:00
|
|
|
|
|
|
|
|
// If 403, it means code was claimed but user is not admin
|
|
|
|
|
if (errorResponse?.response?.status === 403) {
|
|
|
|
|
toast.error('Access denied: You are not an admin. Code was claimed but verification failed.')
|
|
|
|
|
stopStatusPolling()
|
|
|
|
|
stopCountdown()
|
|
|
|
|
} else {
|
|
|
|
|
toast.error(errorMessage)
|
|
|
|
|
// Restart polling in case of other errors
|
|
|
|
|
if (code) {
|
|
|
|
|
startStatusPolling(code)
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-02 23:17:15 +00:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2025-12-11 19:49:08 +00:00
|
|
|
const startStatusPolling = (code: string) => {
|
|
|
|
|
stopStatusPolling()
|
|
|
|
|
|
|
|
|
|
// Poll immediately
|
|
|
|
|
checkCodeStatus(code)
|
|
|
|
|
|
|
|
|
|
// Then poll every 3 seconds
|
|
|
|
|
statusPollIntervalRef.current = setInterval(() => {
|
|
|
|
|
checkCodeStatus(code)
|
|
|
|
|
}, 3000)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stopStatusPolling = () => {
|
|
|
|
|
if (statusPollIntervalRef.current) {
|
|
|
|
|
clearInterval(statusPollIntervalRef.current)
|
|
|
|
|
statusPollIntervalRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const checkCodeStatus = async (codeToCheck: string) => {
|
|
|
|
|
try {
|
|
|
|
|
const status = await authApi.getCodeStatus(codeToCheck)
|
2025-12-11 19:57:55 +00:00
|
|
|
|
|
|
|
|
// Check if response is successful
|
|
|
|
|
if (!status.success) {
|
|
|
|
|
console.error('Code status check failed:', status.message || status.error)
|
|
|
|
|
if (status.error === 'NotFound') {
|
|
|
|
|
stopStatusPolling()
|
|
|
|
|
stopCountdown()
|
|
|
|
|
toast.error('Code not found. Please generate a new one.')
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-11 19:49:08 +00:00
|
|
|
setCodeStatus(status)
|
|
|
|
|
|
2025-12-11 19:57:55 +00:00
|
|
|
console.log('Code status:', status.status, 'isClaimed:', status.isClaimed, 'isUsed:', status.isUsed)
|
|
|
|
|
|
|
|
|
|
if (status.status === 'claimed' && !status.isUsed && !isVerifying) {
|
2025-12-11 19:49:08 +00:00
|
|
|
// Code is claimed, automatically verify and login
|
2025-12-11 19:57:55 +00:00
|
|
|
console.log('Code claimed, attempting login...')
|
|
|
|
|
setIsVerifying(true)
|
2025-12-11 19:49:08 +00:00
|
|
|
stopStatusPolling()
|
|
|
|
|
verifyCodeMutation.mutate(codeToCheck)
|
2025-12-11 19:57:55 +00:00
|
|
|
} else if (status.status === 'expired' || (status.remainingSeconds !== undefined && status.remainingSeconds <= 0)) {
|
2025-12-11 19:49:08 +00:00
|
|
|
stopStatusPolling()
|
|
|
|
|
stopCountdown()
|
|
|
|
|
toast.error('Code expired. Please generate a new one.')
|
|
|
|
|
} else if (status.isUsed) {
|
|
|
|
|
stopStatusPolling()
|
|
|
|
|
stopCountdown()
|
2025-12-11 19:57:55 +00:00
|
|
|
toast.info('Code already used')
|
2025-12-11 19:49:08 +00:00
|
|
|
} else {
|
|
|
|
|
// Update countdown
|
2025-12-11 19:57:55 +00:00
|
|
|
if (status.remainingSeconds !== undefined && status.remainingSeconds > 0) {
|
2025-12-11 19:49:08 +00:00
|
|
|
setCountdown(status.remainingSeconds)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to check code status:', error)
|
2025-12-11 19:57:55 +00:00
|
|
|
// Don't stop polling on network errors, just log
|
2025-12-11 19:49:08 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startCountdown = (initialSeconds: number) => {
|
|
|
|
|
stopCountdown()
|
|
|
|
|
setCountdown(initialSeconds)
|
|
|
|
|
|
|
|
|
|
countdownIntervalRef.current = setInterval(() => {
|
|
|
|
|
setCountdown((prev) => {
|
|
|
|
|
if (prev <= 1) {
|
|
|
|
|
stopCountdown()
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return prev - 1
|
|
|
|
|
})
|
|
|
|
|
}, 1000)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const stopCountdown = () => {
|
|
|
|
|
if (countdownIntervalRef.current) {
|
|
|
|
|
clearInterval(countdownIntervalRef.current)
|
|
|
|
|
countdownIntervalRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
stopStatusPolling()
|
|
|
|
|
stopCountdown()
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
2025-12-03 00:56:29 +00:00
|
|
|
const handleRequestCode = () => {
|
2025-12-02 23:17:15 +00:00
|
|
|
requestCodeMutation.mutate()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleVerifyCode = (e: React.FormEvent<HTMLFormElement>) => {
|
|
|
|
|
e.preventDefault()
|
2025-12-11 19:49:08 +00:00
|
|
|
const trimmedCode = code.trim()
|
|
|
|
|
|
|
|
|
|
if (!trimmedCode) {
|
2025-12-02 23:17:15 +00:00
|
|
|
toast.error('Please enter verification code')
|
|
|
|
|
return
|
|
|
|
|
}
|
2025-12-11 19:49:08 +00:00
|
|
|
|
|
|
|
|
// Validate code format: should be 6 digits
|
|
|
|
|
if (!/^\d{6}$/.test(trimmedCode)) {
|
|
|
|
|
toast.error('Code must be 6 digits')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
verifyCodeMutation.mutate(trimmedCode)
|
2025-12-02 23:17:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleBack = () => {
|
2025-12-11 19:49:08 +00:00
|
|
|
stopStatusPolling()
|
|
|
|
|
stopCountdown()
|
2025-12-11 19:57:55 +00:00
|
|
|
setIsVerifying(false)
|
2025-12-02 23:17:15 +00:00
|
|
|
setCode('')
|
2025-12-11 19:49:08 +00:00
|
|
|
setCodeStatus(null)
|
|
|
|
|
setCountdown(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const formatTime = (seconds: number): string => {
|
|
|
|
|
const mins = Math.floor(seconds / 60)
|
|
|
|
|
const secs = seconds % 60
|
|
|
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
2025-12-02 23:17:15 +00:00
|
|
|
}
|
|
|
|
|
|
2025-12-11 19:49:08 +00:00
|
|
|
const getStatusLabel = (status: string): string => {
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 'claimed':
|
|
|
|
|
return 'Code confirmed. Logging in...'
|
|
|
|
|
case 'used':
|
|
|
|
|
return 'Code already used'
|
|
|
|
|
case 'expired':
|
|
|
|
|
return 'Code expired'
|
|
|
|
|
case 'pending':
|
|
|
|
|
default:
|
|
|
|
|
return 'Waiting for code to be sent in bot...'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isCodeActive = codeStatus &&
|
|
|
|
|
codeStatus.status !== 'expired' &&
|
|
|
|
|
codeStatus.status !== 'used' &&
|
|
|
|
|
countdown > 0
|
|
|
|
|
|
2025-12-02 23:17:15 +00:00
|
|
|
return (
|
|
|
|
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
|
|
|
<Card className="w-full max-w-md">
|
|
|
|
|
<CardHeader className="space-y-1">
|
|
|
|
|
<CardTitle className="text-2xl font-bold text-center">
|
|
|
|
|
Mnemo Cards Admin
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription className="text-center">
|
2025-12-11 19:49:08 +00:00
|
|
|
{codeStatus
|
|
|
|
|
? 'Send the code to the Telegram bot to continue'
|
|
|
|
|
: 'Click the button below to generate a verification code'
|
2025-12-02 23:17:15 +00:00
|
|
|
}
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
2025-12-11 19:49:08 +00:00
|
|
|
{!codeStatus ? (
|
2025-12-02 23:17:15 +00:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
<Button
|
2025-12-03 00:56:29 +00:00
|
|
|
onClick={handleRequestCode}
|
2025-12-02 23:17:15 +00:00
|
|
|
className="w-full"
|
|
|
|
|
disabled={requestCodeMutation.isPending}
|
|
|
|
|
>
|
2025-12-11 19:49:08 +00:00
|
|
|
{requestCodeMutation.isPending ? 'Generating...' : 'Generate Code'}
|
2025-12-02 23:17:15 +00:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2025-12-11 19:49:08 +00:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* Code Display */}
|
2025-12-02 23:17:15 +00:00
|
|
|
<div className="space-y-2">
|
2025-12-11 19:49:08 +00:00
|
|
|
<Label>Verification Code</Label>
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<Input
|
|
|
|
|
type="text"
|
|
|
|
|
value={code}
|
|
|
|
|
readOnly
|
|
|
|
|
className="font-mono text-lg font-bold text-center"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
navigator.clipboard.writeText(code)
|
|
|
|
|
toast.success('Code copied to clipboard!')
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Copy
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-12-02 23:17:15 +00:00
|
|
|
</div>
|
2025-12-11 19:49:08 +00:00
|
|
|
|
|
|
|
|
{/* Status Card */}
|
|
|
|
|
{codeStatus && (
|
|
|
|
|
<div className="p-4 bg-gray-50 rounded-lg border">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-sm font-medium">Status:</span>
|
|
|
|
|
<span className={`text-sm font-semibold ${
|
2025-12-11 19:57:55 +00:00
|
|
|
codeStatus.status === 'claimed' ? (isVerifying ? 'text-blue-600' : 'text-green-600') :
|
2025-12-11 19:49:08 +00:00
|
|
|
codeStatus.status === 'expired' || codeStatus.status === 'used' ? 'text-red-600' :
|
|
|
|
|
'text-gray-600'
|
|
|
|
|
}`}>
|
2025-12-11 19:57:55 +00:00
|
|
|
{isVerifying ? 'Verifying and logging in...' : getStatusLabel(codeStatus.status)}
|
2025-12-11 19:49:08 +00:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{isCodeActive && (
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<span className="text-sm font-medium">Time remaining:</span>
|
|
|
|
|
<span className="text-sm font-mono font-semibold">
|
|
|
|
|
{formatTime(countdown)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-11 19:57:55 +00:00
|
|
|
{/* Debug info */}
|
|
|
|
|
{process.env.NODE_ENV === 'development' && (
|
|
|
|
|
<div className="mt-2 pt-2 border-t text-xs text-gray-500">
|
|
|
|
|
<div>Status: {codeStatus.status}</div>
|
|
|
|
|
<div>Claimed: {codeStatus.isClaimed ? 'Yes' : 'No'}</div>
|
|
|
|
|
<div>Used: {codeStatus.isUsed ? 'Yes' : 'No'}</div>
|
|
|
|
|
<div>Verifying: {isVerifying ? 'Yes' : 'No'}</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-12-11 19:49:08 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Manual Code Input (fallback) */}
|
|
|
|
|
<form onSubmit={handleVerifyCode} className="space-y-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="code">Or enter code manually</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="code"
|
|
|
|
|
type="text"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
pattern="[0-9]*"
|
|
|
|
|
placeholder="Enter 6-digit code"
|
|
|
|
|
value={code}
|
|
|
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
|
const value = e.target.value.replace(/\D/g, '')
|
|
|
|
|
setCode(value)
|
|
|
|
|
}}
|
|
|
|
|
maxLength={6}
|
|
|
|
|
disabled={verifyCodeMutation.isPending}
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex space-x-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={handleBack}
|
|
|
|
|
disabled={verifyCodeMutation.isPending}
|
|
|
|
|
className="flex-1"
|
|
|
|
|
>
|
|
|
|
|
Generate New Code
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={verifyCodeMutation.isPending || !code.trim()}
|
|
|
|
|
className="flex-1"
|
|
|
|
|
>
|
|
|
|
|
{verifyCodeMutation.isPending ? 'Verifying...' : 'Verify'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
{/* Open Bot Button */}
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => window.open(telegramBotDeepLink(code), '_blank')}
|
|
|
|
|
className="w-full"
|
|
|
|
|
>
|
|
|
|
|
Open Telegram Bot
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-12-02 23:17:15 +00:00
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|