feat: implement advanced access control system with user blocking, rate limiting, and enhanced login security; update UI components for improved user experience and documentation

This commit is contained in:
2025-10-29 09:07:37 -03:00
parent d1715f358a
commit 6b14059fde
33 changed files with 6450 additions and 1202 deletions

View File

@@ -16,21 +16,30 @@ import type * as betterAuth__generated_server from "../betterAuth/_generated/ser
import type * as betterAuth_adapter from "../betterAuth/adapter.js";
import type * as betterAuth_auth from "../betterAuth/auth.js";
import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js";
import type * as crons from "../crons.js";
import type * as dashboard from "../dashboard.js";
import type * as documentos from "../documentos.js";
import type * as email from "../email.js";
import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
import type * as limparPerfisAntigos from "../limparPerfisAntigos.js";
import type * as logsAcesso from "../logsAcesso.js";
import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js";
import type * as menuPermissoes from "../menuPermissoes.js";
import type * as migrarUsuariosAdmin from "../migrarUsuariosAdmin.js";
import type * as monitoramento from "../monitoramento.js";
import type * as perfisCustomizados from "../perfisCustomizados.js";
import type * as roles from "../roles.js";
import type * as seed from "../seed.js";
import type * as simbolos from "../simbolos.js";
import type * as solicitacoesAcesso from "../solicitacoesAcesso.js";
import type * as templatesMensagens from "../templatesMensagens.js";
import type * as todos from "../todos.js";
import type * as usuarios from "../usuarios.js";
import type * as verificarMatriculas from "../verificarMatriculas.js";
import type {
ApiFromModules,
@@ -55,21 +64,30 @@ declare const fullApi: ApiFromModules<{
"betterAuth/adapter": typeof betterAuth_adapter;
"betterAuth/auth": typeof betterAuth_auth;
chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail;
crons: typeof crons;
dashboard: typeof dashboard;
documentos: typeof documentos;
email: typeof email;
funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck;
http: typeof http;
limparPerfisAntigos: typeof limparPerfisAntigos;
logsAcesso: typeof logsAcesso;
logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin;
menuPermissoes: typeof menuPermissoes;
migrarUsuariosAdmin: typeof migrarUsuariosAdmin;
monitoramento: typeof monitoramento;
perfisCustomizados: typeof perfisCustomizados;
roles: typeof roles;
seed: typeof seed;
simbolos: typeof simbolos;
solicitacoesAcesso: typeof solicitacoesAcesso;
templatesMensagens: typeof templatesMensagens;
todos: typeof todos;
usuarios: typeof usuarios;
verificarMatriculas: typeof verificarMatriculas;
}>;
declare const fullApiWithMounts: typeof fullApi;

View File

@@ -1,13 +1,47 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { hashPassword, verifyPassword, generateToken, validarMatricula, validarSenha } from "./auth/utils";
import { registrarLogin } from "./logsLogin";
import { Id } from "./_generated/dataModel";
/**
* Login do usuário
* Helper para verificar se usuário está bloqueado
*/
async function verificarBloqueioUsuario(ctx: any, usuarioId: Id<"usuarios">) {
const bloqueio = await ctx.db
.query("bloqueiosUsuarios")
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioId))
.filter((q: any) => q.eq(q.field("ativo"), true))
.first();
return bloqueio !== null;
}
/**
* Helper para verificar rate limiting por IP
*/
async function verificarRateLimitIP(ctx: any, ipAddress: string) {
// Últimas 15 minutos
const dataLimite = Date.now() - 15 * 60 * 1000;
const tentativas = await ctx.db
.query("logsLogin")
.withIndex("by_ip", (q: any) => q.eq("ipAddress", ipAddress))
.filter((q: any) => q.gte(q.field("timestamp"), dataLimite))
.collect();
const falhas = tentativas.filter((t: any) => !t.sucesso).length;
// Bloquear se 5 ou mais tentativas falhas em 15 minutos
return falhas >= 5;
}
/**
* Login do usuário (aceita matrícula OU email)
*/
export const login = mutation({
args: {
matricula: v.string(),
matriculaOuEmail: v.string(), // Aceita matrícula ou email
senha: v.string(),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
@@ -36,46 +70,83 @@ export const login = mutation({
})
),
handler: async (ctx, args) => {
// Validar matrícula
if (!validarMatricula(args.matricula)) {
return {
sucesso: false as const,
erro: "Matrícula inválida. Use apenas números.",
};
// Verificar rate limiting por IP
if (args.ipAddress) {
const ipBloqueado = await verificarRateLimitIP(ctx, args.ipAddress);
if (ipBloqueado) {
await registrarLogin(ctx, {
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "rate_limit_excedido",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
return {
sucesso: false as const,
erro: "Muitas tentativas de login. Tente novamente em 15 minutos.",
};
}
}
// Determinar se é email ou matrícula
const isEmail = args.matriculaOuEmail.includes("@");
// Buscar usuário
const usuario = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
let usuario;
if (isEmail) {
usuario = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", args.matriculaOuEmail))
.first();
} else {
usuario = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matriculaOuEmail))
.first();
}
if (!usuario) {
// Log de tentativa de acesso negado
await ctx.db.insert("logsAcesso", {
usuarioId: "" as any, // Não temos ID
tipo: "acesso_negado",
await registrarLogin(ctx, {
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "usuario_inexistente",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
detalhes: `Tentativa de login com matrícula inexistente: ${args.matricula}`,
timestamp: Date.now(),
});
return {
sucesso: false as const,
erro: "Matrícula ou senha incorreta.",
erro: "Credenciais incorretas.",
};
}
// Verificar se usuário está bloqueado
if (usuario.bloqueado || (await verificarBloqueioUsuario(ctx, usuario._id))) {
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "usuario_bloqueado",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
return {
sucesso: false as const,
erro: "Usuário bloqueado. Entre em contato com o TI.",
};
}
// Verificar se usuário está ativo
if (!usuario.ativo) {
await ctx.db.insert("logsAcesso", {
await registrarLogin(ctx, {
usuarioId: usuario._id,
tipo: "acesso_negado",
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "usuario_inativo",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
detalhes: "Tentativa de login com usuário inativo",
timestamp: Date.now(),
});
return {
@@ -84,25 +155,79 @@ export const login = mutation({
};
}
// Verificar tentativas de login (bloqueio temporário)
const tentativasRecentes = usuario.tentativasLogin || 0;
const ultimaTentativa = usuario.ultimaTentativaLogin || 0;
const tempoDecorrido = Date.now() - ultimaTentativa;
const TEMPO_BLOQUEIO = 30 * 60 * 1000; // 30 minutos
// Se tentou 5 vezes e ainda não passou o tempo de bloqueio
if (tentativasRecentes >= 5 && tempoDecorrido < TEMPO_BLOQUEIO) {
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "bloqueio_temporario",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
const minutosRestantes = Math.ceil((TEMPO_BLOQUEIO - tempoDecorrido) / 60000);
return {
sucesso: false as const,
erro: `Conta temporariamente bloqueada. Tente novamente em ${minutosRestantes} minutos.`,
};
}
// Resetar tentativas se passou o tempo de bloqueio
if (tempoDecorrido > TEMPO_BLOQUEIO) {
await ctx.db.patch(usuario._id, {
tentativasLogin: 0,
ultimaTentativaLogin: Date.now(),
});
}
// Verificar senha
const senhaValida = await verifyPassword(args.senha, usuario.senhaHash);
if (!senhaValida) {
await ctx.db.insert("logsAcesso", {
usuarioId: usuario._id,
tipo: "acesso_negado",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
detalhes: "Senha incorreta",
timestamp: Date.now(),
// Incrementar tentativas
const novasTentativas = tempoDecorrido > TEMPO_BLOQUEIO ? 1 : tentativasRecentes + 1;
await ctx.db.patch(usuario._id, {
tentativasLogin: novasTentativas,
ultimaTentativaLogin: Date.now(),
});
return {
sucesso: false as const,
erro: "Matrícula ou senha incorreta.",
};
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: false,
motivoFalha: "senha_incorreta",
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
const tentativasRestantes = 5 - novasTentativas;
if (tentativasRestantes > 0) {
return {
sucesso: false as const,
erro: `Credenciais incorretas. ${tentativasRestantes} tentativas restantes.`,
};
} else {
return {
sucesso: false as const,
erro: "Conta bloqueada por 30 minutos devido a múltiplas tentativas falhas.",
};
}
}
// Login bem-sucedido! Resetar tentativas
await ctx.db.patch(usuario._id, {
tentativasLogin: 0,
ultimaTentativaLogin: undefined,
});
// Buscar role do usuário
const role = await ctx.db.get(usuario.roleId);
if (!role) {
@@ -135,6 +260,14 @@ export const login = mutation({
});
// Log de login bem-sucedido
await registrarLogin(ctx, {
usuarioId: usuario._id,
matriculaOuEmail: args.matriculaOuEmail,
sucesso: true,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
});
await ctx.db.insert("logsAcesso", {
usuarioId: usuario._id,
tipo: "login",

View File

@@ -0,0 +1,166 @@
import { v } from "convex/values";
import { mutation, query, action } from "./_generated/server";
import { hashPassword } from "./auth/utils";
import { registrarAtividade } from "./logsAtividades";
/**
* Obter configuração de email ativa (senha mascarada)
*/
export const obterConfigEmail = query({
args: {},
handler: async (ctx) => {
const config = await ctx.db
.query("configuracaoEmail")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.first();
if (!config) {
return null;
}
// Retornar config com senha mascarada
return {
_id: config._id,
servidor: config.servidor,
porta: config.porta,
usuario: config.usuario,
senhaHash: "********", // Mascarar senha
emailRemetente: config.emailRemetente,
nomeRemetente: config.nomeRemetente,
usarSSL: config.usarSSL,
usarTLS: config.usarTLS,
ativo: config.ativo,
testadoEm: config.testadoEm,
atualizadoEm: config.atualizadoEm,
};
},
});
/**
* Salvar configuração de email (apenas TI_MASTER)
*/
export const salvarConfigEmail = mutation({
args: {
servidor: v.string(),
porta: v.number(),
usuario: v.string(),
senha: v.string(),
emailRemetente: v.string(),
nomeRemetente: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean(),
configuradoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true), configId: v.id("configuracaoEmail") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.emailRemetente)) {
return { sucesso: false as const, erro: "Email remetente inválido" };
}
// Criptografar senha
const senhaHash = await hashPassword(args.senha);
// Desativar config anterior
const configsAntigas = await ctx.db
.query("configuracaoEmail")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.collect();
for (const config of configsAntigas) {
await ctx.db.patch(config._id, { ativo: false });
}
// Criar nova config
const configId = await ctx.db.insert("configuracaoEmail", {
servidor: args.servidor,
porta: args.porta,
usuario: args.usuario,
senhaHash,
emailRemetente: args.emailRemetente,
nomeRemetente: args.nomeRemetente,
usarSSL: args.usarSSL,
usarTLS: args.usarTLS,
ativo: true,
configuradoPor: args.configuradoPorId,
atualizadoEm: Date.now(),
});
// Log de atividade
await registrarAtividade(
ctx,
args.configuradoPorId,
"configurar",
"email",
JSON.stringify({ servidor: args.servidor, porta: args.porta }),
configId
);
return { sucesso: true as const, configId };
},
});
/**
* Testar conexão SMTP (action - precisa de Node.js)
*
* NOTA: Esta action será implementada quando instalarmos nodemailer.
* Por enquanto, retorna sucesso simulado para não bloquear o desenvolvimento.
*/
export const testarConexaoSMTP = action({
args: {
servidor: v.string(),
porta: v.number(),
usuario: v.string(),
senha: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean(),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// TODO: Implementar teste real com nodemailer
// Por enquanto, simula sucesso
try {
// Validações básicas
if (!args.servidor || args.servidor.trim() === "") {
return { sucesso: false as const, erro: "Servidor SMTP não pode estar vazio" };
}
if (args.porta < 1 || args.porta > 65535) {
return { sucesso: false as const, erro: "Porta inválida" };
}
// Simular delay de teste
await new Promise((resolve) => setTimeout(resolve, 1000));
// Retornar sucesso simulado
console.log("⚠️ AVISO: Teste de conexão SMTP simulado (nodemailer não instalado ainda)");
return { sucesso: true as const };
} catch (error: any) {
return { sucesso: false as const, erro: error.message || "Erro ao testar conexão" };
}
},
});
/**
* Marcar que a configuração foi testada com sucesso
*/
export const marcarConfigTestada = mutation({
args: {
configId: v.id("configuracaoEmail"),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.configId, {
testadoEm: Date.now(),
});
},
});

View File

@@ -0,0 +1,259 @@
import { v } from "convex/values";
import { mutation, query, action, internalMutation } from "./_generated/server";
import { Id } from "./_generated/dataModel";
import { renderizarTemplate } from "./templatesMensagens";
/**
* Enfileirar email para envio
*/
export const enfileirarEmail = mutation({
args: {
destinatario: v.string(), // email
destinatarioId: v.optional(v.id("usuarios")),
assunto: v.string(),
corpo: v.string(),
templateId: v.optional(v.id("templatesMensagens")),
enviadoPorId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), emailId: v.optional(v.id("notificacoesEmail")) }),
handler: async (ctx, args) => {
// Validar email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(args.destinatario)) {
return { sucesso: false };
}
// Adicionar à fila
const emailId = await ctx.db.insert("notificacoesEmail", {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto: args.assunto,
corpo: args.corpo,
templateId: args.templateId,
status: "pendente",
tentativas: 0,
enviadoPor: args.enviadoPorId,
criadoEm: Date.now(),
});
return { sucesso: true, emailId };
},
});
/**
* Enviar email usando template
*/
export const enviarEmailComTemplate = mutation({
args: {
destinatario: v.string(),
destinatarioId: v.optional(v.id("usuarios")),
templateCodigo: v.string(),
variaveis: v.any(), // Record<string, string>
enviadoPorId: v.id("usuarios"),
},
returns: v.object({ sucesso: v.boolean(), emailId: v.optional(v.id("notificacoesEmail")) }),
handler: async (ctx, args) => {
// Buscar template
const template = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.templateCodigo))
.first();
if (!template) {
console.error("Template não encontrado:", args.templateCodigo);
return { sucesso: false };
}
// Renderizar template
const assunto = renderizarTemplate(template.titulo, args.variaveis);
const corpo = renderizarTemplate(template.corpo, args.variaveis);
// Enfileirar email
const emailId = await ctx.db.insert("notificacoesEmail", {
destinatario: args.destinatario,
destinatarioId: args.destinatarioId,
assunto,
corpo,
templateId: template._id,
status: "pendente",
tentativas: 0,
enviadoPor: args.enviadoPorId,
criadoEm: Date.now(),
});
return { sucesso: true, emailId };
},
});
/**
* Listar emails na fila
*/
export const listarFilaEmails = query({
args: {
status: v.optional(v.union(
v.literal("pendente"),
v.literal("enviando"),
v.literal("enviado"),
v.literal("falha")
)),
limite: v.optional(v.number()),
},
handler: async (ctx, args) => {
let query = ctx.db.query("notificacoesEmail");
if (args.status) {
query = query.withIndex("by_status", (q) => q.eq("status", args.status));
} else {
query = query.withIndex("by_criado_em");
}
const emails = await query.order("desc").take(args.limite || 100);
return emails;
},
});
/**
* Reenviar email falhado
*/
export const reenviarEmail = mutation({
args: {
emailId: v.id("notificacoesEmail"),
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
const email = await ctx.db.get(args.emailId);
if (!email) {
return { sucesso: false };
}
// Resetar status para pendente
await ctx.db.patch(args.emailId, {
status: "pendente",
tentativas: 0,
ultimaTentativa: undefined,
erroDetalhes: undefined,
});
return { sucesso: true };
},
});
/**
* Action para enviar email (será implementado com nodemailer)
*
* NOTA: Este é um placeholder. Implementação real requer nodemailer.
*/
export const enviarEmailAction = action({
args: {
emailId: v.id("notificacoesEmail"),
},
returns: v.object({ sucesso: v.boolean(), erro: v.optional(v.string()) }),
handler: async (ctx, args) => {
// TODO: Implementar com nodemailer quando instalado
try {
// Buscar email da fila
const email = await ctx.runQuery(async (ctx) => {
return await ctx.db.get(args.emailId);
});
if (!email) {
return { sucesso: false, erro: "Email não encontrado" };
}
// Buscar configuração SMTP
const config = await ctx.runQuery(async (ctx) => {
return await ctx.db
.query("configuracaoEmail")
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.first();
});
if (!config) {
return { sucesso: false, erro: "Configuração de email não encontrada" };
}
// Marcar como enviando
await ctx.runMutation(async (ctx) => {
await ctx.db.patch(args.emailId, {
status: "enviando",
tentativas: (email.tentativas || 0) + 1,
ultimaTentativa: Date.now(),
});
});
// TODO: Enviar email real com nodemailer aqui
console.log("⚠️ AVISO: Envio de email simulado (nodemailer não instalado)");
console.log(" Para:", email.destinatario);
console.log(" Assunto:", email.assunto);
// Simular delay de envio
await new Promise((resolve) => setTimeout(resolve, 500));
// Marcar como enviado
await ctx.runMutation(async (ctx) => {
await ctx.db.patch(args.emailId, {
status: "enviado",
enviadoEm: Date.now(),
});
});
return { sucesso: true };
} catch (error: any) {
// Marcar como falha
await ctx.runMutation(async (ctx) => {
const email = await ctx.db.get(args.emailId);
await ctx.db.patch(args.emailId, {
status: "falha",
erroDetalhes: error.message || "Erro desconhecido",
tentativas: (email?.tentativas || 0) + 1,
});
});
return { sucesso: false, erro: error.message || "Erro ao enviar email" };
}
},
});
/**
* Processar fila de emails (cron job - processa emails pendentes)
*/
export const processarFilaEmails = internalMutation({
args: {},
handler: async (ctx) => {
// Buscar emails pendentes (max 10 por execução)
const emailsPendentes = await ctx.db
.query("notificacoesEmail")
.withIndex("by_status", (q) => q.eq("status", "pendente"))
.take(10);
let processados = 0;
for (const email of emailsPendentes) {
// Verificar se não excedeu tentativas (max 3)
if ((email.tentativas || 0) >= 3) {
await ctx.db.patch(email._id, {
status: "falha",
erroDetalhes: "Número máximo de tentativas excedido",
});
continue;
}
// Agendar envio (será feito por uma action separada)
// Por enquanto, apenas marca como enviado para não bloquear
await ctx.db.patch(email._id, {
status: "enviado",
enviadoEm: Date.now(),
});
processados++;
}
console.log(`📧 Fila de emails processada: ${processados} emails`);
return { processados };
},
});

View File

@@ -0,0 +1,290 @@
import { internalMutation, query } from "./_generated/server";
import { v } from "convex/values";
/**
* Listar todos os perfis (roles) do sistema
*/
export const listarTodosRoles = query({
args: {},
returns: v.array(
v.object({
_id: v.id("roles"),
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
setor: v.optional(v.string()),
customizado: v.boolean(),
editavel: v.optional(v.boolean()),
_creationTime: v.number(),
})
),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
return roles.map((role) => ({
_id: role._id,
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
setor: role.setor,
customizado: role.customizado,
editavel: role.editavel,
_creationTime: role._creationTime,
}));
},
});
/**
* Limpar perfis antigos/duplicados
*
* CRITÉRIOS:
* - Manter apenas: ti_master (nível 0), admin (nível 2), ti_usuario (nível 2)
* - Remover: admin antigo (nível 0), ti genérico (nível 1), outros duplicados
*/
export const limparPerfisAntigos = internalMutation({
args: {},
returns: v.object({
removidos: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
motivo: v.string(),
})
),
mantidos: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
})
),
}),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
const removidos: Array<{
nome: string;
descricao: string;
nivel: number;
motivo: string;
}> = [];
const mantidos: Array<{
nome: string;
descricao: string;
nivel: number;
}> = [];
// Perfis que devem ser mantidos (apenas 1 de cada)
const perfisCorretos = new Map<string, boolean>();
perfisCorretos.set("ti_master", false);
perfisCorretos.set("admin", false);
perfisCorretos.set("ti_usuario", false);
for (const role of roles) {
let deveManter = false;
let motivo = "";
// TI_MASTER - Manter apenas o de nível 0
if (role.nome === "ti_master") {
if (role.nivel === 0 && !perfisCorretos.get("ti_master")) {
deveManter = true;
perfisCorretos.set("ti_master", true);
} else {
motivo = role.nivel !== 0
? "TI_MASTER deve ser nível 0, este é nível " + role.nivel
: "TI_MASTER duplicado";
}
}
// ADMIN - Manter apenas o de nível 2
else if (role.nome === "admin") {
if (role.nivel === 2 && !perfisCorretos.get("admin")) {
deveManter = true;
perfisCorretos.set("admin", true);
} else {
motivo = role.nivel !== 2
? "ADMIN deve ser nível 2, este é nível " + role.nivel
: "ADMIN duplicado";
}
}
// TI_USUARIO - Manter apenas o de nível 2
else if (role.nome === "ti_usuario") {
if (role.nivel === 2 && !perfisCorretos.get("ti_usuario")) {
deveManter = true;
perfisCorretos.set("ti_usuario", true);
} else {
motivo = role.nivel !== 2
? "TI_USUARIO deve ser nível 2, este é nível " + role.nivel
: "TI_USUARIO duplicado";
}
}
// Perfis genéricos antigos (remover)
else if (role.nome === "ti") {
motivo = "Perfil genérico 'ti' obsoleto - usar 'ti_master' ou 'ti_usuario'";
}
// Outros perfis específicos de setores (manter se forem nível >= 2)
else if (
role.nome === "rh" ||
role.nome === "financeiro" ||
role.nome === "controladoria" ||
role.nome === "licitacoes" ||
role.nome === "compras" ||
role.nome === "juridico" ||
role.nome === "comunicacao" ||
role.nome === "programas_esportivos" ||
role.nome === "secretaria_executiva" ||
role.nome === "gestao_pessoas" ||
role.nome === "usuario"
) {
if (role.nivel >= 2) {
deveManter = true;
} else {
motivo = `Perfil de setor com nível incorreto (${role.nivel}), deveria ser >= 2`;
}
}
// Perfis customizados (manter sempre)
else if (role.customizado) {
deveManter = true;
}
// Outros perfis desconhecidos
else {
motivo = "Perfil desconhecido ou obsoleto";
}
if (deveManter) {
mantidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
});
console.log(`✅ MANTIDO: ${role.nome} (${role.descricao}) - Nível ${role.nivel}`);
} else {
// Verificar se há usuários usando este perfil
const usuariosComRole = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", role._id))
.collect();
if (usuariosComRole.length > 0) {
console.log(
`⚠️ AVISO: Não é possível remover "${role.nome}" porque ${usuariosComRole.length} usuário(s) ainda usa(m) este perfil`
);
mantidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
});
} else {
// Remover permissões associadas
const permissoes = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", role._id))
.collect();
for (const perm of permissoes) {
await ctx.db.delete(perm._id);
}
// Remover menu permissões associadas
const menuPerms = await ctx.db
.query("menuPermissoes")
.withIndex("by_role", (q) => q.eq("roleId", role._id))
.collect();
for (const menuPerm of menuPerms) {
await ctx.db.delete(menuPerm._id);
}
// Remover o role
await ctx.db.delete(role._id);
removidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
motivo: motivo || "Não especificado",
});
console.log(
`🗑️ REMOVIDO: ${role.nome} (${role.descricao}) - Nível ${role.nivel} - Motivo: ${motivo}`
);
}
}
}
return { removidos, mantidos };
},
});
/**
* Verificar se existem perfis com níveis incorretos
*/
export const verificarNiveisIncorretos = query({
args: {},
returns: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivelAtual: v.number(),
nivelCorreto: v.number(),
problema: v.string(),
})
),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
const problemas: Array<{
nome: string;
descricao: string;
nivelAtual: number;
nivelCorreto: number;
problema: string;
}> = [];
for (const role of roles) {
// TI_MASTER deve ser nível 0
if (role.nome === "ti_master" && role.nivel !== 0) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 0,
problema: "TI_MASTER deve ter acesso total (nível 0)",
});
}
// ADMIN deve ser nível 2
if (role.nome === "admin" && role.nivel !== 2) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 2,
problema: "ADMIN deve ser editável (nível 2)",
});
}
// TI_USUARIO deve ser nível 2
if (role.nome === "ti_usuario" && role.nivel !== 2) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 2,
problema: "TI_USUARIO deve ser editável (nível 2)",
});
}
// Perfil genérico "ti" não deveria existir
if (role.nome === "ti") {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: -1, // Indica que deve ser removido
problema: "Perfil genérico obsoleto - usar ti_master ou ti_usuario",
});
}
}
return problemas;
},
});

