/* Tela de auditoria — ESP-0008, RF-AUDITORIA-07 a 17. É a primeira tela do sistema que precisa de paginação de verdade: a parte imortal da tabela (RF-AUDITORIA-13) cresce indefinidamente e nunca é apagada. */ const OPERACOES = [ ["usuario.criado", "Usuário criado"], ["usuario.atualizado", "Usuário atualizado"], ["usuario.perfil_alterado", "Perfil alterado"], ["usuario.ativado", "Usuário ativado"], ["usuario.desativado", "Usuário desativado"], ["usuario.senha_redefinida", "Senha redefinida"], ["usuario.senha_alterada", "Senha alterada pelo próprio"], ["usuario.senha_recuperada", "Senha recuperada (provisionamento)"], ["usuario.bloqueado", "Usuário bloqueado (tentativas incorretas)"], ["usuario.desbloqueado", "Usuário desbloqueado"], ["caso.criado", "Caso criado"], ["caso.atualizado", "Caso atualizado"], ["caso.publicado", "Caso publicado"], ["caso.despublicado", "Caso despublicado"], ["caso.arquivado", "Caso arquivado"], ["caso.restaurado", "Caso restaurado"], ["dispositivo.registro_solicitado", "Registro solicitado (pelo app)"], ["dispositivo.aprovado", "Dispositivo aprovado"], ["dispositivo.recusado", "Solicitação recusada"], ["dispositivo.editado", "Rótulo do dispositivo editado"], ["dispositivo.ativado", "Dispositivo ativado"], ["dispositivo.desativado", "Dispositivo desativado"], ["dispositivo.credencial_resgatada", "Credencial resgatada (pelo app)"], ["dispositivo.credencial_rotacionada", "Credencial rotacionada"], ["auditoria.limpeza_filtrada", "Limpeza do resultado"], ["auditoria.limpeza_total", "Limpeza total"], ]; const ROTULO_DA_OPERACAO = Object.fromEntries(OPERACOES); // O grupo de cada operação vem do próprio código (prefixo antes do ".") — é o // mesmo valor que a coluna `dominio` grava no backend, então um domínio novo // (ex.: `dispositivo.*`, quando o cadastro de dispositivos existir) só precisa // de linhas novas em OPERACOES, sem um segundo mapa para manter sincronizado. const GRUPOS_ROTULO = { usuario: "Usuário", caso: "Casos", dispositivo: "Dispositivos", auditoria: "Auditoria" }; const OPERACOES_POR_GRUPO = OPERACOES.reduce((grupos, entrada) => { const grupo = entrada[0].split(".")[0]; (grupos[grupo] = grupos[grupo] || []).push(entrada); return grupos; }, {}); // Card de filtro recolhível por grupo: com o catálogo crescendo (hoje 18 // tipos, entre usuário, caso e auditoria) uma grade única tomaria o card // inteiro antes mesmo da tabela aparecer. Cada grupo abre sob demanda; o // contador no cabeçalho mostra o que está ativo sem precisar abrir. function GrupoDeOperacoes({ rotulo, operacoes, selecionadas, aberto, onToggle, onAlternar }) { const contagem = operacoes.filter(([codigo]) => selecionadas.includes(codigo)).length; return (
{aberto && (
{operacoes.map(([codigo, rotuloOp]) => ( onAlternar(codigo)}> {rotuloOp} ))}
)}
); } const EXPRESSAO_DE_CONFIRMACAO = "apagar logs"; function dataParaISO(mascarada) { const casado = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(mascarada || ""); return casado ? `${casado[3]}-${casado[2]}-${casado[1]}` : null; } function momentoLegivel(iso) { const data = new Date(iso); if (isNaN(data)) return iso; return data.toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "medium" }); } function autorLegivel(registro) { // RF-AUDITORIA-19: sem autor humano, exibir o processo como tal — nunca // simular um usuário que não existe. if (!registro.autor_id) { return {registro.autor_nome || "provisionamento"} · processo; } return ( <>
{registro.autor_nome}
{registro.autor_email}
); } function detalhesLegiveis(detalhes) { const entradas = Object.entries(detalhes || {}); if (!entradas.length) return "—"; return entradas.map(([campo, valor]) => { if (valor && typeof valor === "object" && "de" in valor) { return `${campo}: ${valor.de ?? "—"} → ${valor.para ?? "—"}`; } return `${campo}: ${typeof valor === "object" ? JSON.stringify(valor) : valor}`; }).join(" · "); } /* --- Limpeza total: três camadas (RF-AUDITORIA-15, 16, 17) --------------- As camadas são proteção contra o acidente. O controle está no backend (RF-AUDITORIA-18), que exige a expressão e a senha no corpo da requisição — uma requisição construída à mão contornaria as três se a verificação vivesse apenas aqui. */ function LimpezaTotalModal({ contagem, onConfirmar, onFechar, busy, erro }) { const [camada, setCamada] = React.useState(1); const [ciente, setCiente] = React.useState(false); const [expressao, setExpressao] = React.useState(""); const [senha, setSenha] = React.useState(""); const expressaoExata = expressao === EXPRESSAO_DE_CONFIRMACAO; return (
!busy && onFechar()}>
e.stopPropagation()}>

Apagar todos os logs — etapa {camada} de 3

