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:
@@ -1,26 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
|
||||
|
||||
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 limite = $state(50);
|
||||
let mostrarFiltros = $state(false);
|
||||
let exportando = $state(false);
|
||||
const client = useConvexClient();
|
||||
|
||||
// Queries com $derived para garantir reatividade
|
||||
const atividades = $derived(useQuery(api.logsAtividades.listarAtividades, { limite }));
|
||||
const logins = $derived(useQuery(api.logsLogin.listarTodosLogins, { limite }));
|
||||
// Filtros avançados
|
||||
let filtroDataInicio = $state<string>("");
|
||||
let filtroDataFim = $state<string>("");
|
||||
let filtroUsuario = $state<string>("");
|
||||
let filtroStatus = $state<"todos" | "sucesso" | "falha">("todos");
|
||||
let filtroIP = $state<string>("");
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
// Queries reativas do Convex - usar função para garantir reatividade do limite
|
||||
const atividadesQuery = useQuery(api.logsAtividades.listarAtividades, () => ({ limite }));
|
||||
const loginsQuery = useQuery(api.logsLogin.listarTodosLogins, () => ({ limite }));
|
||||
|
||||
// Extrair dados das queries de forma robusta
|
||||
const atividadesRaw = $derived.by(() => {
|
||||
if (atividadesQuery === undefined || atividadesQuery === null) {
|
||||
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) {
|
||||
const colors: Record<string, string> = {
|
||||
@@ -47,10 +170,171 @@
|
||||
}
|
||||
|
||||
// Estatísticas
|
||||
const totalAtividades = $derived(atividades?.data?.length || 0);
|
||||
const totalLogins = $derived(logins?.data?.length || 0);
|
||||
const loginsSucesso = $derived(logins?.data?.filter(l => l.sucesso).length || 0);
|
||||
const loginsFalha = $derived(logins?.data?.filter(l => !l.sucesso).length || 0);
|
||||
const totalAtividades = $derived(atividades?.length || 0);
|
||||
const totalLogins = $derived(logins?.length || 0);
|
||||
const loginsSucesso = $derived(logins?.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>
|
||||
|
||||
<main class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
@@ -163,13 +447,38 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline btn-primary btn-sm gap-2">
|
||||
<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>
|
||||
<button
|
||||
class="btn btn-outline btn-primary btn-sm gap-2"
|
||||
onclick={exportarCSV}
|
||||
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
|
||||
</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">
|
||||
<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>
|
||||
@@ -177,6 +486,75 @@
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -191,17 +569,17 @@
|
||||
</svg>
|
||||
Atividades Recentes
|
||||
</h2>
|
||||
{#if atividades?.data}
|
||||
<div class="badge badge-outline badge-lg">{atividades.data.length} registro{atividades.data.length !== 1 ? 's' : ''}</div>
|
||||
{#if atividades}
|
||||
<div class="badge badge-outline badge-lg">{atividades.length} registro{atividades.length !== 1 ? 's' : ''}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !atividades?.data}
|
||||
{#if atividadesRaw === undefined}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||
<p class="text-base-content/60">Carregando atividades...</p>
|
||||
</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">
|
||||
<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" />
|
||||
@@ -222,7 +600,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each atividades.data as atividade}
|
||||
{#each atividades as atividade}
|
||||
<tr class="hover transition-colors">
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -277,17 +655,17 @@
|
||||
</svg>
|
||||
Histórico de Logins
|
||||
</h2>
|
||||
{#if logins?.data}
|
||||
<div class="badge badge-outline badge-lg">{logins.data.length} registro{logins.data.length !== 1 ? 's' : ''}</div>
|
||||
{#if logins}
|
||||
<div class="badge badge-outline badge-lg">{logins.length} registro{logins.length !== 1 ? 's' : ''}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !logins?.data}
|
||||
{#if loginsRaw === undefined}
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||
<p class="text-base-content/60">Carregando logins...</p>
|
||||
</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">
|
||||
<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" />
|
||||
@@ -304,13 +682,14 @@
|
||||
<th class="font-semibold">Usuário/Email</th>
|
||||
<th class="font-semibold">Status</th>
|
||||
<th class="font-semibold">IP</th>
|
||||
<th class="font-semibold">Localização</th>
|
||||
<th class="font-semibold">Dispositivo</th>
|
||||
<th class="font-semibold">Navegador</th>
|
||||
<th class="font-semibold">Sistema</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each logins.data as login}
|
||||
{#each logins as login}
|
||||
<tr class="hover transition-colors">
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -353,6 +732,17 @@
|
||||
<td>
|
||||
<span class="font-mono text-xs bg-base-200 px-2 py-1 rounded">{login.ipAddress || "-"}</span>
|
||||
</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>
|
||||
<div class="text-xs text-base-content/70">{login.device || "-"}</div>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user