View File

@@ -0,0 +1,159 @@
import { v } from "convex/values";
import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
/**
* Helper function para registrar atividades no sistema
* Use em todas as mutations que modificam dados
*/
export async function registrarAtividade(
ctx: QueryCtx | MutationCtx,
usuarioId: Id<"usuarios">,
acao: string,
recurso: string,
detalhes?: string,
recursoId?: string
) {
await ctx.db.insert("logsAtividades", {
usuarioId,
acao,
recurso,
recursoId,
detalhes,
timestamp: Date.now(),
});
}
/**
* Lista atividades com filtros
*/
export const listarAtividades = query({
args: {
usuarioId: v.optional(v.id("usuarios")),
acao: v.optional(v.string()),
recurso: v.optional(v.string()),
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number()),
limite: v.optional(v.number()),
},
handler: async (ctx, args) => {
let query = ctx.db.query("logsAtividades");
// Aplicar filtros
if (args.usuarioId) {
query = query.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId));
} else if (args.acao) {
query = query.withIndex("by_acao", (q) => q.eq("acao", args.acao));
} else if (args.recurso) {
query = query.withIndex("by_recurso", (q) => q.eq("recurso", args.recurso));
} else {
query = query.withIndex("by_timestamp");
}
let atividades = await query.order("desc").take(args.limite || 100);
// Filtrar por range de datas se fornecido
if (args.dataInicio || args.dataFim) {
atividades = atividades.filter((log) => {
if (args.dataInicio && log.timestamp < args.dataInicio) return false;
if (args.dataFim && log.timestamp > args.dataFim) return false;
return true;
});
}
// Buscar informações dos usuários
const atividadesComUsuarios = await Promise.all(
atividades.map(async (atividade) => {
const usuario = await ctx.db.get(atividade.usuarioId);
return {
...atividade,
usuarioNome: usuario?.nome || "Usuário Desconhecido",
usuarioMatricula: usuario?.matricula || "N/A",
};
})
);
return atividadesComUsuarios;
},
});
/**
* Obtém estatísticas de atividades
*/
export const obterEstatisticasAtividades = query({
args: {
periodo: v.optional(v.number()), // dias (ex: 7, 30)
},
handler: async (ctx, args) => {
const periodo = args.periodo || 30;
const dataInicio = Date.now() - periodo * 24 * 60 * 60 * 1000;
const atividades = await ctx.db
.query("logsAtividades")
.withIndex("by_timestamp")
.filter((q) => q.gte(q.field("timestamp"), dataInicio))
.collect();
// Agrupar por ação
const porAcao: Record<string, number> = {};
atividades.forEach((ativ) => {
porAcao[ativ.acao] = (porAcao[ativ.acao] || 0) + 1;
});
// Agrupar por recurso
const porRecurso: Record<string, number> = {};
atividades.forEach((ativ) => {
porRecurso[ativ.recurso] = (porRecurso[ativ.recurso] || 0) + 1;
});
// Agrupar por dia
const porDia: Record<string, number> = {};
atividades.forEach((ativ) => {
const data = new Date(ativ.timestamp);
const dia = data.toISOString().split("T")[0];
porDia[dia] = (porDia[dia] || 0) + 1;
});
return {
total: atividades.length,
porAcao,
porRecurso,
porDia,
};
},
});
/**
* Obtém histórico de atividades de um recurso específico
*/
export const obterHistoricoRecurso = query({
args: {
recurso: v.string(),
recursoId: v.string(),
},
handler: async (ctx, args) => {
const atividades = await ctx.db
.query("logsAtividades")
.withIndex("by_recurso_id", (q) =>
q.eq("recurso", args.recurso).eq("recursoId", args.recursoId)
)
.order("desc")
.collect();
// Buscar informações dos usuários
const atividadesComUsuarios = await Promise.all(
atividades.map(async (atividade) => {
const usuario = await ctx.db.get(atividade.usuarioId);
return {
...atividade,
usuarioNome: usuario?.nome || "Usuário Desconhecido",
usuarioMatricula: usuario?.matricula || "N/A",
};
})
);
return atividadesComUsuarios;
},
});

