login
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
339318b874
commit
a0b2d2584d
5 changed files with 415 additions and 120 deletions
|
|
@ -1,14 +1,20 @@
|
||||||
import { adminApiClient } from './client'
|
import { adminApiClient } from './client'
|
||||||
import type { AuthResponse } from '@/types/models'
|
import type { AuthResponse, RequestCodeResponse, CodeStatusResponse } from '@/types/models'
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
// Request authentication code to be sent to all admin Telegram accounts
|
// Request authentication code (creates web code)
|
||||||
requestCode: async (): Promise<{ success: boolean; message: string }> => {
|
requestCode: async (): Promise<RequestCodeResponse> => {
|
||||||
const response = await adminApiClient.post('/api/v2/admin/auth/request-code')
|
const response = await adminApiClient.post('/api/v2/admin/auth/request-code')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Verify authentication code
|
// Get code status
|
||||||
|
getCodeStatus: async (code: string): Promise<CodeStatusResponse> => {
|
||||||
|
const response = await adminApiClient.get(`/api/v2/admin/auth/code-status/${code}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify authentication code and login
|
||||||
verifyCode: async (code: string): Promise<AuthResponse> => {
|
verifyCode: async (code: string): Promise<AuthResponse> => {
|
||||||
const response = await adminApiClient.post('/api/v2/admin/auth/verify-code', {
|
const response = await adminApiClient.post('/api/v2/admin/auth/verify-code', {
|
||||||
code,
|
code,
|
||||||
|
|
@ -17,7 +23,7 @@ export const authApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get current admin info (for token validation)
|
// Get current admin info (for token validation)
|
||||||
getCurrentUser: async (): Promise<any> => {
|
getCurrentUser: async (): Promise<{ success: boolean; user?: any; message?: string }> => {
|
||||||
const response = await adminApiClient.get('/api/v2/admin/auth/me')
|
const response = await adminApiClient.get('/api/v2/admin/auth/me')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
@ -8,60 +8,223 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuthStore()
|
const { login } = useAuthStore()
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
const [step, setStep] = useState<'request' | 'verify'>('request')
|
const [codeStatus, setCodeStatus] = useState<CodeStatusResponse | null>(null)
|
||||||
|
const [countdown, setCountdown] = useState<number>(0)
|
||||||
|
const statusPollIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
// Request code mutation
|
// Request code mutation
|
||||||
const requestCodeMutation = useMutation({
|
const requestCodeMutation = useMutation({
|
||||||
mutationFn: () => authApi.requestCode(),
|
mutationFn: () => authApi.requestCode(),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.success) {
|
if (data.success && data.code) {
|
||||||
toast.success('Code sent to Telegram!')
|
setCode(data.code)
|
||||||
setStep('verify')
|
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)
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.message || 'Failed to send code')
|
toast.error(data.message || 'Failed to generate code')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: unknown) => {
|
||||||
toast.error(error.response?.data?.message || 'Failed to send code')
|
const errorMessage =
|
||||||
|
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
|
(error as { message?: string })?.message ||
|
||||||
|
'Failed to generate code'
|
||||||
|
toast.error(errorMessage)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify code mutation
|
// Verify code mutation
|
||||||
const verifyCodeMutation = useMutation({
|
const verifyCodeMutation = useMutation({
|
||||||
mutationFn: (code: string) => authApi.verifyCode(code),
|
mutationFn: (code: string) => authApi.verifyCode(code),
|
||||||
onSuccess: (data: any) => {
|
onSuccess: (data) => {
|
||||||
|
// Check if response has success field and it's false
|
||||||
|
if (data.success === false) {
|
||||||
|
toast.error(data.message || 'Verification failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate response structure
|
||||||
|
if (!data.token || !data.user) {
|
||||||
|
toast.error('Invalid response from server')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop polling
|
||||||
|
stopStatusPolling()
|
||||||
|
stopCountdown()
|
||||||
|
|
||||||
login(data.token, data.user)
|
login(data.token, data.user)
|
||||||
toast.success('Welcome back!')
|
toast.success('Welcome back!')
|
||||||
navigate('/')
|
navigate('/')
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: unknown) => {
|
||||||
toast.error(error.response?.data?.message || 'Invalid code')
|
const errorMessage =
|
||||||
|
(error as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
|
(error as { message?: string })?.message ||
|
||||||
|
'Invalid code'
|
||||||
|
toast.error(errorMessage)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
setCodeStatus(status)
|
||||||
|
|
||||||
|
if (status.status === 'claimed' && !status.isUsed) {
|
||||||
|
// Code is claimed, automatically verify and login
|
||||||
|
stopStatusPolling()
|
||||||
|
verifyCodeMutation.mutate(codeToCheck)
|
||||||
|
} else if (status.status === 'expired' || status.remainingSeconds <= 0) {
|
||||||
|
stopStatusPolling()
|
||||||
|
stopCountdown()
|
||||||
|
toast.error('Code expired. Please generate a new one.')
|
||||||
|
} else if (status.isUsed) {
|
||||||
|
stopStatusPolling()
|
||||||
|
stopCountdown()
|
||||||
|
} else {
|
||||||
|
// Update countdown
|
||||||
|
if (status.remainingSeconds > 0) {
|
||||||
|
setCountdown(status.remainingSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check code status:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleRequestCode = () => {
|
const handleRequestCode = () => {
|
||||||
requestCodeMutation.mutate()
|
requestCodeMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVerifyCode = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleVerifyCode = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!code.trim()) {
|
const trimmedCode = code.trim()
|
||||||
|
|
||||||
|
if (!trimmedCode) {
|
||||||
toast.error('Please enter verification code')
|
toast.error('Please enter verification code')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
verifyCodeMutation.mutate(code.trim())
|
|
||||||
|
// Validate code format: should be 6 digits
|
||||||
|
if (!/^\d{6}$/.test(trimmedCode)) {
|
||||||
|
toast.error('Code must be 6 digits')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyCodeMutation.mutate(trimmedCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
setStep('request')
|
stopStatusPolling()
|
||||||
|
stopCountdown()
|
||||||
setCode('')
|
setCode('')
|
||||||
|
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')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<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">
|
<Card className="w-full max-w-md">
|
||||||
|
|
@ -70,35 +233,93 @@ export default function LoginPage() {
|
||||||
Mnemo Cards Admin
|
Mnemo Cards Admin
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-center">
|
<CardDescription className="text-center">
|
||||||
{step === 'request'
|
{codeStatus
|
||||||
? 'Click the button below to receive a verification code'
|
? 'Send the code to the Telegram bot to continue'
|
||||||
: 'Enter the 6-digit code sent to admin Telegram accounts'
|
: 'Click the button below to generate a verification code'
|
||||||
}
|
}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{step === 'request' ? (
|
{!codeStatus ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRequestCode}
|
onClick={handleRequestCode}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={requestCodeMutation.isPending}
|
disabled={requestCodeMutation.isPending}
|
||||||
>
|
>
|
||||||
{requestCodeMutation.isPending ? 'Sending...' : 'Send Code'}
|
{requestCodeMutation.isPending ? 'Generating...' : 'Generate Code'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Code Display */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 ${
|
||||||
|
codeStatus.status === 'claimed' ? 'text-green-600' :
|
||||||
|
codeStatus.status === 'expired' || codeStatus.status === 'used' ? 'text-red-600' :
|
||||||
|
'text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{getStatusLabel(codeStatus.status)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Manual Code Input (fallback) */}
|
||||||
<form onSubmit={handleVerifyCode} className="space-y-4">
|
<form onSubmit={handleVerifyCode} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="code">Verification Code</Label>
|
<Label htmlFor="code">Or enter code manually</Label>
|
||||||
<Input
|
<Input
|
||||||
id="code"
|
id="code"
|
||||||
type="text"
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
placeholder="Enter 6-digit code"
|
placeholder="Enter 6-digit code"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value.replace(/\D/g, '')
|
||||||
|
setCode(value)
|
||||||
|
}}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
disabled={verifyCodeMutation.isPending}
|
disabled={verifyCodeMutation.isPending}
|
||||||
|
autoComplete="one-time-code"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
|
|
@ -109,17 +330,28 @@ export default function LoginPage() {
|
||||||
disabled={verifyCodeMutation.isPending}
|
disabled={verifyCodeMutation.isPending}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Request New Code
|
Generate New Code
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={verifyCodeMutation.isPending}
|
disabled={verifyCodeMutation.isPending || !code.trim()}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{verifyCodeMutation.isPending ? 'Verifying...' : 'Verify'}
|
{verifyCodeMutation.isPending ? 'Verifying...' : 'Verify'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Open Bot Button */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => window.open(telegramBotDeepLink(code), '_blank')}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Open Telegram Bot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,33 @@ export interface AuthRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
|
success?: boolean
|
||||||
token: string
|
token: string
|
||||||
user: UserDto
|
user: UserDto
|
||||||
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodeRequest {
|
export interface CodeRequest {
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestCodeResponse {
|
||||||
|
success: boolean
|
||||||
|
code?: string
|
||||||
|
status?: string
|
||||||
|
expiresAt?: string
|
||||||
|
remainingSeconds?: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeStatusResponse {
|
||||||
|
success: boolean
|
||||||
|
code: string
|
||||||
|
status: string
|
||||||
|
expiresAt?: string
|
||||||
|
remainingSeconds: number
|
||||||
|
isClaimed: boolean
|
||||||
|
isUsed: boolean
|
||||||
|
error?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,12 @@ ENV PORT=3000 \
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Healthcheck - проверка доступности сервера
|
# Healthcheck - проверка доступности сервера
|
||||||
# Increased start-period to 90s to allow server initialization (Isar DB, cron jobs, etc.)
|
# Increased start-period to 45s to allow server initialization (Isar DB, cron jobs, etc.)
|
||||||
# Isar может долго инициализироваться при первом запуске или при большом объеме данных
|
# Isar может долго инициализироваться при первом запуске или при большом объеме данных
|
||||||
# Используем 127.0.0.1 для healthcheck (внутри контейнера работает даже если сервер слушает на 0.0.0.0)
|
# Используем 127.0.0.1 для healthcheck (внутри контейнера работает даже если сервер слушает на 0.0.0.0)
|
||||||
# Сервер работает только по HTTP (HTTPS обрабатывается на уровне reverse proxy в Coolify)
|
# Сервер работает только по HTTP (HTTPS обрабатывается на уровне reverse proxy в Coolify)
|
||||||
# Пробуем curl, если не работает - используем wget как fallback
|
# Пробуем curl, если не работает - используем wget как fallback
|
||||||
HEALTHCHECK --interval=15s --timeout=10s --start-period=90s --retries=3 \
|
HEALTHCHECK --interval=15s --timeout=10s --start-period=45s --retries=3 \
|
||||||
CMD curl -f -sS --max-time 8 --connect-timeout 3 http://127.0.0.1:${PORT:-3000}/health > /dev/null 2>&1 || \
|
CMD curl -f -sS --max-time 8 --connect-timeout 3 http://127.0.0.1:${PORT:-3000}/health > /dev/null 2>&1 || \
|
||||||
wget --quiet --tries=1 --timeout=8 --spider http://127.0.0.1:${PORT:-3000}/health || exit 1
|
wget --quiet --tries=1 --timeout=8 --spider http://127.0.0.1:${PORT:-3000}/health || exit 1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||||
import 'package:mnemo_cards_backend/auth/telegram_auth_code_service.dart';
|
import 'package:mnemo_cards_backend/auth/telegram_auth_code_service.dart';
|
||||||
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
|
||||||
import 'package:mnemo_cards_backend/user/admin_ids_service.dart';
|
import 'package:mnemo_cards_backend/user/admin_ids_service.dart';
|
||||||
import 'package:mnemo_cards_backend/user/telegram.dart';
|
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
@ -18,8 +16,12 @@ part 'admin_auth_api_v2.g.dart';
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class AdminAuthApiV2 {
|
class AdminAuthApiV2 {
|
||||||
final TelegramAuthCodeService _telegramAuthCodeService;
|
final TelegramAuthCodeService _telegramAuthCodeService;
|
||||||
|
final UserManager _userManager;
|
||||||
|
|
||||||
AdminAuthApiV2(this._telegramAuthCodeService);
|
AdminAuthApiV2(
|
||||||
|
this._telegramAuthCodeService,
|
||||||
|
this._userManager,
|
||||||
|
);
|
||||||
|
|
||||||
Response _json(
|
Response _json(
|
||||||
Object? data, {
|
Object? data, {
|
||||||
|
|
@ -37,65 +39,28 @@ class AdminAuthApiV2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/v2/admin/auth/request-code
|
/// POST /api/v2/admin/auth/request-code
|
||||||
/// Request authentication code to be sent to all admin Telegram accounts
|
/// Generate a web authentication code for admin login
|
||||||
|
/// Similar to /api/v2/auth/telegram/web-code but for admin panel
|
||||||
@Route.post('/admin/auth/request-code')
|
@Route.post('/admin/auth/request-code')
|
||||||
Future<Response> requestCode(Request request) async {
|
Future<Response> requestCode(Request request) async {
|
||||||
try {
|
try {
|
||||||
// Get all admin IDs
|
// Create web code (same as regular web flow)
|
||||||
final adminIds = await _getAdminIds();
|
final code = _telegramAuthCodeService.createWebCode();
|
||||||
if (adminIds.isEmpty) {
|
final status = _telegramAuthCodeService.getCodeStatus(code);
|
||||||
return _json(
|
|
||||||
{
|
|
||||||
'success': false,
|
|
||||||
'message': 'No admin accounts configured',
|
|
||||||
},
|
|
||||||
statusCode: 500,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate code for the first admin (they can share it among themselves)
|
|
||||||
final code = _telegramAuthCodeService.generateCodeForTelegramUser(
|
|
||||||
telegramUserId: adminIds.first,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send code to all admins
|
|
||||||
final message = '''
|
|
||||||
🔐 <b>Mnemo Cards Admin Login</b>
|
|
||||||
|
|
||||||
Verification code: <code>$code</code>
|
|
||||||
|
|
||||||
This code will expire in 10 minutes.
|
|
||||||
|
|
||||||
Use this code to access the admin panel at admin.mnemo-cards.online
|
|
||||||
''';
|
|
||||||
|
|
||||||
bool sentToAtLeastOne = false;
|
|
||||||
for (final adminId in adminIds) {
|
|
||||||
final sent = await TelegramUtils.sendMessageToAdmin(adminId, message);
|
|
||||||
if (sent) {
|
|
||||||
sentToAtLeastOne = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sentToAtLeastOne) {
|
|
||||||
return _json(
|
|
||||||
{
|
|
||||||
'success': false,
|
|
||||||
'message': 'Failed to send verification code to any admin',
|
|
||||||
},
|
|
||||||
statusCode: 500,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _json({
|
return _json({
|
||||||
'success': true,
|
'success': true,
|
||||||
'message': 'Verification code sent to admin Telegram accounts',
|
'code': code,
|
||||||
|
'status': status?.state.name ?? 'pending',
|
||||||
|
'expiresAt': status?.expiresAt?.toIso8601String(),
|
||||||
|
'remainingSeconds': status?.remainingSeconds,
|
||||||
|
'message': 'Code generated. Send it to the Telegram bot to claim.',
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return _json(
|
return _json(
|
||||||
{
|
{
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': 'Internal server error',
|
'message': 'Internal server error: $e',
|
||||||
},
|
},
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
);
|
);
|
||||||
|
|
@ -104,6 +69,7 @@ Use this code to access the admin panel at admin.mnemo-cards.online
|
||||||
|
|
||||||
/// POST /api/v2/admin/auth/verify-code
|
/// POST /api/v2/admin/auth/verify-code
|
||||||
/// Verify authentication code and return JWT token
|
/// Verify authentication code and return JWT token
|
||||||
|
/// Code must be claimed by an admin Telegram user
|
||||||
@Route.post('/admin/auth/verify-code')
|
@Route.post('/admin/auth/verify-code')
|
||||||
Future<Response> verifyCode(Request request) async {
|
Future<Response> verifyCode(Request request) async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -131,7 +97,7 @@ Use this code to access the admin panel at admin.mnemo-cards.online
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify code
|
// Verify code and get Telegram user info
|
||||||
final authCode = await _telegramAuthCodeService.verifyCode(code);
|
final authCode = await _telegramAuthCodeService.verifyCode(code);
|
||||||
if (authCode == null) {
|
if (authCode == null) {
|
||||||
return _json(
|
return _json(
|
||||||
|
|
@ -143,20 +109,51 @@ Use this code to access the admin panel at admin.mnemo-cards.online
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user from database
|
// Check if the code was claimed by an admin
|
||||||
// Note: UserModel doesn't have telegramUserId field, so we need to find user differently
|
final adminIds = await _getAdminIds();
|
||||||
// For now, we'll look for admin users (assuming admin field exists)
|
if (adminIds.isEmpty) {
|
||||||
final allUsers = await backend_main.isar
|
|
||||||
.txn(() async => backend_main.isar.userModels.where().findAll());
|
|
||||||
final user = allUsers.where((u) => u.admin).firstOrNull;
|
|
||||||
|
|
||||||
if (user == null) {
|
|
||||||
return _json(
|
return _json(
|
||||||
{
|
{
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': 'Admin user not found',
|
'message': 'No admin accounts configured',
|
||||||
},
|
},
|
||||||
statusCode: 404,
|
statusCode: 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the telegramUserId who claimed the code is an admin
|
||||||
|
if (!adminIds.contains(authCode.telegramUserId)) {
|
||||||
|
return _json(
|
||||||
|
{
|
||||||
|
'success': false,
|
||||||
|
'message': 'Access denied: code was not claimed by an admin',
|
||||||
|
},
|
||||||
|
statusCode: 403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create admin user based on telegram user ID
|
||||||
|
var (user, _) = await _userManager.createOrGetUser(
|
||||||
|
externalId: authCode.telegramUserId,
|
||||||
|
email: '',
|
||||||
|
name: authCode.telegramUsername ??
|
||||||
|
(authCode.firstName != null
|
||||||
|
? (authCode.lastName != null
|
||||||
|
? '${authCode.firstName} ${authCode.lastName}'
|
||||||
|
: authCode.firstName!)
|
||||||
|
: 'Admin User'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: Admin users should already exist in the system with admin=true
|
||||||
|
// If a new user is created, they won't have admin privileges
|
||||||
|
// Admin privileges must be granted manually in the database
|
||||||
|
if (!user.admin) {
|
||||||
|
return _json(
|
||||||
|
{
|
||||||
|
'success': false,
|
||||||
|
'message': 'User account found but does not have admin privileges. Please contact system administrator.',
|
||||||
|
},
|
||||||
|
statusCode: 403,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +174,44 @@ Use this code to access the admin panel at admin.mnemo-cards.online
|
||||||
return _json(
|
return _json(
|
||||||
{
|
{
|
||||||
'success': false,
|
'success': false,
|
||||||
'message': 'Internal server error',
|
'message': 'Internal server error: $e',
|
||||||
|
},
|
||||||
|
statusCode: 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v2/admin/auth/code-status/<code>
|
||||||
|
/// Get current status for an admin authentication code
|
||||||
|
@Route.get('/admin/auth/code-status/<code>')
|
||||||
|
Future<Response> getCodeStatus(Request request, String code) async {
|
||||||
|
try {
|
||||||
|
final status = _telegramAuthCodeService.getCodeStatus(code);
|
||||||
|
if (status == null) {
|
||||||
|
return _json(
|
||||||
|
{
|
||||||
|
'success': false,
|
||||||
|
'error': 'NotFound',
|
||||||
|
'message': 'Code not found',
|
||||||
|
},
|
||||||
|
statusCode: 404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _json({
|
||||||
|
'success': true,
|
||||||
|
'code': code,
|
||||||
|
'status': status.state.name,
|
||||||
|
'expiresAt': status.expiresAt?.toIso8601String(),
|
||||||
|
'remainingSeconds': status.remainingSeconds,
|
||||||
|
'isClaimed': status.isClaimed,
|
||||||
|
'isUsed': status.isUsed,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return _json(
|
||||||
|
{
|
||||||
|
'success': false,
|
||||||
|
'message': 'Internal server error: $e',
|
||||||
},
|
},
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue