feat: implementar autenticacao real com JWT para App e Admin
Build and Deploy (Gitea Actions) / build-and-deploy (push) Failing after 40s Details

This commit is contained in:
Sidney 2026-06-07 20:17:23 -03:00
parent 2bf14b0669
commit 6960d7d400
8 changed files with 388 additions and 24 deletions

145
admin/package-lock.json generated
View File

@ -8,10 +8,12 @@
"name": "agendapro-admin",
"version": "1.0.0",
"dependencies": {
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.15.21",
"@types/pg": "^8.11.10",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.511.0",
"next": "^15.3.3",
"pg": "^8.16.0",
@ -639,6 +641,22 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
@ -677,6 +695,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
@ -719,6 +743,100 @@
"node": ">=8"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lucide-react": {
"version": "0.511.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz",
@ -728,6 +846,12 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
@ -981,6 +1105,26 @@
"react": "^19.2.6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -992,7 +1136,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},

View File

@ -8,15 +8,17 @@
"start": "next start -p 3001"
},
"dependencies": {
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"typescript": "^5.8.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.15.21",
"@types/pg": "^8.11.10",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@types/pg": "^8.11.10",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.511.0",
"pg": "^8.16.0"
"next": "^15.3.3",
"pg": "^8.16.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
export async function POST(req: Request) {
try {
const { email, password } = await req.json();
if (!email || !password) return NextResponse.json({ error: 'Email e senha são obrigatórios' }, { status: 400 });
// Hardcoded Admin Credentials for SaaS owner
// In production, you'd use ENV variables like process.env.ADMIN_EMAIL
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@agendapro.com';
const ADMIN_PASS = process.env.ADMIN_PASSWORD || 'admin123';
if (email !== ADMIN_EMAIL || password !== ADMIN_PASS) {
return NextResponse.json({ error: 'Credenciais inválidas' }, { status: 401 });
}
const secret = process.env.JWT_SECRET || 'fallback_secret_for_development';
const token = jwt.sign({
role: 'superadmin',
email: ADMIN_EMAIL
}, secret, { expiresIn: '7d' });
return NextResponse.json({
token,
user: {
name: 'Super Admin',
email: ADMIN_EMAIL,
role: 'superadmin'
}
});
} catch (error: any) {
console.error('Admin Login error:', error);
return NextResponse.json({ error: 'Erro interno do servidor' }, { status: 500 });
}
}

View File

@ -0,0 +1,83 @@
'use client';
import { useState } from 'react';
import { Mail, Lock, Shield, ArrowRight } from 'lucide-react';
import { useRouter } from 'next/navigation';
export default function AdminLoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Erro ao realizar login');
setLoading(false);
return;
}
localStorage.setItem('agendapro_admin_token', data.token);
localStorage.setItem('agendapro_admin_email', data.user.email);
localStorage.setItem('agendapro_admin_name', data.user.name);
router.push('/');
} catch (err) {
console.error(err);
setError('Falha de conexão com o servidor');
setLoading(false);
}
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-primary)' }}>
<div style={{ width: '100%', maxWidth: '420px', padding: '40px', background: 'var(--bg-card)', borderRadius: 'var(--radius-lg)', boxShadow: 'var(--shadow-xl)', border: '1px solid var(--border-color)', animation: 'slideUp 0.5s ease' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', marginBottom: '32px' }}>
<div style={{ width: 64, height: 64, background: 'var(--primary)', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: '16px', boxShadow: 'var(--shadow-md)' }}>
<Shield size={32} color="white" />
</div>
<h1 style={{ fontSize: '24px', fontWeight: 800 }}>Admin Panel</h1>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginTop: '4px' }}>Acesso restrito ao proprietário do sistema</p>
</div>
{error && <div style={{ background: 'var(--danger)', color: 'white', padding: '12px', borderRadius: 'var(--radius-md)', marginBottom: '20px', fontSize: '14px', textAlign: 'center' }}>{error}</div>}
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Mail size={14} /> E-mail Administrativo</label>
<input type="email" required className="form-input" placeholder="admin@agendapro.com"
value={email} onChange={e => setEmail(e.target.value)} />
</div>
<div className="form-group" style={{ margin: 0 }}>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Lock size={14} /> Senha Mestra</label>
<input type="password" required className="form-input" placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)} />
</div>
<button type="submit" className="btn btn-primary" disabled={loading}
style={{ width: '100%', padding: '12px', fontSize: '16px', justifyContent: 'center', marginTop: '12px' }}>
{loading ? 'Autenticando...' : <><ArrowRight size={18} /> Entrar no Painel</>}
</button>
</form>
<p style={{ textAlign: 'center', marginTop: '32px', fontSize: '12px', color: 'var(--text-muted)' }}>
AgendaPRO SaaS &copy; 2026. Todos os direitos reservados.
</p>
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { LayoutDashboard, Users, CreditCard, Package, Settings, Shield, TrendingUp, Bell, LogOut, User, CheckCircle, AlertCircle } from 'lucide-react';
import { useRouter } from 'next/navigation';
import AdminDashboard from '@/components/AdminDashboard';
import Tenants from '@/components/Tenants';
import Plans from '@/components/Plans';
@ -33,23 +34,38 @@ const mockNotifications = [
];
export default function AdminPage() {
const router = useRouter();
const [currentPage, setCurrentPage] = useState('dashboard');
const [isLoading, setIsLoading] = useState(true);
const [showNotifications, setShowNotifications] = useState(false);
const [showProfile, setShowProfile] = useState(false);
const [notifications, setNotifications] = useState(mockNotifications);
const [adminName, setAdminName] = useState('Super Admin');
const [adminEmail, setAdminEmail] = useState('admin@agendapro.com');
const notifRef = useRef<HTMLDivElement>(null);
const profileRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Auth Check
const token = localStorage.getItem('agendapro_admin_token');
if (!token) {
router.push('/login');
} else {
setAdminName(localStorage.getItem('agendapro_admin_name') || 'Super Admin');
setAdminEmail(localStorage.getItem('agendapro_admin_email') || 'admin@agendapro.com');
setIsLoading(false);
}
const handleClickOutside = (e: MouseEvent) => {
if (notifRef.current && !notifRef.current.contains(e.target as Node)) setShowNotifications(false);
if (profileRef.current && !profileRef.current.contains(e.target as Node)) setShowProfile(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}, [router]);
const playNotificationSound = () => {
try {
@ -95,10 +111,18 @@ export default function AdminPage() {
};
const logout = () => {
window.location.href = '/';
localStorage.removeItem('agendapro_admin_token');
localStorage.removeItem('agendapro_admin_email');
localStorage.removeItem('agendapro_admin_name');
router.push('/login');
};
if (isLoading) {
return <div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>Carregando painel...</div>;
}
const unreadCount = notifications.filter(n => !n.read).length;
const adminInitials = adminName.split(' ').map(n => n[0]).join('').substring(0, 2).toUpperCase();
const renderPage = () => {
switch (currentPage) {
@ -140,10 +164,10 @@ export default function AdminPage() {
</nav>
<div style={{ padding: '16px', borderTop: '1px solid var(--border-color)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '10px', borderRadius: 'var(--radius-sm)', background: 'var(--bg-card)' }}>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 12 }}>SA</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 600 }}>Super Admin</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)' }}>admin@agendapro.com</div>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 12 }}>{adminInitials}</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }}>{adminName}</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }}>{adminEmail}</div>
</div>
</div>
</div>
@ -205,7 +229,7 @@ export default function AdminPage() {
</div>
<div style={{ position: 'relative' }} ref={profileRef}>
<div className="avatar" style={{ width: 36, height: 36, fontSize: 13, cursor: 'pointer', transition: 'transform 0.2s' }} onClick={handleProfileClick}>SA</div>
<div className="avatar" style={{ width: 36, height: 36, fontSize: 13, cursor: 'pointer', transition: 'transform 0.2s' }} onClick={handleProfileClick}>{adminInitials}</div>
{showProfile && (
<div className="card" style={{
@ -214,8 +238,8 @@ export default function AdminPage() {
animation: 'slideUp 0.2s ease-out forwards'
}}>
<div style={{ padding: '12px', borderBottom: '1px solid var(--border-color)', marginBottom: '8px' }}>
<div style={{ fontWeight: 600, fontSize: '14px' }}>Super Admin</div>
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '2px' }}>admin@agendapro.com</div>
<div style={{ fontWeight: 600, fontSize: '14px', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }}>{adminName}</div>
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '2px', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }}>{adminEmail}</div>
</div>
<button className="btn-ghost" style={{ width: '100%', justifyContent: 'flex-start', padding: '10px 12px', fontSize: '13px' }}>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { query } from '@/lib/db';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
export async function POST(req: Request) {
try {
const { email, password } = await req.json();
if (!email || !password) return NextResponse.json({ error: 'Email e senha são obrigatórios' }, { status: 400 });
const result = await query(`
SELECT u.id, u.tenant_id, u.name, u.role, u.password_hash, t.slug as tenant_slug, t.name as tenant_name
FROM users u
JOIN tenants t ON u.tenant_id = t.id
WHERE u.email = $1 AND u.is_active = true
`, [email]);
if (result.rows.length === 0) return NextResponse.json({ error: 'Credenciais inválidas' }, { status: 401 });
const user = result.rows[0];
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) return NextResponse.json({ error: 'Credenciais inválidas' }, { status: 401 });
// Generate JWT
const secret = process.env.JWT_SECRET || 'fallback_secret_for_development';
const token = jwt.sign({
userId: user.id,
tenantId: user.tenant_id,
role: user.role
}, secret, { expiresIn: '7d' });
return NextResponse.json({
token,
user: {
id: user.id,
name: user.name,
email: email,
role: user.role,
tenant_id: user.tenant_id,
tenant_slug: user.tenant_slug,
tenant_name: user.tenant_name
}
});
} catch (error: any) {
console.error('Login error:', error);
return NextResponse.json({ error: 'Erro interno do servidor' }, { status: 500 });
}
}

