Files
SkinbaseNova/resources/js/Pages/Admin/AuthAudit.jsx

232 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react'
import { Head, router } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
const EVENT_LABELS = {
login: 'Login',
register: 'Register',
forgot_password: 'Forgot password',
reset_password: 'Reset password',
}
const STATUS_BADGES = {
success: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200',
failed: 'border-rose-400/20 bg-rose-400/10 text-rose-200',
}
function formatTimestamp(value) {
if (!value) {
return 'Unknown'
}
return new Intl.DateTimeFormat('en-GB', {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(new Date(value))
}
function formatLabel(value) {
if (!value) {
return 'Not recorded'
}
return value
.replaceAll('_', ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
function SummaryCard({ label, value, tone = 'slate' }) {
const tones = {
slate: 'border-white/[0.07] bg-white/[0.02] text-white',
rose: 'border-rose-400/15 bg-rose-500/10 text-rose-100',
sky: 'border-sky-400/15 bg-sky-500/10 text-sky-100',
}
return (
<div className={`rounded-2xl border p-5 ${tones[tone] ?? tones.slate}`}>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</p>
<p className="mt-3 text-3xl font-semibold tracking-tight">{value}</p>
</div>
)
}
export default function AuthAudit({ logs, filters, eventOptions, statusOptions }) {
const items = logs?.data ?? []
const failedCount = items.filter((entry) => entry.status === 'failed').length
const uniqueIpCount = new Set(items.map((entry) => entry.ip).filter(Boolean)).size
const handleSearch = (event) => {
event.preventDefault()
const search = event.target.elements.search.value
router.get('/moderation/auth-audit', {
search,
event: filters.event,
status: filters.status,
}, {
preserveState: true,
preserveScroll: true,
})
}
const handleFilterChange = (key, value) => {
router.get('/moderation/auth-audit', {
search: filters.search,
event: key === 'event' ? value : filters.event,
status: key === 'status' ? value : filters.status,
}, {
preserveState: true,
preserveScroll: true,
})
}
return (
<AdminLayout title="Auth Audit" subtitle="Review login, registration, forgot-password, and reset-password activity with IPs, timestamps, status, and failure reasons.">
<Head title="Admin · Auth Audit" />
<div className="grid gap-4 lg:grid-cols-3">
<SummaryCard label="Visible records" value={items.length.toLocaleString()} tone="slate" />
<SummaryCard label="Failures on page" value={failedCount.toLocaleString()} tone="rose" />
<SummaryCard label="Unique IPs on page" value={uniqueIpCount.toLocaleString()} tone="sky" />
</div>
<div className="mt-6 rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<form onSubmit={handleSearch} className="flex flex-1 flex-col gap-3 md:flex-row">
<label className="flex-1">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Search</span>
<input
name="search"
defaultValue={filters.search}
placeholder="Email, username, IP, or failure reason"
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white placeholder:text-slate-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
/>
</label>
<button type="submit" className="rounded-2xl bg-rose-500/80 px-5 py-3 text-sm font-semibold text-white transition hover:bg-rose-500">
Search
</button>
</form>
<div className="grid gap-3 md:grid-cols-2 xl:min-w-[26rem]">
<label>
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Event</span>
<select
value={filters.event}
onChange={(event) => handleFilterChange('event', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
>
{eventOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-950 text-white">
{option.label}
</option>
))}
</select>
</label>
<label>
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Status</span>
<select
value={filters.status}
onChange={(event) => handleFilterChange('status', event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value} className="bg-slate-950 text-white">
{option.label}
</option>
))}
</select>
</label>
</div>
</div>
</div>
<div className="mt-6 overflow-hidden rounded-[28px] border border-white/[0.07] bg-white/[0.02]">
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-sm">
<thead>
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
<th className="px-5 py-4">When</th>
<th className="px-5 py-4">Event</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Identifier</th>
<th className="px-5 py-4">User</th>
<th className="px-5 py-4">IP</th>
<th className="px-5 py-4">Reason</th>
<th className="px-5 py-4">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.05]">
{items.length === 0 ? (
<tr>
<td colSpan={8} className="px-5 py-12 text-center text-slate-500">No auth audit records matched the current filters.</td>
</tr>
) : items.map((entry) => (
<tr key={entry.id} className="align-top transition hover:bg-white/[0.025]">
<td className="px-5 py-4 text-slate-300">{formatTimestamp(entry.created_at)}</td>
<td className="px-5 py-4 text-white">{EVENT_LABELS[entry.event_type] ?? formatLabel(entry.event_type)}</td>
<td className="px-5 py-4">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${STATUS_BADGES[entry.status] ?? 'border-white/10 bg-white/[0.04] text-white/70'}`}>
{entry.status}
</span>
</td>
<td className="px-5 py-4 text-slate-300">{entry.identifier || 'Not recorded'}</td>
<td className="px-5 py-4">
{entry.user ? (
<div>
<p className="font-medium text-white">{entry.user.name}</p>
<p className="text-xs text-slate-500">{entry.user.username ? `@${entry.user.username}` : entry.user.email}</p>
</div>
) : <span className="text-slate-500">Unknown user</span>}
</td>
<td className="px-5 py-4 text-slate-300">{entry.ip || 'Unknown'}</td>
<td className="px-5 py-4 text-slate-300">{formatLabel(entry.reason)}</td>
<td className="px-5 py-4">
<details className="group w-72 max-w-full">
<summary className="cursor-pointer list-none text-sm font-medium text-sky-200 transition hover:text-sky-100">
View payload
</summary>
<div className="mt-3 space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs text-slate-300">
<div>
<p className="font-semibold uppercase tracking-[0.16em] text-slate-500">User agent</p>
<p className="mt-1 break-words leading-5 text-slate-300">{entry.user_agent || 'Not recorded'}</p>
</div>
<div>
<p className="font-semibold uppercase tracking-[0.16em] text-slate-500">Metadata</p>
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap break-words rounded-xl bg-slate-950/70 p-3 text-[11px] leading-5 text-slate-300">{JSON.stringify(entry.metadata || {}, null, 2)}</pre>
</div>
</div>
</details>
</td>
</tr>
))}
</tbody>
</table>
</div>
{logs?.last_page > 1 ? (
<div className="flex items-center justify-between border-t border-white/[0.06] px-5 py-4">
<p className="text-xs text-slate-500">
Showing {logs.from}{logs.to} of {logs.total} audit records
</p>
<div className="flex gap-1">
{logs.links.map((link, index) => (
link.url ? (
<button
key={`${link.label}-${index}`}
type="button"
onClick={() => router.get(link.url, {}, { preserveScroll: true, preserveState: true })}
className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-rose-500/20 font-semibold text-rose-300' : 'text-slate-500 hover:bg-white/[0.06] hover:text-white'}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
) : (
<span key={`${link.label}-${index}`} className="rounded-lg px-3 py-1.5 text-xs text-slate-700" dangerouslySetInnerHTML={{ __html: link.label }} />
)
))}
</div>
</div>
) : null}
</div>
</AdminLayout>
)
}