Feature: Add collapsible login tables and customer activity report
- Make login activity tables collapsible (show/hide on demand) - Display only last 10 logins in collapsed tables - Add "Most Active Customers" report on customer login activity page - Shows top 10 most active customers with login counts - Fix back links on both login activity pages (was /admin, now /admin/orders) - Create CollapsibleLoginTable component for customer logins - Create CollapsibleAdminLoginTable component for admin logins Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
9623ac2fdb
commit
98b64ecd60
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma';
|
|||||||
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||||
import AdminNav from '@/components/AdminNav';
|
import AdminNav from '@/components/AdminNav';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import CollapsibleLoginTable from '@/components/CollapsibleLoginTable';
|
||||||
|
|
||||||
export default async function CustomerLoginLogsPage() {
|
export default async function CustomerLoginLogsPage() {
|
||||||
const isAdmin = await isAdminAuthenticated();
|
const isAdmin = await isAdminAuthenticated();
|
||||||
@@ -26,8 +27,21 @@ export default async function CustomerLoginLogsPage() {
|
|||||||
);
|
);
|
||||||
const uniqueCustomers = new Set(last30DaysLogs.map((l) => l.customerId)).size;
|
const uniqueCustomers = new Set(last30DaysLogs.map((l) => l.customerId)).size;
|
||||||
|
|
||||||
|
// Get most active customers
|
||||||
|
const successfulLogs = logs.filter((l) => l.success && l.customerId);
|
||||||
|
const customerCounts = new Map<string | null, { email: string; count: number }>();
|
||||||
|
|
||||||
|
successfulLogs.forEach((log) => {
|
||||||
|
const existing = customerCounts.get(log.customerId) || { email: log.email, count: 0 };
|
||||||
|
customerCounts.set(log.customerId, { email: existing.email || log.email, count: existing.count + 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const mostActiveCustomers = Array.from(customerCounts.values())
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
<div className="mx-auto max-w-5xl px-6 py-12">
|
||||||
<AdminNav />
|
<AdminNav />
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
<h1 className="font-display text-3xl">Customer Login Activity</h1>
|
<h1 className="font-display text-3xl">Customer Login Activity</h1>
|
||||||
@@ -60,80 +74,31 @@ export default async function CustomerLoginLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs */}
|
{/* Most Active Customers */}
|
||||||
{logs.length === 0 ? (
|
<div className="mt-8">
|
||||||
<p className="mt-8 text-sm text-muted">No customer login attempts recorded.</p>
|
<h2 className="font-display text-2xl">Most Active Customers</h2>
|
||||||
|
{mostActiveCustomers.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-muted">No customer logins recorded yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-8 overflow-x-auto">
|
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||||
<table className="w-full border-collapse text-sm">
|
{mostActiveCustomers.map((customer, idx) => (
|
||||||
<thead>
|
<div key={idx} className="border border-line bg-surface p-4">
|
||||||
<tr className="border-b border-line">
|
<p className="tag-label mb-1">{idx + 1}. {customer.email}</p>
|
||||||
<th className="text-left py-2 px-3 font-medium">Date & Time</th>
|
<p className="font-mono text-lg">{customer.count} logins</p>
|
||||||
<th className="text-left py-2 px-3 font-medium">Email</th>
|
</div>
|
||||||
<th className="text-left py-2 px-3 font-medium">Status</th>
|
))}
|
||||||
<th className="text-left py-2 px-3 font-medium">IP Address</th>
|
|
||||||
<th className="text-left py-2 px-3 font-medium">Browser / Device</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{logs.map((log) => {
|
|
||||||
// Parse user agent to show simplified browser/device info
|
|
||||||
const ua = log.userAgent || 'Unknown';
|
|
||||||
let device = 'Unknown';
|
|
||||||
if (ua.includes('Windows')) device = 'Windows';
|
|
||||||
else if (ua.includes('Mac')) device = 'Mac';
|
|
||||||
else if (ua.includes('Linux')) device = 'Linux';
|
|
||||||
else if (ua.includes('iPhone')) device = 'iPhone';
|
|
||||||
else if (ua.includes('Android')) device = 'Android';
|
|
||||||
|
|
||||||
let browser = 'Unknown';
|
|
||||||
if (ua.includes('Chrome')) browser = 'Chrome';
|
|
||||||
else if (ua.includes('Safari')) browser = 'Safari';
|
|
||||||
else if (ua.includes('Firefox')) browser = 'Firefox';
|
|
||||||
else if (ua.includes('Edge')) browser = 'Edge';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={log.id} className="border-b border-line hover:bg-surface">
|
|
||||||
<td className="py-2 px-3 font-mono text-xs">
|
|
||||||
{log.loginAt.toLocaleDateString('en-GB', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})}{' '}
|
|
||||||
{log.loginAt.toLocaleTimeString('en-GB', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3 text-xs">
|
|
||||||
<span className="break-all">{log.email}</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3">
|
|
||||||
<span
|
|
||||||
className={`tag-label rounded-full px-3 py-1 text-xs ${
|
|
||||||
log.success
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-splash-pink/10 text-splash-pink'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{log.success ? 'Success' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3 font-mono text-xs">{log.ipAddress || '—'}</td>
|
|
||||||
<td className="py-2 px-3 text-xs text-muted">
|
|
||||||
{browser} / {device}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Logins - Collapsible */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="font-display text-2xl mb-4">Recent Login Activity</h2>
|
||||||
|
<CollapsibleLoginTable logs={logs} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="mt-8 text-xs text-muted">
|
<p className="mt-8 text-xs text-muted">
|
||||||
Showing last 200 login attempts. Use this to monitor customer access patterns and detect suspicious activity.
|
Showing last 200 login attempts. Recent activity shows only the last 10 logins.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma';
|
|||||||
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||||
import AdminNav from '@/components/AdminNav';
|
import AdminNav from '@/components/AdminNav';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import CollapsibleAdminLoginTable from '@/components/CollapsibleAdminLoginTable';
|
||||||
|
|
||||||
export default async function LoginLogsPage() {
|
export default async function LoginLogsPage() {
|
||||||
const isAdmin = await isAdminAuthenticated();
|
const isAdmin = await isAdminAuthenticated();
|
||||||
@@ -24,9 +25,9 @@ export default async function LoginLogsPage() {
|
|||||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||||
<AdminNav />
|
<AdminNav />
|
||||||
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
<div className="flex flex-wrap items-baseline justify-between gap-3">
|
||||||
<h1 className="font-display text-3xl">Login Activity</h1>
|
<h1 className="font-display text-3xl">Admin Login Activity</h1>
|
||||||
<Link href="/admin" className="text-sm text-clay hover:text-clay-dark">
|
<Link href="/admin/orders" className="text-sm text-clay hover:text-clay-dark">
|
||||||
Back to admin
|
Back to orders
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -50,76 +51,14 @@ export default async function LoginLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs */}
|
{/* Recent Logins - Collapsible */}
|
||||||
{logs.length === 0 ? (
|
<div className="mt-8">
|
||||||
<p className="mt-8 text-sm text-muted">No login attempts recorded.</p>
|
<h2 className="font-display text-2xl mb-4">Recent Login Activity</h2>
|
||||||
) : (
|
<CollapsibleAdminLoginTable logs={logs} />
|
||||||
<div className="mt-8 overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-line">
|
|
||||||
<th className="text-left py-2 px-3 font-medium">Date & Time</th>
|
|
||||||
<th className="text-left py-2 px-3 font-medium">Status</th>
|
|
||||||
<th className="text-left py-2 px-3 font-medium">IP Address</th>
|
|
||||||
<th className="text-left py-2 px-3 font-medium">Browser / Device</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{logs.map((log) => {
|
|
||||||
// Parse user agent to show simplified browser/device info
|
|
||||||
const ua = log.userAgent || 'Unknown';
|
|
||||||
let device = 'Unknown';
|
|
||||||
if (ua.includes('Windows')) device = 'Windows';
|
|
||||||
else if (ua.includes('Mac')) device = 'Mac';
|
|
||||||
else if (ua.includes('Linux')) device = 'Linux';
|
|
||||||
else if (ua.includes('iPhone')) device = 'iPhone';
|
|
||||||
else if (ua.includes('Android')) device = 'Android';
|
|
||||||
|
|
||||||
let browser = 'Unknown';
|
|
||||||
if (ua.includes('Chrome')) browser = 'Chrome';
|
|
||||||
else if (ua.includes('Safari')) browser = 'Safari';
|
|
||||||
else if (ua.includes('Firefox')) browser = 'Firefox';
|
|
||||||
else if (ua.includes('Edge')) browser = 'Edge';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={log.id} className="border-b border-line hover:bg-surface">
|
|
||||||
<td className="py-2 px-3 font-mono text-xs">
|
|
||||||
{log.loginAt.toLocaleDateString('en-GB', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})}{' '}
|
|
||||||
{log.loginAt.toLocaleTimeString('en-GB', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3">
|
|
||||||
<span
|
|
||||||
className={`tag-label rounded-full px-3 py-1 ${
|
|
||||||
log.success
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-splash-pink/10 text-splash-pink'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{log.success ? 'Success' : 'Failed'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 px-3 font-mono text-xs">{log.ipAddress || '—'}</td>
|
|
||||||
<td className="py-2 px-3 text-xs text-muted">
|
|
||||||
{browser} / {device}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-8 text-xs text-muted">
|
<p className="mt-8 text-xs text-muted">
|
||||||
Showing last 100 login attempts. Data is kept for security auditing purposes.
|
Showing last 100 login attempts. Recent activity shows only the last 10 logins.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface AdminLoginLog {
|
||||||
|
id: string;
|
||||||
|
loginAt: Date;
|
||||||
|
success: boolean;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: AdminLoginLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollapsibleAdminLoginTable({ logs }: Props) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const recentLogs = logs.slice(0, 10);
|
||||||
|
|
||||||
|
const parseUserAgent = (ua: string | null) => {
|
||||||
|
if (!ua) return { browser: 'Unknown', device: 'Unknown' };
|
||||||
|
|
||||||
|
let device = 'Unknown';
|
||||||
|
if (ua.includes('Windows')) device = 'Windows';
|
||||||
|
else if (ua.includes('Mac')) device = 'Mac';
|
||||||
|
else if (ua.includes('Linux')) device = 'Linux';
|
||||||
|
else if (ua.includes('iPhone')) device = 'iPhone';
|
||||||
|
else if (ua.includes('Android')) device = 'Android';
|
||||||
|
|
||||||
|
let browser = 'Unknown';
|
||||||
|
if (ua.includes('Chrome')) browser = 'Chrome';
|
||||||
|
else if (ua.includes('Safari')) browser = 'Safari';
|
||||||
|
else if (ua.includes('Firefox')) browser = 'Firefox';
|
||||||
|
else if (ua.includes('Edge')) browser = 'Edge';
|
||||||
|
|
||||||
|
return { browser, device };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-line bg-surface rounded">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-surface-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{isExpanded ? '▼' : '▶'} Last 10 Login Attempts
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted">{recentLogs.length} entries</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-line overflow-x-auto">
|
||||||
|
{recentLogs.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-muted">No login attempts recorded.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line bg-surface-1">
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Date & Time</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Status</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">IP Address</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Browser / Device</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentLogs.map((log) => {
|
||||||
|
const { browser, device } = parseUserAgent(log.userAgent);
|
||||||
|
return (
|
||||||
|
<tr key={log.id} className="border-b border-line hover:bg-surface-1">
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">
|
||||||
|
{new Date(log.loginAt).toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}{' '}
|
||||||
|
{new Date(log.loginAt).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span
|
||||||
|
className={`tag-label rounded-full px-3 py-1 text-xs ${
|
||||||
|
log.success
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-splash-pink/10 text-splash-pink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.success ? 'Success' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">{log.ipAddress || '—'}</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-muted">
|
||||||
|
{browser} / {device}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface LoginLog {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
loginAt: Date;
|
||||||
|
success: boolean;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
logs: LoginLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CollapsibleLoginTable({ logs }: Props) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const recentLogs = logs.slice(0, 10);
|
||||||
|
|
||||||
|
const parseUserAgent = (ua: string | null) => {
|
||||||
|
if (!ua) return { browser: 'Unknown', device: 'Unknown' };
|
||||||
|
|
||||||
|
let device = 'Unknown';
|
||||||
|
if (ua.includes('Windows')) device = 'Windows';
|
||||||
|
else if (ua.includes('Mac')) device = 'Mac';
|
||||||
|
else if (ua.includes('Linux')) device = 'Linux';
|
||||||
|
else if (ua.includes('iPhone')) device = 'iPhone';
|
||||||
|
else if (ua.includes('Android')) device = 'Android';
|
||||||
|
|
||||||
|
let browser = 'Unknown';
|
||||||
|
if (ua.includes('Chrome')) browser = 'Chrome';
|
||||||
|
else if (ua.includes('Safari')) browser = 'Safari';
|
||||||
|
else if (ua.includes('Firefox')) browser = 'Firefox';
|
||||||
|
else if (ua.includes('Edge')) browser = 'Edge';
|
||||||
|
|
||||||
|
return { browser, device };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-line bg-surface rounded">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-surface-1 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
{isExpanded ? '▼' : '▶'} Last 10 Login Attempts
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted">{recentLogs.length} entries</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-line overflow-x-auto">
|
||||||
|
{recentLogs.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-muted">No login attempts recorded.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line bg-surface-1">
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Date & Time</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Email</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Status</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">IP Address</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium">Browser / Device</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentLogs.map((log) => {
|
||||||
|
const { browser, device } = parseUserAgent(log.userAgent);
|
||||||
|
return (
|
||||||
|
<tr key={log.id} className="border-b border-line hover:bg-surface-1">
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">
|
||||||
|
{new Date(log.loginAt).toLocaleDateString('en-GB', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})}{' '}
|
||||||
|
{new Date(log.loginAt).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs">
|
||||||
|
<span className="break-all">{log.email}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span
|
||||||
|
className={`tag-label rounded-full px-3 py-1 text-xs ${
|
||||||
|
log.success
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-splash-pink/10 text-splash-pink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{log.success ? 'Success' : 'Failed'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 font-mono text-xs">{log.ipAddress || '—'}</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-muted">
|
||||||
|
{browser} / {device}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user