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';
|
import { db } from '@/lib/prisma';
|
||||||
|
|
||||||
type AdminUser = {
|
type AdminUser = {
|
||||||
@@ -51,6 +52,9 @@ type AdminUserSession = {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
ipAddress: string | null;
|
ipAddress: string | null;
|
||||||
userAgent: string | null;
|
userAgent: string | null;
|
||||||
|
city: string | null;
|
||||||
|
country: string | null;
|
||||||
|
countryCode: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getUserSessions(userId: string): Promise<AdminUserSession[]> {
|
export async function getUserSessions(userId: string): Promise<AdminUserSession[]> {
|
||||||
@@ -59,13 +63,34 @@ export async function getUserSessions(userId: string): Promise<AdminUserSession[
|
|||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
return sessions.map((s) => ({
|
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,
|
id: s.id,
|
||||||
createdAt: s.createdAt.toISOString(),
|
createdAt: s.createdAt.toISOString(),
|
||||||
expiresAt: s.expiresAt.toISOString(),
|
expiresAt: s.expiresAt.toISOString(),
|
||||||
ipAddress: s.ipAddress,
|
ipAddress: s.ipAddress,
|
||||||
userAgent: s.userAgent,
|
userAgent: s.userAgent,
|
||||||
}));
|
city: geo?.city ?? null,
|
||||||
|
country: geo?.country ?? null,
|
||||||
|
countryCode: geo?.countryCode ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminServiceError extends Error {
|
export class AdminServiceError extends Error {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash, randomInt } from 'node:crypto';
|
import { createHash, randomInt } from 'node:crypto';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { type IpGeoInfo, fetchGeoInfo } from '@/lib/geoip';
|
||||||
import { sendMail } from '@/lib/mailer';
|
import { sendMail } from '@/lib/mailer';
|
||||||
import { db } from '@/lib/prisma';
|
import { db } from '@/lib/prisma';
|
||||||
import type {
|
import type {
|
||||||
@@ -82,13 +83,6 @@ type UserAgentInfo = {
|
|||||||
device: string;
|
device: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type IpGeoInfo = {
|
|
||||||
ip: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
countryCode: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseUserAgent(ua: string | undefined): UserAgentInfo {
|
function parseUserAgent(ua: string | undefined): UserAgentInfo {
|
||||||
if (!ua) {
|
if (!ua) {
|
||||||
return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' };
|
return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' };
|
||||||
@@ -118,31 +112,6 @@ function parseUserAgent(ua: string | undefined): UserAgentInfo {
|
|||||||
return { browser, os, device };
|
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) {
|
async function sendResetOtpEmail(email: string, otpCode: string) {
|
||||||
await sendMail({
|
await sendMail({
|
||||||
to: email,
|
to: email,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Globe,
|
Globe,
|
||||||
LogIn,
|
LogIn,
|
||||||
|
MapPin,
|
||||||
Monitor,
|
Monitor,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
@@ -99,6 +100,10 @@ function SessionRow({ session }: { session: AdminUserSession }) {
|
|||||||
const isExpired = new Date(session.expiresAt) < new Date();
|
const isExpired = new Date(session.expiresAt) < new Date();
|
||||||
const userAgent = session.userAgent ?? 'Desconocido';
|
const userAgent = session.userAgent ?? 'Desconocido';
|
||||||
const ip = session.ipAddress ?? '—';
|
const ip = session.ipAddress ?? '—';
|
||||||
|
const location =
|
||||||
|
session.city || session.country
|
||||||
|
? [session.city, session.country].filter(Boolean).join(', ')
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border/60 p-4">
|
<div className="rounded-xl border border-border/60 p-4">
|
||||||
@@ -126,6 +131,12 @@ function SessionRow({ session }: { session: AdminUserSession }) {
|
|||||||
<Globe className="size-3" />
|
<Globe className="size-3" />
|
||||||
{ip}
|
{ip}
|
||||||
</span>
|
</span>
|
||||||
|
{location && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="size-3" />
|
||||||
|
{location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<LogIn className="size-3" />
|
<LogIn className="size-3" />
|
||||||
{new Date(session.createdAt).toLocaleString()}
|
{new Date(session.createdAt).toLocaleString()}
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export const adminUserSessionSchema = z.object({
|
|||||||
expiresAt: z.string().datetime(),
|
expiresAt: z.string().datetime(),
|
||||||
ipAddress: z.string().nullable(),
|
ipAddress: z.string().nullable(),
|
||||||
userAgent: 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>
|
export type AdminComplexListItem = z.infer<typeof adminComplexListItemSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user