feat(admin): add geolocation information to user sessions and implement geo IP fetching
This commit is contained in:
57
apps/backend/src/lib/geoip.ts
Normal file
57
apps/backend/src/lib/geoip.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
type IpGeoInfo = {
|
||||
ip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
};
|
||||
|
||||
const cache = new Map<string, { data: IpGeoInfo; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
function getCached(ip: string): IpGeoInfo | undefined {
|
||||
const entry = cache.get(ip);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(ip);
|
||||
return undefined;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
function setCache(ip: string, info: IpGeoInfo): void {
|
||||
cache.set(ip, { data: info, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
}
|
||||
|
||||
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||
return { ip, city: 'Red local', country: 'Red local', countryCode: 'LOCAL' };
|
||||
}
|
||||
|
||||
const cached = getCached(ip);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,query`
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') return null;
|
||||
|
||||
const info: IpGeoInfo = {
|
||||
ip: data.query,
|
||||
city: data.city || 'Desconocida',
|
||||
country: data.country || 'Desconocido',
|
||||
countryCode: data.countryCode || '',
|
||||
};
|
||||
|
||||
setCache(ip, info);
|
||||
return info;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type { IpGeoInfo };
|
||||
export { fetchGeoInfo };
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fetchGeoInfo } from '@/lib/geoip';
|
||||
import { db } from '@/lib/prisma';
|
||||
|
||||
type AdminUser = {
|
||||
@@ -51,6 +52,9 @@ type AdminUserSession = {
|
||||
expiresAt: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
city: string | null;
|
||||
country: string | null;
|
||||
countryCode: string | null;
|
||||
};
|
||||
|
||||
export async function getUserSessions(userId: string): Promise<AdminUserSession[]> {
|
||||
@@ -59,13 +63,34 @@ export async function getUserSessions(userId: string): Promise<AdminUserSession[
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return sessions.map((s) => ({
|
||||
id: s.id,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
expiresAt: s.expiresAt.toISOString(),
|
||||
ipAddress: s.ipAddress,
|
||||
userAgent: s.userAgent,
|
||||
}));
|
||||
const uniqueIps = [...new Set(sessions.map((s) => s.ipAddress).filter(Boolean))] as string[];
|
||||
const geoResults = await Promise.all(uniqueIps.map((ip) => fetchGeoInfo(ip)));
|
||||
const geoMap = new Map<
|
||||
string,
|
||||
{ city: string | null; country: string | null; countryCode: string | null }
|
||||
>();
|
||||
for (let i = 0; i < uniqueIps.length; i++) {
|
||||
const info = geoResults[i];
|
||||
geoMap.set(uniqueIps[i], {
|
||||
city: info?.city ?? null,
|
||||
country: info?.country ?? null,
|
||||
countryCode: info?.countryCode ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return sessions.map((s) => {
|
||||
const geo = s.ipAddress ? geoMap.get(s.ipAddress) : null;
|
||||
return {
|
||||
id: s.id,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
expiresAt: s.expiresAt.toISOString(),
|
||||
ipAddress: s.ipAddress,
|
||||
userAgent: s.userAgent,
|
||||
city: geo?.city ?? null,
|
||||
country: geo?.country ?? null,
|
||||
countryCode: geo?.countryCode ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export class AdminServiceError extends Error {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash, randomInt } from 'node:crypto';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { type IpGeoInfo, fetchGeoInfo } from '@/lib/geoip';
|
||||
import { sendMail } from '@/lib/mailer';
|
||||
import { db } from '@/lib/prisma';
|
||||
import type {
|
||||
@@ -82,13 +83,6 @@ type UserAgentInfo = {
|
||||
device: string;
|
||||
};
|
||||
|
||||
type IpGeoInfo = {
|
||||
ip: string;
|
||||
city: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
};
|
||||
|
||||
function parseUserAgent(ua: string | undefined): UserAgentInfo {
|
||||
if (!ua) {
|
||||
return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' };
|
||||
@@ -118,31 +112,6 @@ function parseUserAgent(ua: string | undefined): UserAgentInfo {
|
||||
return { browser, os, device };
|
||||
}
|
||||
|
||||
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||
return { ip, city: 'Red local', country: 'Red local', countryCode: 'LOCAL' };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,query`
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') return null;
|
||||
|
||||
return {
|
||||
ip: data.query,
|
||||
city: data.city || 'Desconocida',
|
||||
country: data.country || 'Desconocido',
|
||||
countryCode: data.countryCode || '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResetOtpEmail(email: string, otpCode: string) {
|
||||
await sendMail({
|
||||
to: email,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
LogIn,
|
||||
MapPin,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -99,6 +100,10 @@ function SessionRow({ session }: { session: AdminUserSession }) {
|
||||
const isExpired = new Date(session.expiresAt) < new Date();
|
||||
const userAgent = session.userAgent ?? 'Desconocido';
|
||||
const ip = session.ipAddress ?? '—';
|
||||
const location =
|
||||
session.city || session.country
|
||||
? [session.city, session.country].filter(Boolean).join(', ')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 p-4">
|
||||
@@ -126,6 +131,12 @@ function SessionRow({ session }: { session: AdminUserSession }) {
|
||||
<Globe className="size-3" />
|
||||
{ip}
|
||||
</span>
|
||||
{location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<LogIn className="size-3" />
|
||||
{new Date(session.createdAt).toLocaleString()}
|
||||
|
||||
@@ -79,6 +79,9 @@ export const adminUserSessionSchema = z.object({
|
||||
expiresAt: z.string().datetime(),
|
||||
ipAddress: z.string().nullable(),
|
||||
userAgent: z.string().nullable(),
|
||||
city: z.string().nullable(),
|
||||
country: z.string().nullable(),
|
||||
countryCode: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type AdminComplexListItem = z.infer<typeof adminComplexListItemSchema>
|
||||
|
||||
Reference in New Issue
Block a user