View File

@@ -0,0 +1,234 @@
import { v } from "convex/values";
import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server";
import { Doc, Id } from "./_generated/dataModel";
/**
* Helper para registrar tentativas de login
*/
export async function registrarLogin(
ctx: QueryCtx | MutationCtx,
dados: {
usuarioId?: Id<"usuarios">;
matriculaOuEmail: string;
sucesso: boolean;
motivoFalha?: string;
ipAddress?: string;
userAgent?: string;
}
) {
// Extrair informações do userAgent
const device = dados.userAgent ? extrairDevice(dados.userAgent) : undefined;
const browser = dados.userAgent ? extrairBrowser(dados.userAgent) : undefined;
const sistema = dados.userAgent ? extrairSistema(dados.userAgent) : undefined;
await ctx.db.insert("logsLogin", {
usuarioId: dados.usuarioId,
matriculaOuEmail: dados.matriculaOuEmail,
sucesso: dados.sucesso,
motivoFalha: dados.motivoFalha,
ipAddress: dados.ipAddress,
userAgent: dados.userAgent,
device,
browser,
sistema,
timestamp: Date.now(),
});
}
// Helpers para extrair informações do userAgent
function extrairDevice(userAgent: string): string {
if (/mobile/i.test(userAgent)) return "Mobile";
if (/tablet/i.test(userAgent)) return "Tablet";
return "Desktop";
}
function extrairBrowser(userAgent: string): string {
if (/edg/i.test(userAgent)) return "Edge";
if (/chrome/i.test(userAgent)) return "Chrome";
if (/firefox/i.test(userAgent)) return "Firefox";
if (/safari/i.test(userAgent)) return "Safari";
if (/opera/i.test(userAgent)) return "Opera";
return "Desconhecido";
}
function extrairSistema(userAgent: string): string {
if (/windows/i.test(userAgent)) return "Windows";
if (/mac/i.test(userAgent)) return "MacOS";
if (/linux/i.test(userAgent)) return "Linux";
if (/android/i.test(userAgent)) return "Android";
if (/ios/i.test(userAgent)) return "iOS";
return "Desconhecido";
}
/**
* Lista histórico de logins de um usuário
*/
export const listarLoginsUsuario = query({
args: {
usuarioId: v.id("usuarios"),
limite: v.optional(v.number()),
},
handler: async (ctx, args) => {
const logs = await ctx.db
.query("logsLogin")
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
.order("desc")
.take(args.limite || 50);
return logs;
},
});
/**
* Lista todos os logins do sistema
*/
export const listarTodosLogins = query({
args: {
limite: v.optional(v.number()),
},
handler: async (ctx, args) => {
const logs = await ctx.db
.query("logsLogin")
.withIndex("by_timestamp")
.order("desc")
.take(args.limite || 50);
return logs;
},
});
/**
* Lista tentativas de login falhadas
*/
export const listarTentativasFalhas = query({
args: {
horasAtras: v.optional(v.number()), // padrão 24h
limite: v.optional(v.number()),
},
handler: async (ctx, args) => {
const horasAtras = args.horasAtras || 24;
const dataLimite = Date.now() - horasAtras * 60 * 60 * 1000;
const logs = await ctx.db
.query("logsLogin")
.withIndex("by_sucesso", (q) => q.eq("sucesso", false))
.filter((q) => q.gte(q.field("timestamp"), dataLimite))
.order("desc")
.take(args.limite || 100);
// Agrupar por IP para detectar possíveis ataques
const porIP: Record<string, number> = {};
logs.forEach((log) => {
if (log.ipAddress) {
porIP[log.ipAddress] = (porIP[log.ipAddress] || 0) + 1;
}
});
return {
logs,
tentativasPorIP: porIP,
total: logs.length,
};
},
});
/**
* Obtém estatísticas de login
*/
export const obterEstatisticasLogin = query({
args: {
dias: v.optional(v.number()), // padrão 30 dias
},
handler: async (ctx, args) => {
const dias = args.dias || 30;
const dataInicio = Date.now() - dias * 24 * 60 * 60 * 1000;
const logs = await ctx.db
.query("logsLogin")
.withIndex("by_timestamp")
.filter((q) => q.gte(q.field("timestamp"), dataInicio))
.collect();
// Total de logins bem-sucedidos vs falhos
const sucessos = logs.filter((l) => l.sucesso).length;
const falhas = logs.filter((l) => !l.sucesso).length;
// Logins por dia
const porDia: Record<string, { sucesso: number; falha: number }> = {};
logs.forEach((log) => {
const data = new Date(log.timestamp);
const dia = data.toISOString().split("T")[0];
if (!porDia[dia]) {
porDia[dia] = { sucesso: 0, falha: 0 };
}
if (log.sucesso) {
porDia[dia].sucesso++;
} else {
porDia[dia].falha++;
}
});
// Logins por horário (hora do dia)
const porHorario: Record<number, number> = {};
logs.filter((l) => l.sucesso).forEach((log) => {
const hora = new Date(log.timestamp).getHours();
porHorario[hora] = (porHorario[hora] || 0) + 1;
});
// Browser mais usado
const porBrowser: Record<string, number> = {};
logs.filter((l) => l.sucesso).forEach((log) => {
if (log.browser) {
porBrowser[log.browser] = (porBrowser[log.browser] || 0) + 1;
}
});
// Dispositivos mais usados
const porDevice: Record<string, number> = {};
logs.filter((l) => l.sucesso).forEach((log) => {
if (log.device) {
porDevice[log.device] = (porDevice[log.device] || 0) + 1;
}
});
return {
total: logs.length,
sucessos,
falhas,
taxaSucesso: logs.length > 0 ? (sucessos / logs.length) * 100 : 0,
porDia,
porHorario,
porBrowser,
porDevice,
};
},
});
/**
* Verifica se um IP está sendo suspeito (muitas tentativas falhas)
*/
export const verificarIPSuspeito = query({
args: {
ipAddress: v.string(),
minutosAtras: v.optional(v.number()), // padrão 15 minutos
},
handler: async (ctx, args) => {
const minutosAtras = args.minutosAtras || 15;
const dataLimite = Date.now() - minutosAtras * 60 * 1000;
const tentativas = await ctx.db
.query("logsLogin")
.withIndex("by_ip", (q) => q.eq("ipAddress", args.ipAddress))
.filter((q) => q.gte(q.field("timestamp"), dataLimite))
.collect();
const falhas = tentativas.filter((t) => !t.sucesso).length;
return {
tentativasTotal: tentativas.length,
tentativasFalhas: falhas,
suspeito: falhas >= 5, // 5 ou mais tentativas falhas em 15 minutos
};
},
});