View File

@ -7,17 +7,41 @@ export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const handleLogin = (e: React.FormEvent) => {
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
// Simulação de login (em produção faria requisição à API)
setTimeout(() => {
setError('');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Erro ao realizar login');
setLoading(false);
localStorage.setItem('agendapro_token', 'demo_token');
return;
}
localStorage.setItem('agendapro_token', data.token);
localStorage.setItem('agendapro_tenant_slug', data.user.tenant_slug);
localStorage.setItem('agendapro_tenant_name', data.user.tenant_name);
localStorage.setItem('agendapro_email', data.user.email);
localStorage.setItem('agendapro_role', data.user.role);
router.push('/');
}, 1000);
} catch (err) {
console.error(err);
setError('Falha de conexão com o servidor');
setLoading(false);
}
};
return (
@ -41,7 +65,9 @@ export default function LoginPage() {
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '40px' }}>
<div style={{ width: '100%', maxWidth: '400px', animation: 'slideUp 0.5s ease' }}>
<h2 style={{ fontSize: '28px', fontWeight: 800, marginBottom: '8px' }}>Bem-vindo de volta</h2>
<p style={{ color: 'var(--text-secondary)', marginBottom: '32px' }}>Faça login para acessar o seu painel</p>
<p style={{ color: 'var(--text-secondary)', marginBottom: '24px' }}>Faça login para acessar o seu painel</p>
{error && <div style={{ background: 'var(--danger)', color: 'white', padding: '12px', borderRadius: 'var(--radius-md)', marginBottom: '20px', fontSize: '14px', textAlign: 'center' }}>{error}</div>}
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div className="form-group" style={{ margin: 0 }}>