diff --git a/apps/backend/src/lib/geoip.ts b/apps/backend/src/lib/geoip.ts new file mode 100644 index 0000000..6f82440 --- /dev/null +++ b/apps/backend/src/lib/geoip.ts @@ -0,0 +1,57 @@ +type IpGeoInfo = { + ip: string; + city: string; + country: string; + countryCode: string; +}; + +const cache = new Map(); +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 { + 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 }; diff --git a/apps/backend/src/modules/admin/services/admin-users.service.ts b/apps/backend/src/modules/admin/services/admin-users.service.ts index 5c01a43..7c5069e 100644 --- a/apps/backend/src/modules/admin/services/admin-users.service.ts +++ b/apps/backend/src/modules/admin/services/admin-users.service.ts @@ -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 { @@ -59,13 +63,34 @@ export async function getUserSessions(userId: string): Promise ({ - 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 { diff --git a/apps/backend/src/modules/password-reset/services/password-reset.service.ts b/apps/backend/src/modules/password-reset/services/password-reset.service.ts index 9ed1c41..7b0c42e 100644 --- a/apps/backend/src/modules/password-reset/services/password-reset.service.ts +++ b/apps/backend/src/modules/password-reset/services/password-reset.service.ts @@ -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 { - 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, diff --git a/apps/frontend/src/features/admin/users-page.tsx b/apps/frontend/src/features/admin/users-page.tsx index 7bc39f9..b3e0d39 100644 --- a/apps/frontend/src/features/admin/users-page.tsx +++ b/apps/frontend/src/features/admin/users-page.tsx @@ -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 (
@@ -126,6 +131,12 @@ function SessionRow({ session }: { session: AdminUserSession }) { {ip} + {location && ( + + + {location} + + )} {new Date(session.createdAt).toLocaleString()} diff --git a/packages/api-contract/src/admin.ts b/packages/api-contract/src/admin.ts index 2b52fc8..de5ebcd 100644 --- a/packages/api-contract/src/admin.ts +++ b/packages/api-contract/src/admin.ts @@ -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