{camada === 1 && ( Todos os {contagem.removiveis.toLocaleString("pt-BR")} logs de auditoria serão apagados. A base de logs existente não terá possibilidade de recuperação. {contagem.preservados > 0 && (
{contagem.preservados.toLocaleString("pt-BR")} registro(s) de operações de limpeza não serão apagados e continuarão na base.
)}
)} {camada === 2 && ( <> {}}> Reiterando: todos os logs serão apagados e a base existente não terá possibilidade de recuperação. )} {camada === 3 && ( <> Reapresente a sua senha. Esta é a única etapa que prova quem está executando a operação. )} {erro && ( {erro.message || "Revise a confirmação e tente novamente."} )}
Cancelar {camada === 1 && ( setCamada(2)}>Prosseguir )} {camada === 2 && ( setCamada(3)}>Prosseguir )} {camada === 3 && ( onConfirmar({ confirmacao: expressao, senha })}> {busy ? "Apagando..." : "Apagar todos os logs"} )}
); } function AuditView({ onToast }) { const [itens, setItens] = React.useState([]); const [total, setTotal] = React.useState(0); const [skip, setSkip] = React.useState(0); const [limit, setLimit] = React.useState(50); const [de, setDe] = React.useState(""); const [ate, setAte] = React.useState(""); const [operacoes, setOperacoes] = React.useState([]); // Todos os grupos começam fechados: é o que garante que o card de filtro // não cresça com o catálogo, qualquer que seja o número de domínios. const [gruposAbertos, setGruposAbertos] = React.useState([]); const [carregando, setCarregando] = React.useState(true); const [erro, setErro] = React.useState(null); const [limpandoFiltro, setLimpandoFiltro] = React.useState(null); const [limpandoTudo, setLimpandoTudo] = React.useState(null); const [busy, setBusy] = React.useState(false); const filtro = React.useMemo(() => ({ de: dataParaISO(de) || undefined, ate: dataParaISO(ate) || undefined, operacao: operacoes.length ? operacoes : undefined, }), [de, ate, operacoes]); const carregar = React.useCallback(() => { setCarregando(true); setErro(null); window.API.listAuditLogs({ ...filtro, skip, limit }) .then(dados => { setItens(dados.items || []); setTotal(dados.total || 0); setCarregando(false); }) .catch(problema => { setErro(problema); setItens([]); setCarregando(false); }); }, [filtro, skip, limit]); React.useEffect(() => { carregar(); }, [carregar]); const alternarOperacao = (codigo) => { setSkip(0); setOperacoes(atual => atual.includes(codigo) ? atual.filter(item => item !== codigo) : [...atual, codigo]); }; const alternarGrupo = (grupo) => { setGruposAbertos(atual => atual.includes(grupo) ? atual.filter(item => item !== grupo) : [...atual, grupo]); }; const exportar = () => { window.API.exportAuditLogs(filtro) .then(() => onToast("Exportação concluída.")) .catch(problema => setErro(problema)); }; // RF-AUDITORIA-11 e 13: a contagem exata precede a confirmação, e a diferença // entre alcançados e removíveis é o aviso sobre os registros imortais. const abrirLimpeza = (total) => { setErro(null); window.API.countRemovableAuditLogs({ ...filtro, total }) .then(contagem => (total ? setLimpandoTudo(contagem) : setLimpandoFiltro(contagem))) .catch(problema => setErro(problema)); }; const executarLimpeza = (extra = {}) => { setBusy(true); window.API.purgeAuditLogs({ total: !!limpandoTudo, de: filtro.de, ate: filtro.ate, operacao: filtro.operacao || [], ...extra, }).then(resultado => { setBusy(false); setLimpandoFiltro(null); setLimpandoTudo(null); setSkip(0); onToast(`${resultado.removidos.toLocaleString("pt-BR")} registro(s) apagado(s).`); carregar(); }).catch(problema => { setBusy(false); setErro(problema); }); }; return (
Administração

Auditoria

Operações de administração registradas no sistema, com autoria e momento.

Exportar CSV
{erro && ( setErro(null)}> {erro.message} )}
{/* Não existe seletor de data no sistema. Reusar a máscara do formulário de caso é o caminho consistente — inventar um componente novo aqui criaria dois jeitos de digitar data. */} { setDe(valor); setSkip(0); }} /> { setAte(valor); setSkip(0); }} />
{Object.entries(GRUPOS_ROTULO).map(([grupo, rotulo]) => ( alternarGrupo(grupo)} onAlternar={alternarOperacao} /> ))}
abrirLimpeza(false)}>Apagar logs do resultado abrirLimpeza(true)}>Apagar tudo
{carregando ? (
Carregando...
) : ( {itens.map(registro => ( ))}
Data e horaOperaçãoAutorAlvoDetalhes
{momentoLegivel(registro.ocorrido_em)} {ROTULO_DA_OPERACAO[registro.operacao] || registro.operacao} {registro.resultado === "bloqueado" && bloqueado} {autorLegivel(registro)}
{registro.alvo_nome || "—"}
{registro.alvo_email &&
{registro.alvo_email}
}
{detalhesLegiveis(registro.detalhes)}
)} {!carregando && !itens.length && (
Nenhuma operação registrada para este filtro.
)}
{limpandoFiltro && ( executarLimpeza()} onClose={() => setLimpandoFiltro(null)} >

Serão apagados {limpandoFiltro.removiveis.toLocaleString("pt-BR")} registro(s) que satisfazem o filtro corrente. A operação não tem volta.

{limpandoFiltro.preservados > 0 && ( {limpandoFiltro.preservados.toLocaleString("pt-BR")} registro(s) do resultado são de operações de limpeza e nunca são apagados. Eles continuarão aparecendo neste filtro depois desta operação. )}
)} {limpandoTudo && ( setLimpandoTudo(null)} /> )}
); } Object.assign(window, { AuditView, OPERACOES, ROTULO_DA_OPERACAO, EXPRESSAO_DE_CONFIRMACAO });