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:
2025-11-30 08:12:46 -03:00
parent e35846103e
commit 334676b860
4 changed files with 610 additions and 38 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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
*/ */
@@ -53,6 +108,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,
matriculaOuEmail: dados.matriculaOuEmail, matriculaOuEmail: dados.matriculaOuEmail,
@@ -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
*/ */

View File

@@ -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"])