diff --git a/admin/src/api/auth.ts b/admin/src/api/auth.ts index 52270f3..b217674 100644 --- a/admin/src/api/auth.ts +++ b/admin/src/api/auth.ts @@ -55,4 +55,13 @@ export const authApi = { }) return response.data }, + + // Login with username and password + loginWithPassword: async (username: string, password: string): Promise => { + const response = await adminApiClient.post('/api/admin/auth/login', { + username, + password, + }) + return response.data + }, } diff --git a/admin/src/pages/LoginPage.tsx b/admin/src/pages/LoginPage.tsx index bd88a37..16464ca 100644 --- a/admin/src/pages/LoginPage.tsx +++ b/admin/src/pages/LoginPage.tsx @@ -17,9 +17,14 @@ function telegramBotDeepLink(code: string): string { return `${TELEGRAM_BOT_DEEP_LINK_BASE}?start=${code}` } +type AuthMethod = 'telegram' | 'password' + export default function LoginPage() { const navigate = useNavigate() const { login } = useAuthStore() + const [authMethod, setAuthMethod] = useState('telegram') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') const [code, setCode] = useState('') const [codeStatus, setCodeStatus] = useState(null) const [countdown, setCountdown] = useState(0) @@ -64,6 +69,58 @@ export default function LoginPage() { }, }) + // Login with password mutation + const loginWithPasswordMutation = useMutation({ + mutationFn: ({ username, password }: { username: string; password: string }) => + authApi.loginWithPassword(username, password), + onSuccess: (data) => { + console.log('Login with password response:', data) + + if (!data.token || !data.user) { + console.error('Invalid response structure:', data) + toast.error('Invalid response from server') + return + } + + console.log('Login successful, setting token and user') + console.log('Token received:', data.token ? `Token exists, length: ${data.token.length}` : 'NO TOKEN!') + console.log('RefreshToken received:', data.refreshToken ? `Token exists, length: ${data.refreshToken.length}` : 'NO REFRESH TOKEN!') + console.log('ExpiresIn:', data.expiresIn) + console.log('User received:', data.user) + + // Save token with refresh token and expiration + login(data.token, data.refreshToken ?? null, data.expiresIn ?? null, data.user) + + // Verify token was saved + const savedToken = localStorage.getItem('admin_token') + const savedRefreshToken = localStorage.getItem('admin_refresh_token') + console.log('Token saved verification:', savedToken ? `Token exists, length: ${savedToken.length}` : 'Token NOT saved!') + console.log('RefreshToken saved verification:', savedRefreshToken ? `Token exists, length: ${savedRefreshToken.length}` : 'RefreshToken NOT saved!') + + if (!savedToken) { + console.error('CRITICAL: Token was not saved to localStorage!') + toast.error('Failed to save authentication token') + return + } + + toast.success('Welcome back!') + + // Small delay to ensure token is available for subsequent requests + setTimeout(() => { + navigate('/') + }, 100) + }, + onError: (error: unknown) => { + console.error('Login with password error:', error) + const errorResponse = error as { response?: { data?: { message?: string }; status?: number } } + const errorMessage = + errorResponse?.response?.data?.message || + (error as { message?: string })?.message || + 'Invalid credentials' + toast.error(errorMessage) + }, + }) + // Verify code mutation const verifyCodeMutation = useMutation({ mutationFn: (code: string) => authApi.verifyCode(code), @@ -270,6 +327,44 @@ export default function LoginPage() { setCountdown(0) } + const handlePasswordLogin = (e: React.FormEvent) => { + e.preventDefault() + const trimmedUsername = username.trim() + const trimmedPassword = password.trim() + + if (!trimmedUsername) { + toast.error('Please enter username') + return + } + + if (!trimmedPassword) { + toast.error('Please enter password') + return + } + + loginWithPasswordMutation.mutate({ + username: trimmedUsername, + password: trimmedPassword, + }) + } + + const switchAuthMethod = (method: AuthMethod) => { + setAuthMethod(method) + // Reset states when switching + if (method === 'telegram') { + setUsername('') + setPassword('') + handleBack() + } else { + setCode('') + setCodeStatus(null) + setCountdown(0) + stopStatusPolling() + stopCountdown() + setIsVerifying(false) + } + } + const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60) const secs = seconds % 60 @@ -303,14 +398,77 @@ export default function LoginPage() { Sto k Odnomu Admin - {codeStatus + {authMethod === 'password' + ? 'Enter your credentials to login' + : codeStatus ? 'Send the code to the Telegram bot to continue' : 'Click the button below to generate a verification code' } - {!codeStatus ? ( + {/* Auth method switcher */} +
+ + +
+ + {authMethod === 'password' ? ( +
+
+ + ) => { + setUsername(e.target.value) + }} + disabled={loginWithPasswordMutation.isPending} + autoComplete="username" + autoFocus + /> +
+
+ + ) => { + setPassword(e.target.value) + }} + disabled={loginWithPasswordMutation.isPending} + autoComplete="current-password" + /> +
+ +
+ ) : ( + <> + {!codeStatus ? (
+ )} + )}
diff --git a/backend/src/admin/auth/admin-auth.controller.ts b/backend/src/admin/auth/admin-auth.controller.ts index 837f92d..ea3e3d3 100644 --- a/backend/src/admin/auth/admin-auth.controller.ts +++ b/backend/src/admin/auth/admin-auth.controller.ts @@ -10,6 +10,7 @@ import { import { AdminAuthService } from './admin-auth.service'; import { VerifyCodeDto } from './dto/verify-code.dto'; import { RefreshTokenDto } from './dto/refresh-token.dto'; +import { LoginDto } from './dto/login.dto'; import { AdminAuthGuard } from '../guards/admin-auth.guard'; import { AdminGuard } from '../guards/admin.guard'; @@ -32,6 +33,14 @@ export class AdminAuthController { return this.adminAuthService.verifyCode(verifyCodeDto.code); } + @Post('login') + async login(@Body() loginDto: LoginDto) { + return this.adminAuthService.loginWithPassword( + loginDto.username, + loginDto.password, + ); + } + @Post('refresh') async refreshToken(@Body() refreshTokenDto: RefreshTokenDto) { return this.adminAuthService.refreshAccessToken( diff --git a/backend/src/admin/auth/admin-auth.service.ts b/backend/src/admin/auth/admin-auth.service.ts index f84e5c1..a9435f8 100644 --- a/backend/src/admin/auth/admin-auth.service.ts +++ b/backend/src/admin/auth/admin-auth.service.ts @@ -325,4 +325,88 @@ export class AdminAuthService implements OnModuleInit { getAdminIdsList(): string[] { return AdminChecker.getAdminIds(); } + + /** + * Login with username and password from environment variables + */ + async loginWithPassword(username: string, password: string): Promise<{ + token: string; + refreshToken: string; + expiresIn: number; + user: any; + }> { + const adminUsername = this.configService.get('ADMIN_USERNAME'); + const adminPassword = this.configService.get('ADMIN_PASSWORD'); + + if (!adminUsername || !adminPassword) { + throw new UnauthorizedException('Admin credentials not configured'); + } + + if (username !== adminUsername || password !== adminPassword) { + throw new UnauthorizedException('Invalid credentials'); + } + + // Find or create admin user for password login + // Use a special telegramId format to identify password-based admin + const passwordAdminTelegramId = 'password_admin'; + + let user = await this.prisma.user.findUnique({ + where: { telegramId: passwordAdminTelegramId }, + select: { + id: true, + email: true, + name: true, + role: true, + telegramId: true, + }, + }); + + if (!user) { + // Create admin user for password login + user = await this.prisma.user.create({ + data: { + telegramId: passwordAdminTelegramId, + role: 'ADMIN', + name: 'Admin', + email: adminUsername, + }, + select: { + id: true, + email: true, + name: true, + role: true, + telegramId: true, + }, + }); + } else if (user.role !== 'ADMIN') { + // Upgrade to admin if needed + await this.prisma.user.update({ + where: { id: user.id }, + data: { role: 'ADMIN' }, + }); + user.role = 'ADMIN'; + } + + // Generate tokens + const expiresIn = 3600; // 1 hour + const secret = this.configService.get('ADMIN_JWT_SECRET') || + this.configService.get('JWT_SECRET'); + + const token = this.jwtService.sign( + { sub: user.id, email: user.email, role: user.role }, + { secret, expiresIn }, + ); + + const refreshToken = this.jwtService.sign( + { sub: user.id, type: 'refresh' }, + { secret, expiresIn: 60 * 60 * 24 * 7 }, // 7 days + ); + + return { + token, + refreshToken, + expiresIn, + user, + }; + } } diff --git a/backend/src/admin/auth/dto/login.dto.ts b/backend/src/admin/auth/dto/login.dto.ts new file mode 100644 index 0000000..f43c3e8 --- /dev/null +++ b/backend/src/admin/auth/dto/login.dto.ts @@ -0,0 +1,12 @@ +import { IsString, MinLength } from 'class-validator'; + +export class LoginDto { + @IsString() + @MinLength(1) + username: string; + + @IsString() + @MinLength(1) + password: string; +} +