174 lines
7.0 KiB
TypeScript
Executable File
174 lines
7.0 KiB
TypeScript
Executable File
import React, { useState, useEffect, useRef } from 'react';
|
||
import { backendApi } from '../services/apiClient';
|
||
|
||
interface LoginPageProps {
|
||
onLoginSuccess: (user: { id: string; name: string; role: string; avatar?: string | null }, token: string) => void;
|
||
apiError: string | null;
|
||
loading: boolean;
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
turnstile?: {
|
||
render: (container: string | HTMLElement, options: { sitekey: string; callback?: (token: string) => void; 'expired-callback'?: () => void }) => string;
|
||
reset: (widgetId?: string) => void;
|
||
remove: (widgetId?: string) => void;
|
||
};
|
||
}
|
||
}
|
||
|
||
export const LoginPage: React.FC<LoginPageProps> = ({ onLoginSuccess, apiError, loading: externalLoading }) => {
|
||
const [login, setLogin] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [captchaToken, setCaptchaToken] = useState<string | undefined>(undefined);
|
||
const [localError, setLocalError] = useState<string | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [turnstileSiteKey, setTurnstileSiteKey] = useState<string | null>(null);
|
||
const captchaContainerRef = useRef<HTMLDivElement>(null);
|
||
const turnstileWidgetIdRef = useRef<string | null>(null);
|
||
const loading = externalLoading || submitting;
|
||
|
||
useEffect(() => {
|
||
backendApi.getCaptchaSiteKey().then((r) => setTurnstileSiteKey(r.siteKey || null)).catch(() => setTurnstileSiteKey(null));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!turnstileSiteKey || !captchaContainerRef.current) return;
|
||
const renderTurnstile = () => {
|
||
if (captchaContainerRef.current && window.turnstile && !turnstileWidgetIdRef.current) {
|
||
turnstileWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, {
|
||
sitekey: turnstileSiteKey,
|
||
callback: (token) => setCaptchaToken(token),
|
||
'expired-callback': () => setCaptchaToken(undefined),
|
||
});
|
||
}
|
||
};
|
||
if (window.turnstile) {
|
||
renderTurnstile();
|
||
} else {
|
||
const t = setInterval(() => {
|
||
if (window.turnstile) {
|
||
clearInterval(t);
|
||
renderTurnstile();
|
||
}
|
||
}, 100);
|
||
return () => clearInterval(t);
|
||
}
|
||
return () => {
|
||
if (turnstileWidgetIdRef.current != null && window.turnstile?.remove) {
|
||
window.turnstile.remove(turnstileWidgetIdRef.current);
|
||
turnstileWidgetIdRef.current = null;
|
||
}
|
||
};
|
||
}, [turnstileSiteKey]);
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setLocalError(null);
|
||
if (!login.trim()) {
|
||
setLocalError('Введите логин');
|
||
return;
|
||
}
|
||
if (!password) {
|
||
setLocalError('Введите пароль');
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
const { setAuthToken } = await import('../services/apiClient');
|
||
const res = await backendApi.login({
|
||
login: login.trim(),
|
||
password,
|
||
captchaToken: captchaToken || undefined,
|
||
});
|
||
setAuthToken(res.token);
|
||
onLoginSuccess(res.user, res.token);
|
||
} catch (err: any) {
|
||
setLocalError(err?.message || 'Ошибка входа');
|
||
if (turnstileSiteKey && window.turnstile?.reset && turnstileWidgetIdRef.current != null) {
|
||
window.turnstile.reset(turnstileWidgetIdRef.current);
|
||
setCaptchaToken(undefined);
|
||
}
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const error = localError || apiError;
|
||
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-slate-100 px-4" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||
<div className="w-full max-w-md">
|
||
<div className="bg-white rounded-[2.5rem] shadow-2xl border border-slate-200 p-8 md:p-10 animate-fade-in">
|
||
<div className="flex flex-col items-center mb-8">
|
||
<div className="w-14 h-14 rounded-2xl bg-primary-500 flex items-center justify-center mb-4 shadow-lg shadow-primary-900/20">
|
||
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||
</svg>
|
||
</div>
|
||
<h1 className="text-xl font-bold text-slate-900 text-center leading-tight">
|
||
Центр управления<br />домами
|
||
</h1>
|
||
<p className="text-xs text-slate-500 mt-2">Войдите в свой аккаунт</p>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-5">
|
||
{error && (
|
||
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label htmlFor="login" className="block text-xs font-bold text-slate-700 mb-1.5 uppercase tracking-wider">
|
||
Логин
|
||
</label>
|
||
<input
|
||
id="login"
|
||
type="text"
|
||
value={login}
|
||
onChange={(e) => setLogin(e.target.value)}
|
||
autoComplete="username"
|
||
placeholder="Введите логин"
|
||
className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
||
disabled={loading}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="password" className="block text-xs font-bold text-slate-700 mb-1.5 uppercase tracking-wider">
|
||
Пароль
|
||
</label>
|
||
<input
|
||
id="password"
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
autoComplete="current-password"
|
||
placeholder="Введите пароль"
|
||
className="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-slate-800 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all"
|
||
disabled={loading}
|
||
/>
|
||
</div>
|
||
|
||
{turnstileSiteKey && (
|
||
<div ref={captchaContainerRef} className="min-h-[65px] flex items-center justify-center" />
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading}
|
||
className="w-full py-3.5 px-4 bg-primary-600 hover:bg-primary-700 text-white text-sm font-bold rounded-xl shadow-lg shadow-primary-900/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{loading ? 'Вход...' : 'Войти'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<p className="text-center text-xs text-slate-400 mt-6">
|
||
Используйте учётные данные, выданные администратором
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|