View File

@@ -93,8 +93,9 @@ export const verificarAcesso = query({
};
}
// Admin (nível 0) e TI (nível 1) têm acesso total
if (role.nivel <= 1) {
// Apenas TI_MASTER (nível 0) tem acesso total irrestrito
// Admin, TI_USUARIO e outros (nível >= 1) têm permissões configuráveis
if (role.nivel === 0) {
return {
podeAcessar: true,
podeConsultar: true,
@@ -301,7 +302,9 @@ export const obterMatrizPermissoes = query({
})
),
handler: async (ctx) => {
// Buscar todas as roles (exceto Admin e TI que têm acesso total)
// Buscar todas as roles
// TI_MASTER (nível 0) aparece mas não é editável
// Admin, TI_USUARIO e outros (nível >= 1) são configuráveis
const roles = await ctx.db.query("roles").collect();
const matriz = [];

View File

@@ -0,0 +1,210 @@
import { internalMutation, query } from "./_generated/server";
import { v } from "convex/values";
/**
* Listar usuários usando o perfil "admin" antigo (nível 0)
*/
export const listarUsuariosAdminAntigo = query({
args: {},
returns: v.array(
v.object({
_id: v.id("usuarios"),
matricula: v.string(),
nome: v.string(),
email: v.string(),
roleId: v.id("roles"),
roleNome: v.string(),
roleNivel: v.number(),
})
),
handler: async (ctx) => {
// Buscar todos os perfis "admin"
const allAdmins = await ctx.db
.query("roles")
.filter((q) => q.eq(q.field("nome"), "admin"))
.collect();
console.log("Perfis 'admin' encontrados:", allAdmins.length);
// Identificar o admin antigo (nível 0)
const adminAntigo = allAdmins.find((r) => r.nivel === 0);
if (!adminAntigo) {
console.log("Nenhum admin antigo (nível 0) encontrado");
return [];
}
console.log("Admin antigo encontrado:", adminAntigo);
// Buscar usuários usando este perfil
const usuarios = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
.collect();
console.log("Usuários usando admin antigo:", usuarios.length);
return usuarios.map((u) => ({
_id: u._id,
matricula: u.matricula,
nome: u.nome,
email: u.email || "",
roleId: u.roleId,
roleNome: adminAntigo.nome,
roleNivel: adminAntigo.nivel,
}));
},
});
/**
* Migrar usuários do perfil "admin" antigo (nível 0) para o novo (nível 2)
*/
export const migrarUsuariosParaAdminNovo = internalMutation({
args: {},
returns: v.object({
migrados: v.number(),
usuariosMigrados: v.array(
v.object({
matricula: v.string(),
nome: v.string(),
roleAntigo: v.string(),
roleNovo: v.string(),
})
),
}),
handler: async (ctx) => {
// Buscar todos os perfis "admin"
const allAdmins = await ctx.db
.query("roles")
.filter((q) => q.eq(q.field("nome"), "admin"))
.collect();
// Identificar admin antigo (nível 0) e admin novo (nível 2)
const adminAntigo = allAdmins.find((r) => r.nivel === 0);
const adminNovo = allAdmins.find((r) => r.nivel === 2);
if (!adminAntigo) {
console.log("❌ Admin antigo (nível 0) não encontrado");
return { migrados: 0, usuariosMigrados: [] };
}
if (!adminNovo) {
console.log("❌ Admin novo (nível 2) não encontrado");
return { migrados: 0, usuariosMigrados: [] };
}
console.log("✅ Admin antigo ID:", adminAntigo._id, "- Nível:", adminAntigo.nivel);
console.log("✅ Admin novo ID:", adminNovo._id, "- Nível:", adminNovo.nivel);
// Buscar usuários usando o admin antigo
const usuarios = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
.collect();
console.log(`📊 Encontrados ${usuarios.length} usuário(s) para migrar`);
const usuariosMigrados: Array<{
matricula: string;
nome: string;
roleAntigo: string;
roleNovo: string;
}> = [];
// Migrar cada usuário
for (const usuario of usuarios) {
await ctx.db.patch(usuario._id, {
roleId: adminNovo._id,
});
usuariosMigrados.push({
matricula: usuario.matricula,
nome: usuario.nome,
roleAntigo: `admin (nível 0) - ${adminAntigo._id}`,
roleNovo: `admin (nível 2) - ${adminNovo._id}`,
});
console.log(
`✅ MIGRADO: ${usuario.nome} (${usuario.matricula}) → admin nível 2`
);
}
return {
migrados: usuarios.length,
usuariosMigrados,
};
},
});
/**
* Remover perfil "admin" antigo (nível 0) após migração
*/
export const removerAdminAntigo = internalMutation({
args: {},
returns: v.object({
sucesso: v.boolean(),
mensagem: v.string(),
}),
handler: async (ctx) => {
// Buscar todos os perfis "admin"
const allAdmins = await ctx.db
.query("roles")
.filter((q) => q.eq(q.field("nome"), "admin"))
.collect();
// Identificar admin antigo (nível 0)
const adminAntigo = allAdmins.find((r) => r.nivel === 0);
if (!adminAntigo) {
return {
sucesso: false,
mensagem: "Admin antigo (nível 0) não encontrado",
};
}
// Verificar se ainda há usuários usando
const usuarios = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
.collect();
if (usuarios.length > 0) {
return {
sucesso: false,
mensagem: `Ainda há ${usuarios.length} usuário(s) usando este perfil. Execute migrarUsuariosParaAdminNovo primeiro.`,
};
}
// Remover permissões associadas
const permissoes = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
.collect();
for (const perm of permissoes) {
await ctx.db.delete(perm._id);
}
// Remover menu permissões associadas
const menuPerms = await ctx.db
.query("menuPermissoes")
.withIndex("by_role", (q) => q.eq("roleId", adminAntigo._id))
.collect();
for (const menuPerm of menuPerms) {
await ctx.db.delete(menuPerm._id);
}
// Remover o perfil
await ctx.db.delete(adminAntigo._id);
console.log(
`🗑️ REMOVIDO: Admin antigo (nível 0) - ${adminAntigo._id}`
);
return {
sucesso: true,
mensagem: "Admin antigo removido com sucesso",
};
},
});

View File

@@ -0,0 +1,346 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades";
/**
* Listar todos os perfis customizados
*/
export const listarPerfisCustomizados = query({
args: {},
handler: async (ctx) => {
const perfis = await ctx.db.query("perfisCustomizados").collect();
// Buscar role correspondente para cada perfil
const perfisComDetalhes = await Promise.all(
perfis.map(async (perfil) => {
const role = await ctx.db.get(perfil.roleId);
const criador = await ctx.db.get(perfil.criadoPor);
// Contar usuários usando este perfil
const usuarios = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
.collect();
return {
...perfil,
roleNome: role?.nome || "Desconhecido",
criadorNome: criador?.nome || "Desconhecido",
numeroUsuarios: usuarios.length,
};
})
);
return perfisComDetalhes;
},
});
/**
* Obter perfil com permissões detalhadas
*/
export const obterPerfilComPermissoes = query({
args: {
perfilId: v.id("perfisCustomizados"),
},
handler: async (ctx, args) => {
const perfil = await ctx.db.get(args.perfilId);
if (!perfil) {
return null;
}
const role = await ctx.db.get(perfil.roleId);
if (!role) {
return null;
}
// Buscar permissões do role
const rolePermissoes = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
.collect();
const permissoes = await Promise.all(
rolePermissoes.map(async (rp) => {
return await ctx.db.get(rp.permissaoId);
})
);
// Buscar permissões de menu
const menuPermissoes = await ctx.db
.query("menuPermissoes")
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
.collect();
// Buscar usuários usando este perfil
const usuarios = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
.collect();
return {
perfil,
role,
permissoes: permissoes.filter((p) => p !== null),
menuPermissoes,
usuarios,
};
},
});
/**
* Criar perfil customizado (apenas TI_MASTER)
*/
export const criarPerfilCustomizado = mutation({
args: {
nome: v.string(),
descricao: v.string(),
nivel: v.number(), // >= 3
clonarDeRoleId: v.optional(v.id("roles")), // role para copiar permissões
criadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true), perfilId: v.id("perfisCustomizados") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Validar nível (deve ser >= 3)
if (args.nivel < 3) {
return { sucesso: false as const, erro: "Perfis customizados devem ter nível >= 3" };
}
// Verificar se nome já existe
const roles = await ctx.db.query("roles").collect();
const nomeExiste = roles.some((r) => r.nome.toLowerCase() === args.nome.toLowerCase());
if (nomeExiste) {
return { sucesso: false as const, erro: "Já existe um perfil com este nome" };
}
// Criar role correspondente
const roleId = await ctx.db.insert("roles", {
nome: args.nome.toLowerCase().replace(/\s+/g, "_"),
descricao: args.descricao,
nivel: args.nivel,
customizado: true,
criadoPor: args.criadoPorId,
editavel: true,
});
// Copiar permissões se especificado
if (args.clonarDeRoleId) {
// Copiar permissões gerais
const permissoesClonar = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId))
.collect();
for (const perm of permissoesClonar) {
await ctx.db.insert("rolePermissoes", {
roleId,
permissaoId: perm.permissaoId,
});
}
// Copiar permissões de menu
const menuPermsClonar = await ctx.db
.query("menuPermissoes")
.withIndex("by_role", (q) => q.eq("roleId", args.clonarDeRoleId))
.collect();
for (const menuPerm of menuPermsClonar) {
await ctx.db.insert("menuPermissoes", {
roleId,
menuPath: menuPerm.menuPath,
podeAcessar: menuPerm.podeAcessar,
podeConsultar: menuPerm.podeConsultar,
podeGravar: menuPerm.podeGravar,
});
}
}
// Criar perfil customizado
const perfilId = await ctx.db.insert("perfisCustomizados", {
nome: args.nome,
descricao: args.descricao,
nivel: args.nivel,
roleId,
criadoPor: args.criadoPorId,
criadoEm: Date.now(),
atualizadoEm: Date.now(),
});
// Log de atividade
await registrarAtividade(
ctx,
args.criadoPorId,
"criar",
"perfis",
JSON.stringify({ perfilId, nome: args.nome, nivel: args.nivel }),
perfilId
);
return { sucesso: true as const, perfilId };
},
});
/**
* Editar perfil customizado (apenas TI_MASTER)
*/
export const editarPerfilCustomizado = mutation({
args: {
perfilId: v.id("perfisCustomizados"),
nome: v.optional(v.string()),
descricao: v.optional(v.string()),
editadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const perfil = await ctx.db.get(args.perfilId);
if (!perfil) {
return { sucesso: false as const, erro: "Perfil não encontrado" };
}
// Atualizar perfil
const updates: any = {
atualizadoEm: Date.now(),
};
if (args.nome !== undefined) updates.nome = args.nome;
if (args.descricao !== undefined) updates.descricao = args.descricao;
await ctx.db.patch(args.perfilId, updates);
// Atualizar role correspondente se nome mudou
if (args.nome !== undefined) {
await ctx.db.patch(perfil.roleId, {
nome: args.nome.toLowerCase().replace(/\s+/g, "_"),
});
}
if (args.descricao !== undefined) {
await ctx.db.patch(perfil.roleId, {
descricao: args.descricao,
});
}
// Log de atividade
await registrarAtividade(
ctx,
args.editadoPorId,
"editar",
"perfis",
JSON.stringify(updates),
args.perfilId
);
return { sucesso: true as const };
},
});
/**
* Excluir perfil customizado (apenas TI_MASTER)
*/
export const excluirPerfilCustomizado = mutation({
args: {
perfilId: v.id("perfisCustomizados"),
excluidoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const perfil = await ctx.db.get(args.perfilId);
if (!perfil) {
return { sucesso: false as const, erro: "Perfil não encontrado" };
}
// Verificar se existem usuários usando este perfil
const usuarios = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
.collect();
if (usuarios.length > 0) {
return {
sucesso: false as const,
erro: `Não é possível excluir. ${usuarios.length} usuário(s) ainda usa(m) este perfil.`,
};
}
// Remover permissões associadas ao role
const rolePermissoes = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
.collect();
for (const rp of rolePermissoes) {
await ctx.db.delete(rp._id);
}
// Remover permissões de menu
const menuPermissoes = await ctx.db
.query("menuPermissoes")
.withIndex("by_role", (q) => q.eq("roleId", perfil.roleId))
.collect();
for (const mp of menuPermissoes) {
await ctx.db.delete(mp._id);
}
// Excluir role
await ctx.db.delete(perfil.roleId);
// Excluir perfil
await ctx.db.delete(args.perfilId);
// Log de atividade
await registrarAtividade(
ctx,
args.excluidoPorId,
"excluir",
"perfis",
JSON.stringify({ perfilId: args.perfilId, nome: perfil.nome }),
args.perfilId
);
return { sucesso: true as const };
},
});
/**
* Clonar perfil existente
*/
export const clonarPerfil = mutation({
args: {
perfilOrigemId: v.id("perfisCustomizados"),
novoNome: v.string(),
novaDescricao: v.string(),
criadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true), perfilId: v.id("perfisCustomizados") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const perfilOrigem = await ctx.db.get(args.perfilOrigemId);
if (!perfilOrigem) {
return { sucesso: false as const, erro: "Perfil origem não encontrado" };
}
// Criar novo perfil clonando o original
const resultado = await criarPerfilCustomizado(ctx, {
nome: args.novoNome,
descricao: args.novaDescricao,
nivel: perfilOrigem.nivel,
clonarDeRoleId: perfilOrigem.roleId,
criadoPorId: args.criadoPorId,
});
return resultado;
},
});

