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
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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user