feat: complete UI/UX refinement, email flow updates, and deep black theme
All checks were successful
Build and Deploy / build-and-push (push) Successful in 2m18s

- Updated all email templates to a clean light theme and changed button text to 'Finalizar Cadastro'.

- Enforced a strict 15-minute expiration on all auth/reset tokens.

- Created SetupAccount flow distinct from ResetPassword to capture user name during admin init.

- Refined dark mode to a premium True Black (Onyx) palette using Zinc.

- Fixed Dashboard KPI visibility and true period-over-period trend logic.

- Enhanced TeamManagement with global tenant filtering for Super Admins.

- Implemented secure User URL routing via slugs instead of raw UUIDs.

- Enforced strict Agent-level RBAC for viewing attendances.
This commit is contained in:
Cauê Faleiros
2026-03-05 15:33:03 -03:00
parent d5b57835a7
commit c4bd4d58a1
14 changed files with 369 additions and 70 deletions

View File

@@ -203,7 +203,7 @@ apiRouter.post('/auth/forgot-password', async (req, res) => {
const [users] = await pool.query('SELECT name FROM users WHERE email = ?', [email]);
if (users.length > 0) {
const token = crypto.randomBytes(32).toString('hex');
await pool.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 1 HOUR))', [email, token]);
await pool.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [email, token]);
const link = `${process.env.APP_URL || 'http://localhost:3001'}/#/reset-password?token=${token}`;
const mailOptions = {
@@ -218,7 +218,7 @@ apiRouter.post('/auth/forgot-password', async (req, res) => {
<div style="text-align: center; margin: 30px 0;">
<a href="${link}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Redefinir Minha Senha</a>
</div>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 1 hora. Se você não solicitou isso, pode ignorar este e-mail.</p>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos. Se você não solicitou isso, pode ignorar este e-mail.</p>
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
</div>
@@ -236,14 +236,23 @@ apiRouter.post('/auth/forgot-password', async (req, res) => {
// Reset Password
apiRouter.post('/auth/reset-password', async (req, res) => {
const { token, password } = req.body;
const { token, password, name } = req.body;
try {
const [resets] = await pool.query('SELECT email FROM password_resets WHERE token = ? AND expires_at > NOW()', [token]);
if (resets.length === 0) return res.status(400).json({ error: 'Token inválido.' });
if (resets.length === 0) return res.status(400).json({ error: 'Token inválido ou expirado.' });
const hash = await bcrypt.hash(password, 10);
await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]);
if (name) {
// If a name is provided (like in the initial admin setup flow), update it along with the password
await pool.query('UPDATE users SET password_hash = ?, name = ? WHERE email = ?', [hash, name, resets[0].email]);
} else {
// Standard password reset, just update the hash
await pool.query('UPDATE users SET password_hash = ? WHERE email = ?', [hash, resets[0].email]);
}
await pool.query('DELETE FROM password_resets WHERE email = ?', [resets[0].email]);
res.json({ message: 'Senha resetada.' });
res.json({ message: 'Senha e perfil atualizados com sucesso.' });
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -263,9 +272,9 @@ apiRouter.get('/users', async (req, res) => {
} catch (error) { res.status(500).json({ error: error.message }); }
});
apiRouter.get('/users/:id', async (req, res) => {
apiRouter.get('/users/:idOrSlug', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
const [rows] = await pool.query('SELECT * FROM users WHERE id = ? OR slug = ?', [req.params.idOrSlug, req.params.idOrSlug]);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'super_admin' && rows[0].tenant_id !== req.user.tenant_id) {
return res.status(403).json({ error: 'Acesso negado.' });
@@ -295,33 +304,32 @@ apiRouter.post('/users', requireRole(['admin', 'owner', 'super_admin']), async (
// 3. Gerar Token de Setup de Senha (reusando lógica de reset)
const token = crypto.randomBytes(32).toString('hex');
await pool.query(
'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))',
'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))',
[email, token]
);
// 4. Enviar E-mail de Boas-vindas
const setupLink = `${process.env.APP_URL || 'http://localhost:3001'}/#/reset-password?token=${token}`;
await transporter.sendMail({
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
to: email,
subject: 'Bem-vindo ao Fasto - Crie sua senha',
subject: 'Bem-vindo ao Fasto - Finalize seu cadastro',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px;">
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; color: #0f172a;">
<h2 style="color: #0f172a;">Olá, ${name}!</h2>
<p style="color: #475569;">Você foi convidado para participar da equipe no Fasto.</p>
<p style="color: #475569;">Clique no botão abaixo para definir sua senha e acessar sua conta:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Definir Minha Senha</a>
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Finalizar Cadastro</a>
</div>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 24 horas. Se você não esperava este convite, ignore este e-mail.</p>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos. Se você não esperava este convite, ignore este e-mail.</p>
<div style="border-top: 1px solid #f1f5f9; margin-top: 20px; padding-top: 20px; text-align: center;">
<p style="font-size: 12px; color: #94a3b8;">Desenvolvido por <a href="https://blyzer.com.br" style="color: #3b82f6; text-decoration: none;">Blyzer</a></p>
</div>
</div>
`
});
res.status(201).json({ id: uid, message: 'Convite enviado com sucesso.' });
} catch (error) {
console.error('Invite error:', error);
@@ -406,8 +414,23 @@ apiRouter.get('/attendances', async (req, res) => {
let q = 'SELECT a.*, u.team_id FROM attendances a JOIN users u ON a.user_id = u.id WHERE a.tenant_id = ?';
const params = [effectiveTenantId];
if (startDate && endDate) { q += ' AND a.created_at BETWEEN ? AND ?'; params.push(new Date(startDate), new Date(endDate)); }
if (userId && userId !== 'all') { q += ' AND a.user_id = ?'; params.push(userId); }
// Strict RBAC: Agents can ONLY see their own data, regardless of what they request
if (req.user.role === 'agent') {
q += ' AND a.user_id = ?';
params.push(req.user.id);
} else if (userId && userId !== 'all') {
// check if it's a slug or id
if (userId.startsWith('u_')) {
q += ' AND a.user_id = ?';
params.push(userId);
} else {
q += ' AND u.slug = ?';
params.push(userId);
}
}
if (teamId && teamId !== 'all') { q += ' AND u.team_id = ?'; params.push(teamId); }
if (funnelStage && funnelStage !== 'all') { q += ' AND a.funnel_stage = ?'; params.push(funnelStage); }
if (origin && origin !== 'all') { q += ' AND a.origin = ?'; params.push(origin); }
@@ -525,19 +548,32 @@ apiRouter.post('/tenants', requireRole(['super_admin']), async (req, res) => {
await connection.query('INSERT INTO users (id, tenant_id, name, email, password_hash, role) VALUES (?, ?, ?, ?, ?, ?)', [uid, tid, 'Admin', admin_email, placeholderHash, 'admin']);
const token = crypto.randomBytes(32).toString('hex');
await connection.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))', [admin_email, token]);
// Adicionar aos pending_registrations em vez de criar um usuário direto se quisermos que eles completem o cadastro.
// Ou, se quisermos apenas definir a senha, mantemos o password_resets.
// O usuário quer "like a register", então vamos enviar para uma página onde eles definem a senha.
await connection.query('INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))', [admin_email, token]);
const setupLink = `${process.env.APP_URL || 'http://localhost:3001'}/#/reset-password?token=${token}`;
await transporter.sendMail({
from: `"Fasto" <${process.env.MAIL_FROM || 'nao-responda@blyzer.com.br'}>`,
to: admin_email,
subject: 'Bem-vindo ao Fasto - Crie sua senha',
html: `<p>Você foi convidado para ser Admin. <a href="${setupLink}">Crie sua senha aqui</a>.</p>`
subject: 'Bem-vindo ao Fasto - Conclua seu cadastro de Admin',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; color: #0f172a;">
<h2 style="color: #0f172a;">Sua organização foi criada</h2>
<p style="color: #475569;">Você foi definido como administrador da organização <strong>${name}</strong>.</p>
<p style="color: #475569;">Por favor, clique no botão abaixo para definir sua senha e concluir seu cadastro.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Finalizar Cadastro</a>
</div>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos.</p>
</div>
`
}).catch(err => console.error("Email failed:", err));
}
await connection.commit();
res.status(201).json({ id: tid });
res.status(201).json({ id: tid, message: 'Organização criada e convite enviado por e-mail.' });
} catch (error) { await connection.rollback(); res.status(500).json({ error: error.message }); } finally { connection.release(); }
});
@@ -592,7 +628,7 @@ const provisionSuperAdmin = async () => {
const token = crypto.randomBytes(32).toString('hex');
await pool.query(
'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR))',
'INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 15 MINUTE))',
[email, token]
);
@@ -604,13 +640,13 @@ const provisionSuperAdmin = async () => {
to: email,
subject: 'Conta Super Admin Criada - Fasto',
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #222; border-radius: 12px; background: #0a0a0a; color: #ededed;">
<h2 style="color: #facc15;">Conta Super Admin Gerada</h2>
<p>Sua conta de suporte (super_admin) foi criada no Fasto.</p>
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background: #ffffff; color: #0f172a;">
<h2 style="color: #0f172a;">Conta Super Admin Gerada</h2>
<p style="color: #475569;">Sua conta de suporte (super_admin) foi criada no Fasto.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${setupLink}" style="background-color: #facc15; color: #09090b; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Definir Senha do Super Admin</a>
<a href="${setupLink}" style="background-color: #0f172a; color: white; padding: 12px 24px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block;">Finalizar Cadastro</a>
</div>
<p style="font-size: 12px; color: #888;">Este link expira em 24 horas.</p>
<p style="font-size: 12px; color: #94a3b8;">Este link expira em 15 minutos.</p>
</div>
`
}).catch(err => console.error("Failed to send super_admin email:", err));