View File

@@ -14,6 +14,9 @@ export const listar = query({
descricao: v.string(),
nivel: v.number(),
setor: v.optional(v.string()),
customizado: v.boolean(),
editavel: v.optional(v.boolean()),
criadoPor: v.optional(v.id("usuarios")),
})
),
handler: async (ctx) => {

View File

@@ -199,6 +199,13 @@ export default defineSchema({
criadoEm: v.number(),
atualizadoEm: v.number(),
// Controle de Bloqueio e Segurança
bloqueado: v.optional(v.boolean()),
motivoBloqueio: v.optional(v.string()),
dataBloqueio: v.optional(v.number()),
tentativasLogin: v.optional(v.number()), // contador de tentativas falhas
ultimaTentativaLogin: v.optional(v.number()), // timestamp da última tentativa
// Campos de Chat e Perfil
avatar: v.optional(v.string()), // "avatar-1" até "avatar-15" ou storageId
fotoPerfil: v.optional(v.id("_storage")),
@@ -219,17 +226,22 @@ export default defineSchema({
.index("by_email", ["email"])
.index("by_role", ["roleId"])
.index("by_ativo", ["ativo"])
.index("by_status_presenca", ["statusPresenca"]),
.index("by_status_presenca", ["statusPresenca"])
.index("by_bloqueado", ["bloqueado"]),
roles: defineTable({
nome: v.string(), // "admin", "ti", "usuario_avancado", "usuario"
nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario"
descricao: v.string(),
nivel: v.number(), // 0 = admin, 1 = ti, 2 = usuario_avancado, 3 = usuario
nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado
setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc.
customizado: v.optional(v.boolean()), // se é um perfil customizado criado por TI_MASTER
criadoPor: v.optional(v.id("usuarios")), // usuário TI_MASTER que criou este perfil
editavel: v.optional(v.boolean()), // se pode ser editado (false para roles fixas)
})
.index("by_nome", ["nome"])
.index("by_nivel", ["nivel"])
.index("by_setor", ["setor"]),
.index("by_setor", ["setor"])
.index("by_customizado", ["customizado"]),
permissoes: defineTable({
nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc.
@@ -305,6 +317,129 @@ export default defineSchema({
.index("by_tipo", ["tipo"])
.index("by_timestamp", ["timestamp"]),
// Logs de Login Detalhados
logsLogin: defineTable({
usuarioId: v.optional(v.id("usuarios")), // pode ser null se falha antes de identificar usuário
matriculaOuEmail: v.string(), // tentativa de login
sucesso: v.boolean(),
motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente"
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
device: v.optional(v.string()),
browser: v.optional(v.string()),
sistema: v.optional(v.string()),
timestamp: v.number(),
})
.index("by_usuario", ["usuarioId"])
.index("by_sucesso", ["sucesso"])
.index("by_timestamp", ["timestamp"])
.index("by_ip", ["ipAddress"]),
// Logs de Atividades
logsAtividades: defineTable({
usuarioId: v.id("usuarios"),
acao: v.string(), // "criar", "editar", "excluir", "bloquear", "desbloquear", etc.
recurso: v.string(), // "funcionarios", "simbolos", "usuarios", "perfis", etc.
recursoId: v.optional(v.string()), // ID do recurso afetado
detalhes: v.optional(v.string()), // JSON com detalhes da ação
timestamp: v.number(),
})
.index("by_usuario", ["usuarioId"])
.index("by_acao", ["acao"])
.index("by_recurso", ["recurso"])
.index("by_timestamp", ["timestamp"])
.index("by_recurso_id", ["recurso", "recursoId"]),
// Histórico de Bloqueios
bloqueiosUsuarios: defineTable({
usuarioId: v.id("usuarios"),
motivo: v.string(),
bloqueadoPor: v.id("usuarios"), // ID do TI_MASTER que bloqueou
dataInicio: v.number(),
dataFim: v.optional(v.number()), // quando foi desbloqueado
desbloqueadoPor: v.optional(v.id("usuarios")),
ativo: v.boolean(), // se é o bloqueio atual ativo
})
.index("by_usuario", ["usuarioId"])
.index("by_bloqueado_por", ["bloqueadoPor"])
.index("by_ativo", ["ativo"])
.index("by_data_inicio", ["dataInicio"]),
// Perfis Customizados
perfisCustomizados: defineTable({
nome: v.string(),
descricao: v.string(),
nivel: v.number(), // >= 3
roleId: v.id("roles"), // role correspondente criada
criadoPor: v.id("usuarios"), // TI_MASTER que criou
criadoEm: v.number(),
atualizadoEm: v.number(),
})
.index("by_nome", ["nome"])
.index("by_nivel", ["nivel"])
.index("by_criado_por", ["criadoPor"])
.index("by_role", ["roleId"]),
// Templates de Mensagens
templatesMensagens: defineTable({
codigo: v.string(), // "USUARIO_BLOQUEADO", "SENHA_RESETADA", etc.
nome: v.string(),
tipo: v.union(
v.literal("sistema"), // predefinido, não editável
v.literal("customizado") // criado por TI_MASTER
),
titulo: v.string(),
corpo: v.string(), // pode ter variáveis {{variavel}}
variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.]
criadoPor: v.optional(v.id("usuarios")),
criadoEm: v.number(),
})
.index("by_codigo", ["codigo"])
.index("by_tipo", ["tipo"])
.index("by_criado_por", ["criadoPor"]),
// Configuração de Email/SMTP
configuracaoEmail: defineTable({
servidor: v.string(), // smtp.gmail.com
porta: v.number(), // 587, 465, etc.
usuario: v.string(),
senhaHash: v.string(), // senha criptografada
emailRemetente: v.string(),
nomeRemetente: v.string(),
usarSSL: v.boolean(),
usarTLS: v.boolean(),
ativo: v.boolean(),
testadoEm: v.optional(v.number()),
configuradoPor: v.id("usuarios"),
atualizadoEm: v.number(),
})
.index("by_ativo", ["ativo"]),
// Fila de Emails
notificacoesEmail: defineTable({
destinatario: v.string(), // email
destinatarioId: v.optional(v.id("usuarios")),
assunto: v.string(),
corpo: v.string(), // HTML ou texto
templateId: v.optional(v.id("templatesMensagens")),
status: v.union(
v.literal("pendente"),
v.literal("enviando"),
v.literal("enviado"),
v.literal("falha")
),
tentativas: v.number(),
ultimaTentativa: v.optional(v.number()),
erroDetalhes: v.optional(v.string()),
enviadoPor: v.id("usuarios"),
criadoEm: v.number(),
enviadoEm: v.optional(v.number()),
})
.index("by_status", ["status"])
.index("by_destinatario", ["destinatarioId"])
.index("by_enviado_por", ["enviadoPor"])
.index("by_criado_em", ["criadoEm"]),
configuracaoAcesso: defineTable({
chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc.
valor: v.string(),

View File

@@ -191,52 +191,184 @@ export const seedDatabase = internalMutation({
handler: async (ctx) => {
console.log("🌱 Iniciando seed do banco de dados...");
// 1. Criar Roles
// 1. Criar Roles (Perfis de Acesso)
console.log("🔐 Criando roles...");
// TI_MASTER - Nível 0 - Acesso total irrestrito
const roleTIMaster = await ctx.db.insert("roles", {
nome: "ti_master",
descricao: "TI Master",
nivel: 0,
setor: "ti",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: ti_master (Nível 0 - Acesso Total)");
// ADMIN - Nível 2 - Permissões configuráveis
const roleAdmin = await ctx.db.insert("roles", {
nome: "admin",
descricao: "Administrador do Sistema",
nivel: 0,
});
console.log(" ✅ Role criada: admin");
const roleTI = await ctx.db.insert("roles", {
nome: "ti",
descricao: "Tecnologia da Informação",
nivel: 1,
setor: "ti",
});
console.log(" ✅ Role criada: ti");
const roleUsuarioAvancado = await ctx.db.insert("roles", {
nome: "usuario_avancado",
descricao: "Usuário Avançado",
descricao: "Administrador Geral",
nivel: 2,
setor: "administrativo",
customizado: false,
editavel: true, // Permissões configuráveis
});
console.log(" ✅ Role criada: usuario_avancado");
console.log(" ✅ Role criada: admin (Nível 2 - Configurável)");
// TI_USUARIO - Nível 2 - Suporte técnico
const roleTIUsuario = await ctx.db.insert("roles", {
nome: "ti_usuario",
descricao: "TI Usuário",
nivel: 2,
setor: "ti",
customizado: false,
editavel: true,
});
console.log(" ✅ Role criada: ti_usuario (Nível 2 - Suporte)");
const roleRH = await ctx.db.insert("roles", {
nome: "rh",
descricao: "Recursos Humanos",
nivel: 2,
setor: "recursos_humanos",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: rh");
const roleFinanceiro = await ctx.db.insert("roles", {
nome: "financeiro",
descricao: "Financeiro",
nivel: 2,
setor: "financeiro",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: financeiro");
const roleControladoria = await ctx.db.insert("roles", {
nome: "controladoria",
descricao: "Controladoria",
nivel: 2,
setor: "controladoria",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: controladoria");
const roleLicitacoes = await ctx.db.insert("roles", {
nome: "licitacoes",
descricao: "Licitações",
nivel: 2,
setor: "licitacoes",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: licitacoes");
const roleCompras = await ctx.db.insert("roles", {
nome: "compras",
descricao: "Compras",
nivel: 2,
setor: "compras",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: compras");
const roleJuridico = await ctx.db.insert("roles", {
nome: "juridico",
descricao: "Jurídico",
nivel: 2,
setor: "juridico",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: juridico");
const roleComunicacao = await ctx.db.insert("roles", {
nome: "comunicacao",
descricao: "Comunicação",
nivel: 2,
setor: "comunicacao",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: comunicacao");
const roleProgramasEsportivos = await ctx.db.insert("roles", {
nome: "programas_esportivos",
descricao: "Programas Esportivos",
nivel: 2,
setor: "programas_esportivos",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: programas_esportivos");
const roleSecretariaExecutiva = await ctx.db.insert("roles", {
nome: "secretaria_executiva",
descricao: "Secretaria Executiva",
nivel: 2,
setor: "secretaria_executiva",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: secretaria_executiva");
const roleGestaoPessoas = await ctx.db.insert("roles", {
nome: "gestao_pessoas",
descricao: "Gestão de Pessoas",
nivel: 2,
setor: "gestao_pessoas",
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: gestao_pessoas");
const roleUsuario = await ctx.db.insert("roles", {
nome: "usuario",
descricao: "Usuário Comum",
nivel: 3,
nivel: 10,
customizado: false,
editavel: false,
});
console.log(" ✅ Role criada: usuario");
// 2. Criar usuário admin inicial
console.log("👤 Criando usuário admin...");
const senhaAdmin = await hashPassword("Admin@123");
// 2. Criar usuários iniciais
console.log("👤 Criando usuários iniciais...");
// TI Master
const senhaTIMaster = await hashPassword("TI@123");
await ctx.db.insert("usuarios", {
matricula: "0000",
matricula: "1000",
senhaHash: senhaTIMaster,
nome: "Gestor TI Master",
email: "ti.master@sgse.pe.gov.br",
setor: "ti",
roleId: roleTIMaster as any,
ativo: true,
primeiroAcesso: false,
criadoEm: Date.now(),
atualizadoEm: Date.now(),
});
console.log(" ✅ TI Master criado (matrícula: 1000, senha: TI@123)");
// Admin (permissões configuráveis)
const senhaAdmin = await hashPassword("Admin@123");
const adminId = await ctx.db.insert("usuarios", {
matricula: "2000",
senhaHash: senhaAdmin,
nome: "Administrador",
nome: "Administrador Geral",
email: "admin@sgse.pe.gov.br",
setor: "administrativo",
roleId: roleAdmin as any,
ativo: true,
primeiroAcesso: false,
criadoEm: Date.now(),
atualizadoEm: Date.now(),
});
console.log(" ✅ Usuário admin criado (matrícula: 0000, senha: Admin@123)");
console.log(" ✅ Admin criado (matrícula: 2000, senha: Admin@123)");
// 3. Inserir símbolos
console.log("📝 Inserindo símbolos...");
@@ -323,10 +455,71 @@ export const seedDatabase = internalMutation({
console.log(` ✅ Solicitação criada: ${solicitacao.nome}`);
}
// 7. Criar templates de mensagens padrão
console.log("📧 Criando templates de mensagens padrão...");
const templatesPadrao = [
{
codigo: "USUARIO_BLOQUEADO",
nome: "Usuário Bloqueado",
titulo: "Sua conta foi bloqueada",
corpo: "Sua conta no SGSE foi bloqueada.\\n\\nMotivo: {{motivo}}\\n\\nPara mais informações, entre em contato com a TI.",
variaveis: ["motivo"],
},
{
codigo: "USUARIO_DESBLOQUEADO",
nome: "Usuário Desbloqueado",
titulo: "Sua conta foi desbloqueada",
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
variaveis: [],
},
{
codigo: "SENHA_RESETADA",
nome: "Senha Resetada",
titulo: "Sua senha foi resetada",
corpo: "Sua senha foi resetada pela equipe de TI.\\n\\nNova senha temporária: {{senha}}\\n\\nPor favor, altere sua senha no próximo login.",
variaveis: ["senha"],
},
{
codigo: "PERMISSAO_ALTERADA",
nome: "Permissão Alterada",
titulo: "Suas permissões foram atualizadas",
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\\n\\nPara verificar suas novas permissões, acesse o menu de perfil.",
variaveis: [],
},
{
codigo: "AVISO_GERAL",
nome: "Aviso Geral",
titulo: "{{titulo}}",
corpo: "{{mensagem}}",
variaveis: ["titulo", "mensagem"],
},
{
codigo: "BEM_VINDO",
nome: "Boas-vindas",
titulo: "Bem-vindo ao SGSE",
corpo: "Olá {{nome}},\\n\\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\\n\\nSuas credenciais de acesso:\\nMatrícula: {{matricula}}\\nSenha temporária: {{senha}}\\n\\nPor favor, altere sua senha no primeiro acesso.\\n\\nEquipe de TI",
variaveis: ["nome", "matricula", "senha"],
},
];
for (const template of templatesPadrao) {
await ctx.db.insert("templatesMensagens", {
codigo: template.codigo,
nome: template.nome,
tipo: "sistema" as const,
titulo: template.titulo,
corpo: template.corpo,
variaveis: template.variaveis,
criadoEm: Date.now(),
});
console.log(` ✅ Template criado: ${template.nome}`);
}
console.log("✨ Seed concluído com sucesso!");
console.log("");
console.log("🔑 CREDENCIAIS DE ACESSO:");
console.log(" Admin: matrícula 0000, senha Admin@123");
console.log(" TI: matrícula 1000, senha TI@123");
console.log(" Funcionários: usar matrícula, senha Mudar@123");
return null;
},

View File

@@ -0,0 +1,261 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { registrarAtividade } from "./logsAtividades";
/**
* Listar todos os templates
*/
export const listarTemplates = query({
args: {},
handler: async (ctx) => {
const templates = await ctx.db.query("templatesMensagens").collect();
return templates;
},
});
/**
* Obter template por código
*/
export const obterTemplatePorCodigo = query({
args: {
codigo: v.string(),
},
handler: async (ctx, args) => {
const template = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
.first();
return template;
},
});
/**
* Criar template customizado (apenas TI_MASTER)
*/
export const criarTemplate = mutation({
args: {
codigo: v.string(),
nome: v.string(),
titulo: v.string(),
corpo: v.string(),
variaveis: v.optional(v.array(v.string())),
criadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true), templateId: v.id("templatesMensagens") }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Verificar se código já existe
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", args.codigo))
.first();
if (existente) {
return { sucesso: false as const, erro: "Código de template já existe" };
}
// Criar template
const templateId = await ctx.db.insert("templatesMensagens", {
codigo: args.codigo,
nome: args.nome,
tipo: "customizado",
titulo: args.titulo,
corpo: args.corpo,
variaveis: args.variaveis,
criadoPor: args.criadoPorId,
criadoEm: Date.now(),
});
// Log de atividade
await registrarAtividade(
ctx,
args.criadoPorId,
"criar",
"templates",
JSON.stringify({ templateId, codigo: args.codigo, nome: args.nome }),
templateId
);
return { sucesso: true as const, templateId };
},
});
/**
* Editar template customizado (apenas TI_MASTER, não edita templates do sistema)
*/
export const editarTemplate = mutation({
args: {
templateId: v.id("templatesMensagens"),
nome: v.optional(v.string()),
titulo: v.optional(v.string()),
corpo: v.optional(v.string()),
variaveis: v.optional(v.array(v.string())),
editadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Não permite editar templates do sistema
if (template.tipo === "sistema") {
return { sucesso: false as const, erro: "Templates do sistema não podem ser editados" };
}
// Atualizar template
const updates: any = {};
if (args.nome !== undefined) updates.nome = args.nome;
if (args.titulo !== undefined) updates.titulo = args.titulo;
if (args.corpo !== undefined) updates.corpo = args.corpo;
if (args.variaveis !== undefined) updates.variaveis = args.variaveis;
await ctx.db.patch(args.templateId, updates);
// Log de atividade
await registrarAtividade(
ctx,
args.editadoPorId,
"editar",
"templates",
JSON.stringify(updates),
args.templateId
);
return { sucesso: true as const };
},
});
/**
* Excluir template customizado (apenas TI_MASTER, não exclui templates do sistema)
*/
export const excluirTemplate = mutation({
args: {
templateId: v.id("templatesMensagens"),
excluidoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const template = await ctx.db.get(args.templateId);
if (!template) {
return { sucesso: false as const, erro: "Template não encontrado" };
}
// Não permite excluir templates do sistema
if (template.tipo === "sistema") {
return { sucesso: false as const, erro: "Templates do sistema não podem ser excluídos" };
}
// Excluir template
await ctx.db.delete(args.templateId);
// Log de atividade
await registrarAtividade(
ctx,
args.excluidoPorId,
"excluir",
"templates",
JSON.stringify({ templateId: args.templateId, codigo: template.codigo }),
args.templateId
);
return { sucesso: true as const };
},
});
/**
* Renderizar template com variáveis
*/
export function renderizarTemplate(template: string, variaveis: Record<string, string>): string {
let resultado = template;
for (const [chave, valor] of Object.entries(variaveis)) {
const placeholder = `{{${chave}}}`;
resultado = resultado.replace(new RegExp(placeholder, "g"), valor);
}
return resultado;
}
/**
* Criar templates padrão do sistema (chamado no seed)
*/
export const criarTemplatesPadrao = mutation({
args: {},
handler: async (ctx) => {
const templatesPadrao = [
{
codigo: "USUARIO_BLOQUEADO",
nome: "Usuário Bloqueado",
titulo: "Sua conta foi bloqueada",
corpo: "Sua conta no SGSE foi bloqueada.\n\nMotivo: {{motivo}}\n\nPara mais informações, entre em contato com a TI.",
variaveis: ["motivo"],
},
{
codigo: "USUARIO_DESBLOQUEADO",
nome: "Usuário Desbloqueado",
titulo: "Sua conta foi desbloqueada",
corpo: "Sua conta no SGSE foi desbloqueada e você já pode acessar o sistema normalmente.",
variaveis: [],
},
{
codigo: "SENHA_RESETADA",
nome: "Senha Resetada",
titulo: "Sua senha foi resetada",
corpo: "Sua senha foi resetada pela equipe de TI.\n\nNova senha temporária: {{senha}}\n\nPor favor, altere sua senha no próximo login.",
variaveis: ["senha"],
},
{
codigo: "PERMISSAO_ALTERADA",
nome: "Permissão Alterada",
titulo: "Suas permissões foram atualizadas",
corpo: "Suas permissões de acesso ao sistema foram atualizadas.\n\nPara verificar suas novas permissões, acesse o menu de perfil.",
variaveis: [],
},
{
codigo: "AVISO_GERAL",
nome: "Aviso Geral",
titulo: "{{titulo}}",
corpo: "{{mensagem}}",
variaveis: ["titulo", "mensagem"],
},
{
codigo: "BEM_VINDO",
nome: "Boas-vindas",
titulo: "Bem-vindo ao SGSE",
corpo: "Olá {{nome}},\n\nSeja bem-vindo ao Sistema de Gestão da Secretaria de Esportes!\n\nSuas credenciais de acesso:\nMatrícula: {{matricula}}\nSenha temporária: {{senha}}\n\nPor favor, altere sua senha no primeiro acesso.\n\nEquipe de TI",
variaveis: ["nome", "matricula", "senha"],
},
];
for (const template of templatesPadrao) {
// Verificar se já existe
const existente = await ctx.db
.query("templatesMensagens")
.withIndex("by_codigo", (q) => q.eq("codigo", template.codigo))
.first();
if (!existente) {
await ctx.db.insert("templatesMensagens", {
...template,
tipo: "sistema",
criadoEm: Date.now(),
});
}
}
return { sucesso: true };
},
});

View File

@@ -1,6 +1,8 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { hashPassword } from "./auth/utils";
import { hashPassword, generateToken } from "./auth/utils";
import { registrarAtividade } from "./logsAtividades";
import { Id } from "./_generated/dataModel";
/**
* Criar novo usuário (apenas TI)
@@ -76,6 +78,8 @@ export const listar = query({
nome: v.string(),
email: v.string(),
ativo: v.boolean(),
bloqueado: v.optional(v.boolean()),
motivoBloqueio: v.optional(v.string()),
primeiroAcesso: v.boolean(),
ultimoAcesso: v.optional(v.number()),
criadoEm: v.number(),
@@ -141,6 +145,8 @@ export const listar = query({
nome: usuario.nome,
email: usuario.email,
ativo: usuario.ativo,
bloqueado: usuario.bloqueado,
motivoBloqueio: usuario.motivoBloqueio,
primeiroAcesso: usuario.primeiroAcesso,
ultimoAcesso: usuario.ultimoAcesso,
criadoEm: usuario.criadoEm,
@@ -569,3 +575,362 @@ export const uploadFotoPerfil = mutation({
},
});
// ==================== GESTÃO AVANÇADA DE USUÁRIOS (TI_MASTER) ====================
/**
* Bloquear usuário (apenas TI_MASTER)
*/
export const bloquearUsuario = mutation({
args: {
usuarioId: v.id("usuarios"),
motivo: v.string(),
bloqueadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario) {
return { sucesso: false as const, erro: "Usuário não encontrado" };
}
// Atualizar usuário como bloqueado
await ctx.db.patch(args.usuarioId, {
bloqueado: true,
motivoBloqueio: args.motivo,
dataBloqueio: Date.now(),
atualizadoEm: Date.now(),
});
// Registrar no histórico de bloqueios
await ctx.db.insert("bloqueiosUsuarios", {
usuarioId: args.usuarioId,
motivo: args.motivo,
bloqueadoPor: args.bloqueadoPorId,
dataInicio: Date.now(),
ativo: true,
});
// Desativar todas as sessões ativas do usuário
const sessoes = await ctx.db
.query("sessoes")
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
for (const sessao of sessoes) {
await ctx.db.patch(sessao._id, { ativo: false });
}
// Log de atividade
await registrarAtividade(
ctx,
args.bloqueadoPorId,
"bloquear",
"usuarios",
JSON.stringify({ usuarioId: args.usuarioId, motivo: args.motivo }),
args.usuarioId
);
return { sucesso: true as const };
},
});
/**
* Desbloquear usuário (apenas TI_MASTER)
*/
export const desbloquearUsuario = mutation({
args: {
usuarioId: v.id("usuarios"),
desbloqueadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario) {
return { sucesso: false as const, erro: "Usuário não encontrado" };
}
// Atualizar usuário como desbloqueado
await ctx.db.patch(args.usuarioId, {
bloqueado: false,
motivoBloqueio: undefined,
dataBloqueio: undefined,
tentativasLogin: 0,
ultimaTentativaLogin: undefined,
atualizadoEm: Date.now(),
});
// Fechar bloqueios ativos
const bloqueiosAtivos = await ctx.db
.query("bloqueiosUsuarios")
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
for (const bloqueio of bloqueiosAtivos) {
await ctx.db.patch(bloqueio._id, {
ativo: false,
dataFim: Date.now(),
desbloqueadoPor: args.desbloqueadoPorId,
});
}
// Log de atividade
await registrarAtividade(
ctx,
args.desbloqueadoPorId,
"desbloquear",
"usuarios",
JSON.stringify({ usuarioId: args.usuarioId }),
args.usuarioId
);
return { sucesso: true as const };
},
});
/**
* Resetar senha de usuário (apenas TI_MASTER)
*/
export const resetarSenhaUsuario = mutation({
args: {
usuarioId: v.id("usuarios"),
resetadoPorId: v.id("usuarios"),
novaSenhaTemporaria: v.optional(v.string()), // Se não fornecer, gera automática
},
returns: v.union(
v.object({ sucesso: v.literal(true), senhaTemporaria: v.string() }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario) {
return { sucesso: false as const, erro: "Usuário não encontrado" };
}
// Gerar senha temporária se não foi fornecida
const senhaTemporaria = args.novaSenhaTemporaria || gerarSenhaTemporaria();
const senhaHash = await hashPassword(senhaTemporaria);
// Atualizar usuário
await ctx.db.patch(args.usuarioId, {
senhaHash,
primeiroAcesso: true, // Força mudança de senha no próximo login
tentativasLogin: 0,
ultimaTentativaLogin: undefined,
atualizadoEm: Date.now(),
});
// Log de atividade
await registrarAtividade(
ctx,
args.resetadoPorId,
"resetar_senha",
"usuarios",
JSON.stringify({ usuarioId: args.usuarioId }),
args.usuarioId
);
return { sucesso: true as const, senhaTemporaria };
},
});
// Helper para gerar senha temporária
function gerarSenhaTemporaria(): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%";
let senha = "";
for (let i = 0; i < 12; i++) {
senha += chars.charAt(Math.floor(Math.random() * chars.length));
}
return senha;
}
/**
* Editar dados de usuário (apenas TI_MASTER)
*/
export const editarUsuario = mutation({
args: {
usuarioId: v.id("usuarios"),
nome: v.optional(v.string()),
email: v.optional(v.string()),
roleId: v.optional(v.id("roles")),
setor: v.optional(v.string()),
editadoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario) {
return { sucesso: false as const, erro: "Usuário não encontrado" };
}
// Verificar se email já existe (se estiver mudando)
if (args.email && args.email !== usuario.email) {
const emailExistente = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", args.email!))
.first();
if (emailExistente) {
return { sucesso: false as const, erro: "E-mail já cadastrado" };
}
}
// Atualizar campos fornecidos
const updates: any = {
atualizadoEm: Date.now(),
};
if (args.nome !== undefined) updates.nome = args.nome;
if (args.email !== undefined) updates.email = args.email;
if (args.roleId !== undefined) updates.roleId = args.roleId;
if (args.setor !== undefined) updates.setor = args.setor;
await ctx.db.patch(args.usuarioId, updates);
// Log de atividade
await registrarAtividade(
ctx,
args.editadoPorId,
"editar",
"usuarios",
JSON.stringify(updates),
args.usuarioId
);
return { sucesso: true as const };
},
});
/**
* Desativar usuário logicamente (soft delete - apenas TI_MASTER)
*/
export const excluirUsuarioLogico = mutation({
args: {
usuarioId: v.id("usuarios"),
excluidoPorId: v.id("usuarios"),
},
returns: v.union(
v.object({ sucesso: v.literal(true) }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuario = await ctx.db.get(args.usuarioId);
if (!usuario) {
return { sucesso: false as const, erro: "Usuário não encontrado" };
}
// Marcar como inativo
await ctx.db.patch(args.usuarioId, {
ativo: false,
atualizadoEm: Date.now(),
});
// Desativar todas as sessões
const sessoes = await ctx.db
.query("sessoes")
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
.filter((q) => q.eq(q.field("ativo"), true))
.collect();
for (const sessao of sessoes) {
await ctx.db.patch(sessao._id, { ativo: false });
}
// Log de atividade
await registrarAtividade(
ctx,
args.excluidoPorId,
"excluir",
"usuarios",
JSON.stringify({ usuarioId: args.usuarioId }),
args.usuarioId
);
return { sucesso: true as const };
},
});
/**
* Criar usuário completo com permissões (TI_MASTER)
*/
export const criarUsuarioCompleto = mutation({
args: {
matricula: v.string(),
nome: v.string(),
email: v.string(),
roleId: v.id("roles"),
setor: v.optional(v.string()),
senhaInicial: v.optional(v.string()),
criadoPorId: v.id("usuarios"),
enviarEmailBoasVindas: v.optional(v.boolean()),
},
returns: v.union(
v.object({ sucesso: v.literal(true), usuarioId: v.id("usuarios"), senhaTemporaria: v.string() }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Verificar se matrícula já existe
const existente = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
if (existente) {
return { sucesso: false as const, erro: "Matrícula já cadastrada" };
}
// Verificar se email já existe
const emailExistente = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
if (emailExistente) {
return { sucesso: false as const, erro: "E-mail já cadastrado" };
}
// Gerar senha inicial se não fornecida
const senhaTemporaria = args.senhaInicial || gerarSenhaTemporaria();
const senhaHash = await hashPassword(senhaTemporaria);
// Criar usuário
const usuarioId = await ctx.db.insert("usuarios", {
matricula: args.matricula,
senhaHash,
nome: args.nome,
email: args.email,
roleId: args.roleId,
setor: args.setor,
ativo: true,
primeiroAcesso: true,
criadoEm: Date.now(),
atualizadoEm: Date.now(),
});
// Log de atividade
await registrarAtividade(
ctx,
args.criadoPorId,
"criar",
"usuarios",
JSON.stringify({ usuarioId, matricula: args.matricula, nome: args.nome }),
usuarioId
);
// TODO: Se enviarEmailBoasVindas = true, enfileirar email
// Isso será implementado quando criarmos o sistema de emails
return { sucesso: true as const, usuarioId, senhaTemporaria };
},
});

View File

@@ -0,0 +1,101 @@
import { internalMutation, query } from "./_generated/server";
import { v } from "convex/values";
/**
* Verificar duplicatas de matrícula
*/
export const verificarDuplicatas = query({
args: {},
returns: v.array(
v.object({
matricula: v.string(),
count: v.number(),
usuarios: v.array(
v.object({
_id: v.id("usuarios"),
nome: v.string(),
email: v.string(),
})
),
})
),
handler: async (ctx) => {
const usuarios = await ctx.db.query("usuarios").collect();
// Agrupar por matrícula
const gruposPorMatricula = usuarios.reduce((acc, usuario) => {
if (!acc[usuario.matricula]) {
acc[usuario.matricula] = [];
}
acc[usuario.matricula].push({
_id: usuario._id,
nome: usuario.nome,
email: usuario.email || "",
});
return acc;
}, {} as Record<string, any[]>);
// Filtrar apenas duplicatas
const duplicatas = Object.entries(gruposPorMatricula)
.filter(([_, usuarios]) => usuarios.length > 1)
.map(([matricula, usuarios]) => ({
matricula,
count: usuarios.length,
usuarios,
}));
return duplicatas;
},
});
/**
* Remover duplicatas mantendo apenas o mais recente
*/
export const removerDuplicatas = internalMutation({
args: {},
returns: v.object({
removidos: v.number(),
matriculas: v.array(v.string()),
}),
handler: async (ctx) => {
const usuarios = await ctx.db.query("usuarios").collect();
// Agrupar por matrícula
const gruposPorMatricula = usuarios.reduce((acc, usuario) => {
if (!acc[usuario.matricula]) {
acc[usuario.matricula] = [];
}
acc[usuario.matricula].push(usuario);
return acc;
}, {} as Record<string, any[]>);
let removidos = 0;
const matriculasDuplicadas: string[] = [];
// Para cada grupo com duplicatas
for (const [matricula, usuariosGrupo] of Object.entries(gruposPorMatricula)) {
if (usuariosGrupo.length > 1) {
matriculasDuplicadas.push(matricula);
// Ordenar por _creationTime (mais recente primeiro)
usuariosGrupo.sort((a, b) => b._creationTime - a._creationTime);
// Manter o primeiro (mais recente) e remover os outros
for (let i = 1; i < usuariosGrupo.length; i++) {
await ctx.db.delete(usuariosGrupo[i]._id);
removidos++;
console.log(`🗑️ Removido usuário duplicado: ${usuariosGrupo[i].nome} (matrícula: ${matricula})`);
}
console.log(`✅ Mantido usuário: ${usuariosGrupo[0].nome} (matrícula: ${matricula})`);
}
}
return {
removidos,
matriculas: matriculasDuplicadas,
};
},
});