feat: enhance login functionality by adding IP geolocation tracking and advanced filtering options in the audit page, improving user insights and data accuracy
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
import logo from '$lib/assets/logo_governo_PE.png';
|
import logo from '$lib/assets/logo_governo_PE.png';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { loginModalStore } from '$lib/stores/loginModal.svelte';
|
import { loginModalStore } from '$lib/stores/loginModal.svelte';
|
||||||
import { useQuery } from 'convex-svelte';
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
|
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
|
||||||
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
|
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
|
||||||
@@ -14,11 +14,26 @@
|
|||||||
import { authClient } from '$lib/auth';
|
import { authClient } from '$lib/auth';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
// Função para obter IP público (similar ao sistema de ponto)
|
||||||
|
async function obterIPPublico(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.ipify.org?format=json');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as { ip: string };
|
||||||
|
return data.ip;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao obter IP público:', error);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
const currentPath = $derived(page.url.pathname);
|
const currentPath = $derived(page.url.pathname);
|
||||||
|
|
||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
|
const convexClient = useConvexClient();
|
||||||
|
|
||||||
// Função para obter a URL do avatar/foto do usuário
|
// Função para obter a URL do avatar/foto do usuário
|
||||||
const avatarUrlDoUsuario = $derived(() => {
|
const avatarUrlDoUsuario = $derived(() => {
|
||||||
@@ -122,18 +137,67 @@
|
|||||||
erroLogin = '';
|
erroLogin = '';
|
||||||
carregandoLogin = true;
|
carregandoLogin = true;
|
||||||
|
|
||||||
// const browserInfo = await getBrowserInfo();
|
// Obter IP público e userAgent antes do login
|
||||||
|
const [ipPublico, userAgent] = await Promise.all([
|
||||||
|
obterIPPublico().catch(() => undefined),
|
||||||
|
Promise.resolve(typeof navigator !== 'undefined' ? navigator.userAgent : undefined)
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await authClient.signIn.email(
|
const result = await authClient.signIn.email(
|
||||||
{ email: matricula.trim(), password: senha },
|
{ email: matricula.trim(), password: senha },
|
||||||
{
|
{
|
||||||
onError: (ctx) => {
|
onError: async (ctx) => {
|
||||||
|
// Registrar tentativa de login falha
|
||||||
|
try {
|
||||||
|
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||||
|
matriculaOuEmail: matricula.trim(),
|
||||||
|
sucesso: false,
|
||||||
|
motivoFalha: ctx.error?.message || 'Erro desconhecido',
|
||||||
|
userAgent: userAgent,
|
||||||
|
ipAddress: ipPublico,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao registrar tentativa de login falha:', err);
|
||||||
|
}
|
||||||
alert(ctx.error.message);
|
alert(ctx.error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
|
// Registrar tentativa de login bem-sucedida
|
||||||
|
// Fazer de forma assíncrona para não bloquear o login
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Aguardar um pouco para o usuário ser sincronizado no Convex
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Buscar o usuário no Convex usando getCurrentUser
|
||||||
|
const usuario = await convexClient.query(api.auth.getCurrentUser, {});
|
||||||
|
|
||||||
|
if (usuario && usuario._id) {
|
||||||
|
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
matriculaOuEmail: matricula.trim(),
|
||||||
|
sucesso: true,
|
||||||
|
userAgent: userAgent,
|
||||||
|
ipAddress: ipPublico,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Se não encontrou o usuário, registrar sem usuarioId (será atualizado depois)
|
||||||
|
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
|
||||||
|
matriculaOuEmail: matricula.trim(),
|
||||||
|
sucesso: true,
|
||||||
|
userAgent: userAgent,
|
||||||
|
ipAddress: ipPublico,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao registrar tentativa de login:', err);
|
||||||
|
// Não bloquear o login se houver erro ao registrar
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
closeLoginModal();
|
closeLoginModal();
|
||||||
goto(resolve('/'));
|
goto(resolve('/'));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,26 +1,149 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery } from "convex-svelte";
|
import { useQuery, useConvexClient } from "convex-svelte";
|
||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
|
||||||
|
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
|
import jsPDF from "jspdf";
|
||||||
|
import autoTable from "jspdf-autotable";
|
||||||
|
import Papa from "papaparse";
|
||||||
|
|
||||||
let abaAtiva = $state<"atividades" | "logins">("atividades");
|
let abaAtiva = $state<"atividades" | "logins">("atividades");
|
||||||
let limite = $state(50);
|
let limite = $state(50);
|
||||||
|
let mostrarFiltros = $state(false);
|
||||||
|
let exportando = $state(false);
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
// Queries com $derived para garantir reatividade
|
// Filtros avançados
|
||||||
const atividades = $derived(useQuery(api.logsAtividades.listarAtividades, { limite }));
|
let filtroDataInicio = $state<string>("");
|
||||||
const logins = $derived(useQuery(api.logsLogin.listarTodosLogins, { limite }));
|
let filtroDataFim = $state<string>("");
|
||||||
|
let filtroUsuario = $state<string>("");
|
||||||
|
let filtroStatus = $state<"todos" | "sucesso" | "falha">("todos");
|
||||||
|
let filtroIP = $state<string>("");
|
||||||
|
|
||||||
function formatarData(timestamp: number) {
|
// Queries reativas do Convex - usar função para garantir reatividade do limite
|
||||||
return new Date(timestamp).toLocaleString('pt-BR', {
|
const atividadesQuery = useQuery(api.logsAtividades.listarAtividades, () => ({ limite }));
|
||||||
day: '2-digit',
|
const loginsQuery = useQuery(api.logsLogin.listarTodosLogins, () => ({ limite }));
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
// Extrair dados das queries de forma robusta
|
||||||
hour: '2-digit',
|
const atividadesRaw = $derived.by(() => {
|
||||||
minute: '2-digit',
|
if (atividadesQuery === undefined || atividadesQuery === null) {
|
||||||
second: '2-digit'
|
return undefined;
|
||||||
});
|
}
|
||||||
}
|
if (typeof atividadesQuery === 'object' && Object.keys(atividadesQuery).length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if ('data' in atividadesQuery && atividadesQuery.data !== undefined) {
|
||||||
|
return Array.isArray(atividadesQuery.data) ? atividadesQuery.data : undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(atividadesQuery)) {
|
||||||
|
return atividadesQuery;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginsRaw = $derived.by(() => {
|
||||||
|
if (loginsQuery === undefined || loginsQuery === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof loginsQuery === 'object' && Object.keys(loginsQuery).length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if ('data' in loginsQuery && loginsQuery.data !== undefined) {
|
||||||
|
return Array.isArray(loginsQuery.data) ? loginsQuery.data : undefined;
|
||||||
|
}
|
||||||
|
if (Array.isArray(loginsQuery)) {
|
||||||
|
return loginsQuery;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aplicar filtros
|
||||||
|
const atividades = $derived.by(() => {
|
||||||
|
if (!atividadesRaw || !Array.isArray(atividadesRaw)) return [];
|
||||||
|
let filtradas = [...atividadesRaw];
|
||||||
|
|
||||||
|
if (filtroDataInicio) {
|
||||||
|
const dataInicio = new Date(filtroDataInicio).getTime();
|
||||||
|
filtradas = filtradas.filter(a => a.timestamp >= dataInicio);
|
||||||
|
}
|
||||||
|
if (filtroDataFim) {
|
||||||
|
const dataFim = new Date(filtroDataFim + "T23:59:59").getTime();
|
||||||
|
filtradas = filtradas.filter(a => a.timestamp <= dataFim);
|
||||||
|
}
|
||||||
|
if (filtroUsuario) {
|
||||||
|
const usuarioLower = filtroUsuario.toLowerCase();
|
||||||
|
filtradas = filtradas.filter(a =>
|
||||||
|
(a.usuarioNome?.toLowerCase().includes(usuarioLower)) ||
|
||||||
|
(a.usuarioMatricula?.toLowerCase().includes(usuarioLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtradas;
|
||||||
|
});
|
||||||
|
|
||||||
|
const logins = $derived.by(() => {
|
||||||
|
if (!loginsRaw || !Array.isArray(loginsRaw)) return [];
|
||||||
|
let filtrados = [...loginsRaw];
|
||||||
|
|
||||||
|
if (filtroDataInicio) {
|
||||||
|
const dataInicio = new Date(filtroDataInicio).getTime();
|
||||||
|
filtrados = filtrados.filter(l => l.timestamp >= dataInicio);
|
||||||
|
}
|
||||||
|
if (filtroDataFim) {
|
||||||
|
const dataFim = new Date(filtroDataFim + "T23:59:59").getTime();
|
||||||
|
filtrados = filtrados.filter(l => l.timestamp <= dataFim);
|
||||||
|
}
|
||||||
|
if (filtroUsuario) {
|
||||||
|
const usuarioLower = filtroUsuario.toLowerCase();
|
||||||
|
filtrados = filtrados.filter(l =>
|
||||||
|
l.matriculaOuEmail.toLowerCase().includes(usuarioLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filtroStatus !== "todos") {
|
||||||
|
filtrados = filtrados.filter(l =>
|
||||||
|
filtroStatus === "sucesso" ? l.sucesso : !l.sucesso
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filtroIP) {
|
||||||
|
filtrados = filtrados.filter(l =>
|
||||||
|
l.ipAddress?.includes(filtroIP)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtrados;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatarData(timestamp: number) {
|
||||||
|
return new Date(timestamp).toLocaleString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarLocalizacao(login: { cidade?: string; estado?: string; pais?: string; endereco?: string } | undefined): string {
|
||||||
|
if (!login) return "-";
|
||||||
|
|
||||||
|
const partes: string[] = [];
|
||||||
|
if (login.cidade) partes.push(login.cidade);
|
||||||
|
if (login.estado) partes.push(login.estado);
|
||||||
|
if (login.pais) partes.push(login.pais);
|
||||||
|
|
||||||
|
if (partes.length > 0) {
|
||||||
|
return partes.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não tiver cidade/estado/pais, mas tiver endereco, mostrar endereco
|
||||||
|
if (login.endereco) {
|
||||||
|
return login.endereco;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
function getAcaoColor(acao: string) {
|
function getAcaoColor(acao: string) {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
@@ -47,10 +170,171 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Estatísticas
|
// Estatísticas
|
||||||
const totalAtividades = $derived(atividades?.data?.length || 0);
|
const totalAtividades = $derived(atividades?.length || 0);
|
||||||
const totalLogins = $derived(logins?.data?.length || 0);
|
const totalLogins = $derived(logins?.length || 0);
|
||||||
const loginsSucesso = $derived(logins?.data?.filter(l => l.sucesso).length || 0);
|
const loginsSucesso = $derived(logins?.filter(l => l.sucesso).length || 0);
|
||||||
const loginsFalha = $derived(logins?.data?.filter(l => !l.sucesso).length || 0);
|
const loginsFalha = $derived(logins?.filter(l => !l.sucesso).length || 0);
|
||||||
|
|
||||||
|
// Funções de exportação
|
||||||
|
async function exportarCSV() {
|
||||||
|
exportando = true;
|
||||||
|
try {
|
||||||
|
const dadosParaExportar = abaAtiva === "atividades" ? atividades : logins;
|
||||||
|
|
||||||
|
if (!dadosParaExportar || dadosParaExportar.length === 0) {
|
||||||
|
alert("Nenhum dado para exportar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let csvData: Record<string, string>[] = [];
|
||||||
|
|
||||||
|
if (abaAtiva === "atividades") {
|
||||||
|
csvData = dadosParaExportar.map((atividade: any) => ({
|
||||||
|
"Data/Hora": formatarData(atividade.timestamp),
|
||||||
|
"Usuário": atividade.usuarioNome || "Sistema",
|
||||||
|
"Matrícula": atividade.usuarioMatricula || "-",
|
||||||
|
"Ação": getAcaoLabel(atividade.acao),
|
||||||
|
"Recurso": atividade.recurso,
|
||||||
|
"Detalhes": atividade.detalhes || "-"
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
csvData = dadosParaExportar.map((login: any) => ({
|
||||||
|
"Data/Hora": formatarData(login.timestamp),
|
||||||
|
"Usuário/Email": login.matriculaOuEmail,
|
||||||
|
"Status": login.sucesso ? "Sucesso" : "Falhou",
|
||||||
|
"Motivo Falha": login.motivoFalha || "-",
|
||||||
|
"IP": login.ipAddress || "-",
|
||||||
|
"Localização": formatarLocalizacao(login),
|
||||||
|
"Dispositivo": login.device || "-",
|
||||||
|
"Navegador": login.browser || "-",
|
||||||
|
"Sistema": login.sistema || "-"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const csv = Papa.unparse(csvData);
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute("href", url);
|
||||||
|
link.setAttribute(
|
||||||
|
"download",
|
||||||
|
`auditoria-${abaAtiva}-${format(new Date(), "yyyy-MM-dd-HHmm", { locale: ptBR })}.csv`
|
||||||
|
);
|
||||||
|
link.style.visibility = "hidden";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao exportar CSV:", error);
|
||||||
|
alert("Erro ao exportar CSV. Tente novamente.");
|
||||||
|
} finally {
|
||||||
|
exportando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportarPDF() {
|
||||||
|
exportando = true;
|
||||||
|
try {
|
||||||
|
const dadosParaExportar = abaAtiva === "atividades" ? atividades : logins;
|
||||||
|
|
||||||
|
if (!dadosParaExportar || dadosParaExportar.length === 0) {
|
||||||
|
alert("Nenhum dado para exportar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new jsPDF();
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.setFontSize(20);
|
||||||
|
doc.setTextColor(102, 126, 234);
|
||||||
|
const titulo = abaAtiva === "atividades"
|
||||||
|
? "Relatório de Atividades do Sistema"
|
||||||
|
: "Relatório de Histórico de Logins";
|
||||||
|
doc.text(titulo, 14, 20);
|
||||||
|
|
||||||
|
// Informações gerais
|
||||||
|
doc.setFontSize(12);
|
||||||
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.text(
|
||||||
|
`Gerado em: ${format(new Date(), "dd/MM/yyyy HH:mm", { locale: ptBR })}`,
|
||||||
|
14,
|
||||||
|
30
|
||||||
|
);
|
||||||
|
doc.text(`Total de registros: ${dadosParaExportar.length}`, 14, 36);
|
||||||
|
|
||||||
|
let yPos = 50;
|
||||||
|
|
||||||
|
if (abaAtiva === "atividades") {
|
||||||
|
// Tabela de atividades
|
||||||
|
const atividadesData = dadosParaExportar.map((atividade: any) => [
|
||||||
|
formatarData(atividade.timestamp),
|
||||||
|
atividade.usuarioNome || "Sistema",
|
||||||
|
getAcaoLabel(atividade.acao),
|
||||||
|
atividade.recurso,
|
||||||
|
(atividade.detalhes || "-").substring(0, 50)
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [["Data/Hora", "Usuário", "Ação", "Recurso", "Detalhes"]],
|
||||||
|
body: atividadesData,
|
||||||
|
theme: "striped",
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 8 }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Tabela de logins
|
||||||
|
const loginsData = dadosParaExportar.map((login: any) => [
|
||||||
|
formatarData(login.timestamp),
|
||||||
|
login.matriculaOuEmail,
|
||||||
|
login.sucesso ? "Sucesso" : "Falhou",
|
||||||
|
login.ipAddress || "-",
|
||||||
|
formatarLocalizacao(login).substring(0, 30),
|
||||||
|
login.device || "-",
|
||||||
|
login.browser || "-"
|
||||||
|
]);
|
||||||
|
|
||||||
|
autoTable(doc, {
|
||||||
|
startY: yPos,
|
||||||
|
head: [["Data/Hora", "Usuário/Email", "Status", "IP", "Localização", "Dispositivo", "Navegador"]],
|
||||||
|
body: loginsData,
|
||||||
|
theme: "striped",
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 7 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
const pageCount = doc.getNumberOfPages();
|
||||||
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
|
doc.setPage(i);
|
||||||
|
doc.setFontSize(8);
|
||||||
|
doc.setTextColor(128, 128, 128);
|
||||||
|
doc.text(
|
||||||
|
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||||
|
doc.internal.pageSize.getWidth() / 2,
|
||||||
|
doc.internal.pageSize.getHeight() - 10,
|
||||||
|
{ align: "center" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.save(`auditoria-${abaAtiva}-${format(new Date(), "yyyy-MM-dd-HHmm", { locale: ptBR })}.pdf`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao exportar PDF:", error);
|
||||||
|
alert("Erro ao exportar PDF. Tente novamente.");
|
||||||
|
} finally {
|
||||||
|
exportando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
filtroDataInicio = "";
|
||||||
|
filtroDataFim = "";
|
||||||
|
filtroUsuario = "";
|
||||||
|
filtroStatus = "todos";
|
||||||
|
filtroIP = "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="container mx-auto px-4 py-6 max-w-7xl">
|
<main class="container mx-auto px-4 py-6 max-w-7xl">
|
||||||
@@ -163,13 +447,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-outline btn-primary btn-sm gap-2">
|
<button
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
class="btn btn-outline btn-primary btn-sm gap-2"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
onclick={exportarCSV}
|
||||||
</svg>
|
disabled={exportando || (abaAtiva === "atividades" ? !atividades?.length : !logins?.length)}
|
||||||
|
>
|
||||||
|
{#if exportando}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
Exportar CSV
|
Exportar CSV
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline btn-secondary btn-sm gap-2">
|
<button
|
||||||
|
class="btn btn-outline btn-secondary btn-sm gap-2"
|
||||||
|
onclick={exportarPDF}
|
||||||
|
disabled={exportando || (abaAtiva === "atividades" ? !atividades?.length : !logins?.length)}
|
||||||
|
>
|
||||||
|
{#if exportando}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Exportar PDF
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline btn-secondary btn-sm gap-2"
|
||||||
|
onclick={() => mostrarFiltros = !mostrarFiltros}
|
||||||
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -177,6 +486,75 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros Avançados -->
|
||||||
|
{#if mostrarFiltros}
|
||||||
|
<div class="divider my-4"></div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text font-medium">Data Início</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
bind:value={filtroDataInicio}
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text font-medium">Data Fim</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
bind:value={filtroDataFim}
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text font-medium">Usuário</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={filtroUsuario}
|
||||||
|
placeholder="Nome ou matrícula"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if abaAtiva === "logins"}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text font-medium">Status</span>
|
||||||
|
</label>
|
||||||
|
<select bind:value={filtroStatus} class="select select-bordered select-sm">
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
<option value="sucesso">Sucesso</option>
|
||||||
|
<option value="falha">Falha</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label py-1">
|
||||||
|
<span class="label-text font-medium">IP Address</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={filtroIP}
|
||||||
|
placeholder="Ex: 192.168.1.1"
|
||||||
|
class="input input-bordered input-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="form-control flex items-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
onclick={limparFiltros}
|
||||||
|
>
|
||||||
|
Limpar Filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -191,17 +569,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Atividades Recentes
|
Atividades Recentes
|
||||||
</h2>
|
</h2>
|
||||||
{#if atividades?.data}
|
{#if atividades}
|
||||||
<div class="badge badge-outline badge-lg">{atividades.data.length} registro{atividades.data.length !== 1 ? 's' : ''}</div>
|
<div class="badge badge-outline badge-lg">{atividades.length} registro{atividades.length !== 1 ? 's' : ''}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !atividades?.data}
|
{#if atividadesRaw === undefined}
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||||
<p class="text-base-content/60">Carregando atividades...</p>
|
<p class="text-base-content/60">Carregando atividades...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if atividades.data.length === 0}
|
{:else if atividades.length === 0}
|
||||||
<div class="flex flex-col items-center justify-center py-16 text-base-content/60">
|
<div class="flex flex-col items-center justify-center py-16 text-base-content/60">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
@@ -222,7 +600,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each atividades.data as atividade}
|
{#each atividades as atividade}
|
||||||
<tr class="hover transition-colors">
|
<tr class="hover transition-colors">
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -277,17 +655,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Histórico de Logins
|
Histórico de Logins
|
||||||
</h2>
|
</h2>
|
||||||
{#if logins?.data}
|
{#if logins}
|
||||||
<div class="badge badge-outline badge-lg">{logins.data.length} registro{logins.data.length !== 1 ? 's' : ''}</div>
|
<div class="badge badge-outline badge-lg">{logins.length} registro{logins.length !== 1 ? 's' : ''}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !logins?.data}
|
{#if loginsRaw === undefined}
|
||||||
<div class="flex flex-col items-center justify-center py-16">
|
<div class="flex flex-col items-center justify-center py-16">
|
||||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||||
<p class="text-base-content/60">Carregando logins...</p>
|
<p class="text-base-content/60">Carregando logins...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if logins.data.length === 0}
|
{:else if logins.length === 0}
|
||||||
<div class="flex flex-col items-center justify-center py-16 text-base-content/60">
|
<div class="flex flex-col items-center justify-center py-16 text-base-content/60">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||||
@@ -304,13 +682,14 @@
|
|||||||
<th class="font-semibold">Usuário/Email</th>
|
<th class="font-semibold">Usuário/Email</th>
|
||||||
<th class="font-semibold">Status</th>
|
<th class="font-semibold">Status</th>
|
||||||
<th class="font-semibold">IP</th>
|
<th class="font-semibold">IP</th>
|
||||||
|
<th class="font-semibold">Localização</th>
|
||||||
<th class="font-semibold">Dispositivo</th>
|
<th class="font-semibold">Dispositivo</th>
|
||||||
<th class="font-semibold">Navegador</th>
|
<th class="font-semibold">Navegador</th>
|
||||||
<th class="font-semibold">Sistema</th>
|
<th class="font-semibold">Sistema</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each logins.data as login}
|
{#each logins as login}
|
||||||
<tr class="hover transition-colors">
|
<tr class="hover transition-colors">
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -353,6 +732,17 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="font-mono text-xs bg-base-200 px-2 py-1 rounded">{login.ipAddress || "-"}</span>
|
<span class="font-mono text-xs bg-base-200 px-2 py-1 rounded">{login.ipAddress || "-"}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2 max-w-xs">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/40 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs text-base-content/70 truncate" title={formatarLocalizacao(login)}>
|
||||||
|
{formatarLocalizacao(login)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="text-xs text-base-content/70">{login.device || "-"}</div>
|
<div class="text-xs text-base-content/70">{login.device || "-"}</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -2,6 +2,61 @@ import { v } from "convex/values";
|
|||||||
import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server";
|
import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server";
|
||||||
import { Doc, Id } from "./_generated/dataModel";
|
import { Doc, Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtém geolocalização aproximada por IP usando serviço externo
|
||||||
|
* Similar ao sistema de ponto
|
||||||
|
*/
|
||||||
|
async function obterGeoPorIP(ipAddress: string): Promise<{
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
cidade?: string;
|
||||||
|
estado?: string;
|
||||||
|
pais?: string;
|
||||||
|
endereco?: string;
|
||||||
|
} | null> {
|
||||||
|
try {
|
||||||
|
// Usar ipapi.co (gratuito, sem chave para uso limitado)
|
||||||
|
const response = await fetch(`https://ipapi.co/${ipAddress}/json/`, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'SGSE-App/1.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
city?: string;
|
||||||
|
region?: string;
|
||||||
|
country_name?: string;
|
||||||
|
error?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data.error && data.latitude && data.longitude) {
|
||||||
|
// Montar endereço completo
|
||||||
|
const partesEndereco: string[] = [];
|
||||||
|
if (data.city) partesEndereco.push(data.city);
|
||||||
|
if (data.region) partesEndereco.push(data.region);
|
||||||
|
if (data.country_name) partesEndereco.push(data.country_name);
|
||||||
|
const endereco = partesEndereco.length > 0 ? partesEndereco.join(', ') : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: data.latitude,
|
||||||
|
longitude: data.longitude,
|
||||||
|
cidade: data.city,
|
||||||
|
estado: data.region,
|
||||||
|
pais: data.country_name,
|
||||||
|
endereco
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao obter geolocalização por IP:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper para registrar tentativas de login
|
* Helper para registrar tentativas de login
|
||||||
*/
|
*/
|
||||||
@@ -52,6 +107,26 @@ export async function registrarLogin(
|
|||||||
|
|
||||||
// Validar e sanitizar IP antes de salvar
|
// Validar e sanitizar IP antes de salvar
|
||||||
const ipAddressValidado = validarIP(dados.ipAddress);
|
const ipAddressValidado = validarIP(dados.ipAddress);
|
||||||
|
|
||||||
|
// Obter geolocalização por IP se disponível (de forma assíncrona para não bloquear)
|
||||||
|
let geolocalizacao: {
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
cidade?: string;
|
||||||
|
estado?: string;
|
||||||
|
pais?: string;
|
||||||
|
endereco?: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
if (ipAddressValidado) {
|
||||||
|
// Obter geolocalização por IP (não bloquear se falhar)
|
||||||
|
try {
|
||||||
|
geolocalizacao = await obterGeoPorIP(ipAddressValidado);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erro ao obter geolocalização por IP:', error);
|
||||||
|
// Continuar sem localização se houver erro
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.db.insert("logsLogin", {
|
await ctx.db.insert("logsLogin", {
|
||||||
usuarioId: dados.usuarioId,
|
usuarioId: dados.usuarioId,
|
||||||
@@ -63,6 +138,13 @@ export async function registrarLogin(
|
|||||||
device,
|
device,
|
||||||
browser,
|
browser,
|
||||||
sistema,
|
sistema,
|
||||||
|
// Informações de Localização
|
||||||
|
latitude: geolocalizacao?.latitude,
|
||||||
|
longitude: geolocalizacao?.longitude,
|
||||||
|
cidade: geolocalizacao?.cidade,
|
||||||
|
estado: geolocalizacao?.estado,
|
||||||
|
pais: geolocalizacao?.pais,
|
||||||
|
endereco: geolocalizacao?.endereco,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,6 +362,32 @@ function extrairSistema(userAgent: string): string {
|
|||||||
return "Desconhecido";
|
return "Desconhecido";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutation pública para registrar tentativa de login
|
||||||
|
* Pode ser chamada do frontend após login bem-sucedido ou falho
|
||||||
|
*/
|
||||||
|
export const registrarTentativaLogin = mutation({
|
||||||
|
args: {
|
||||||
|
usuarioId: v.optional(v.id("usuarios")),
|
||||||
|
matriculaOuEmail: v.string(),
|
||||||
|
sucesso: v.boolean(),
|
||||||
|
motivoFalha: v.optional(v.string()),
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await registrarLogin(ctx, {
|
||||||
|
usuarioId: args.usuarioId,
|
||||||
|
matriculaOuEmail: args.matriculaOuEmail,
|
||||||
|
sucesso: args.sucesso,
|
||||||
|
motivoFalha: args.motivoFalha,
|
||||||
|
ipAddress: args.ipAddress,
|
||||||
|
userAgent: args.userAgent,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lista histórico de logins de um usuário
|
* Lista histórico de logins de um usuário
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -779,11 +779,21 @@ export default defineSchema({
|
|||||||
matriculaOuEmail: v.string(), // tentativa de login
|
matriculaOuEmail: v.string(), // tentativa de login
|
||||||
sucesso: v.boolean(),
|
sucesso: v.boolean(),
|
||||||
motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente"
|
motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente"
|
||||||
|
// Informações de Rede
|
||||||
ipAddress: v.optional(v.string()),
|
ipAddress: v.optional(v.string()),
|
||||||
|
ipPublico: v.optional(v.string()),
|
||||||
|
ipLocal: v.optional(v.string()),
|
||||||
userAgent: v.optional(v.string()),
|
userAgent: v.optional(v.string()),
|
||||||
device: v.optional(v.string()),
|
device: v.optional(v.string()),
|
||||||
browser: v.optional(v.string()),
|
browser: v.optional(v.string()),
|
||||||
sistema: v.optional(v.string()),
|
sistema: v.optional(v.string()),
|
||||||
|
// Informações de Localização
|
||||||
|
latitude: v.optional(v.number()),
|
||||||
|
longitude: v.optional(v.number()),
|
||||||
|
endereco: v.optional(v.string()),
|
||||||
|
cidade: v.optional(v.string()),
|
||||||
|
estado: v.optional(v.string()),
|
||||||
|
pais: v.optional(v.string()),
|
||||||
timestamp: v.number(),
|
timestamp: v.number(),
|
||||||
})
|
})
|
||||||
.index("by_usuario", ["usuarioId"])
|
.index("by_usuario", ["usuarioId"])
|
||||||
|
|||||||
Reference in New Issue
Block a user