250 lines
9.3 KiB
TypeScript
250 lines
9.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Lock } from 'lucide-react';
|
|
import { getLoginConfig, login } from '../dataService';
|
|
|
|
declare global {
|
|
interface Window {
|
|
turnstile?: {
|
|
render: (
|
|
container: HTMLElement,
|
|
options: {
|
|
sitekey: string;
|
|
callback: (token: string) => void;
|
|
'expired-callback': () => void;
|
|
'error-callback': () => void;
|
|
theme: 'dark' | 'light' | 'auto';
|
|
}
|
|
) => string;
|
|
reset: (widgetId?: string) => void;
|
|
};
|
|
}
|
|
}
|
|
|
|
const turnstileScriptId = 'turnstile-api-script';
|
|
const securityConfigLoadMessage = 'Não foi possível carregar as configurações de segurança. Atualize a página e tente novamente.';
|
|
const securityUnavailableMessage = 'Não foi possível carregar a verificação de segurança. Atualize a página e tente novamente.';
|
|
const securityConfigurationMessage = 'Verificação de segurança indisponível. Entre em contato com o administrador.';
|
|
const securityRequiredMessage = 'Conclua a verificação de segurança para continuar.';
|
|
const securityFailedMessage = 'Não foi possível validar a verificação de segurança. Atualize a página e tente novamente.';
|
|
const turnstileSiteKeyPattern = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
|
|
|
|
const Login = () => {
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [captchaToken, setCaptchaToken] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isConfigLoading, setIsConfigLoading] = useState(true);
|
|
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
|
const [captchaRequired, setCaptchaRequired] = useState(false);
|
|
const [captchaReady, setCaptchaReady] = useState(true);
|
|
const captchaContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const captchaWidgetIdRef = useRef<string | null>(null);
|
|
const navigate = useNavigate();
|
|
const hasValidSiteKey = turnstileSiteKeyPattern.test(turnstileSiteKey);
|
|
const captchaEnabled = Boolean(captchaRequired && hasValidSiteKey);
|
|
const securityMisconfigured = captchaRequired && !hasValidSiteKey;
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const loadLoginConfig = async () => {
|
|
try {
|
|
const config = await getLoginConfig();
|
|
if (!isMounted) return;
|
|
|
|
setCaptchaRequired(config.captchaRequired);
|
|
setTurnstileSiteKey(config.turnstileSiteKey.trim());
|
|
setCaptchaReady(!config.captchaRequired);
|
|
if (config.captchaRequired && !config.captchaConfigured) {
|
|
setError(securityConfigurationMessage);
|
|
}
|
|
} catch {
|
|
if (!isMounted) return;
|
|
setCaptchaReady(false);
|
|
setError(securityConfigLoadMessage);
|
|
} finally {
|
|
if (isMounted) {
|
|
setIsConfigLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
void loadLoginConfig();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!hasValidSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
|
|
|
const siteKey = turnstileSiteKey;
|
|
|
|
const renderCaptcha = () => {
|
|
if (!window.turnstile || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
|
|
|
try {
|
|
captchaWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, {
|
|
sitekey: siteKey,
|
|
theme: 'dark',
|
|
callback: (token) => {
|
|
setCaptchaToken(token);
|
|
setCaptchaReady(true);
|
|
},
|
|
'expired-callback': () => {
|
|
setCaptchaToken('');
|
|
setCaptchaReady(true);
|
|
},
|
|
'error-callback': () => {
|
|
setCaptchaToken('');
|
|
setCaptchaReady(false);
|
|
setError(securityUnavailableMessage);
|
|
},
|
|
});
|
|
setCaptchaReady(true);
|
|
} catch {
|
|
setCaptchaToken('');
|
|
setCaptchaReady(false);
|
|
setError(securityConfigurationMessage);
|
|
}
|
|
};
|
|
|
|
if (window.turnstile) {
|
|
renderCaptcha();
|
|
return;
|
|
}
|
|
|
|
const existingScript = document.getElementById(turnstileScriptId);
|
|
if (existingScript) {
|
|
existingScript.addEventListener('load', renderCaptcha, { once: true });
|
|
return () => existingScript.removeEventListener('load', renderCaptcha);
|
|
}
|
|
|
|
const script = document.createElement('script');
|
|
script.id = turnstileScriptId;
|
|
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
|
script.async = true;
|
|
script.defer = true;
|
|
script.addEventListener('load', renderCaptcha, { once: true });
|
|
script.addEventListener('error', () => {
|
|
setCaptchaReady(false);
|
|
setError(securityUnavailableMessage);
|
|
}, { once: true });
|
|
document.head.appendChild(script);
|
|
|
|
return () => script.removeEventListener('load', renderCaptcha);
|
|
}, [hasValidSiteKey, turnstileSiteKey]);
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (securityMisconfigured) {
|
|
setError(securityConfigurationMessage);
|
|
return;
|
|
}
|
|
|
|
if (captchaEnabled && !captchaToken) {
|
|
setError(securityRequiredMessage);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError('');
|
|
|
|
try {
|
|
const result = await login(email, password, captchaToken);
|
|
if (result === 'success') {
|
|
navigate('/graph');
|
|
} else if (result === 'captcha_failed') {
|
|
setError(captchaEnabled ? securityFailedMessage : securityConfigurationMessage);
|
|
if (captchaEnabled) {
|
|
setCaptchaToken('');
|
|
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
|
}
|
|
} else if (result === 'invalid_credentials') {
|
|
setError('E-mail ou senha incorretos.');
|
|
if (captchaEnabled) {
|
|
setCaptchaToken('');
|
|
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
|
}
|
|
} else {
|
|
setError('Erro ao conectar ao servidor.');
|
|
if (captchaEnabled) {
|
|
setCaptchaToken('');
|
|
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
|
}
|
|
}
|
|
} catch {
|
|
setError('Erro ao conectar ao servidor.');
|
|
if (captchaEnabled) {
|
|
setCaptchaToken('');
|
|
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
|
}
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-dark-bg flex items-center justify-center p-4">
|
|
<div className="w-full max-w-md bg-dark-card border border-dark-border rounded-3xl p-8 shadow-xl">
|
|
<div className="flex flex-col items-center mb-8">
|
|
<div className="w-16 h-16 bg-brand-primary/10 rounded-2xl flex items-center justify-center text-brand-primary mb-4">
|
|
<Lock size={32} />
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-dark-text">Acesso Restrito</h1>
|
|
<p className="text-dark-muted mt-2 text-sm text-center">Insira suas credenciais para acessar o painel administrativo.</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleLogin} className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-bold text-dark-muted uppercase tracking-widest mb-2">E-mail</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="w-full bg-dark-input border border-dark-border text-dark-text rounded-xl px-4 py-3 focus:outline-none focus:border-brand-primary focus:ring-1 focus:ring-brand-primary transition-all"
|
|
placeholder="admin@admin.com"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-dark-muted uppercase tracking-widest mb-2">Senha</label>
|
|
<input
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="w-full bg-dark-input border border-dark-border text-dark-text rounded-xl px-4 py-3 focus:outline-none focus:border-brand-primary focus:ring-1 focus:ring-brand-primary transition-all"
|
|
placeholder="••••••••"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{(captchaEnabled || securityMisconfigured) && (
|
|
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
|
{captchaEnabled && <div ref={captchaContainerRef} className="flex justify-center" />}
|
|
{(securityMisconfigured || !captchaReady) && (
|
|
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação de segurança indisponível.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && <p className="text-red-500 text-sm font-medium" role="alert">{error}</p>}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading || isConfigLoading || securityMisconfigured || !captchaReady || (captchaEnabled && !captchaToken)}
|
|
className="w-full bg-brand-primary hover:bg-opacity-90 hover:scale-[1.02] active:scale-[0.98] text-zinc-900 font-bold py-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 mt-4 cursor-pointer"
|
|
>
|
|
{isLoading || isConfigLoading ? 'Entrando...' : 'Entrar'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Login;
|