/* 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 (
);
}
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."}
)}
);
}
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); }} />
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)}
/>
)}