217 lines
10 KiB
React
217 lines
10 KiB
React
import React from 'react'
|
||
import { Head, router, usePage } from '@inertiajs/react'
|
||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||
|
||
function formatTimestamp(value) {
|
||
if (!value) {
|
||
return 'Not finished'
|
||
}
|
||
|
||
return new Intl.DateTimeFormat('en-GB', {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
}).format(new Date(value))
|
||
}
|
||
|
||
function SummaryCard({ label, value, tone }) {
|
||
const tones = {
|
||
red: 'border-rose-400/15 bg-rose-500/10 text-rose-100',
|
||
orange: 'border-orange-400/15 bg-orange-500/10 text-orange-100',
|
||
yellow: 'border-amber-400/15 bg-amber-500/10 text-amber-100',
|
||
blue: 'border-sky-400/15 bg-sky-500/10 text-sky-100',
|
||
slate: 'border-white/[0.07] bg-white/[0.02] text-white',
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|
||
|
||
function RiskBadge({ label }) {
|
||
const tone = label === 'Critical'
|
||
? 'border-rose-400/20 bg-rose-500/10 text-rose-200'
|
||
: label === 'High'
|
||
? 'border-orange-400/20 bg-orange-500/10 text-orange-200'
|
||
: label === 'Medium'
|
||
? 'border-amber-400/20 bg-amber-500/10 text-amber-200'
|
||
: label === 'Low'
|
||
? 'border-sky-400/20 bg-sky-500/10 text-sky-200'
|
||
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
|
||
|
||
return <span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${tone}`}>{label}</span>
|
||
}
|
||
|
||
export default function SecurityReportIndex({ latest, reports, canRunScan }) {
|
||
const flash = usePage().props.flash ?? {}
|
||
const items = reports?.data ?? []
|
||
|
||
const runScan = () => {
|
||
router.post('/moderation/system/security-report/run')
|
||
}
|
||
|
||
return (
|
||
<AdminLayout title="Security Report" subtitle="Private Composer and npm vulnerability reporting for administrators.">
|
||
<Head title="Admin · Security Report" />
|
||
|
||
<div className="space-y-6">
|
||
{flash.success ? (
|
||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||
{flash.success}
|
||
</div>
|
||
) : null}
|
||
|
||
{latest ? (
|
||
<>
|
||
<div className="grid gap-4 md:grid-cols-5">
|
||
<SummaryCard label="Critical" value={latest.total_critical} tone="red" />
|
||
<SummaryCard label="High" value={latest.total_high} tone="orange" />
|
||
<SummaryCard label="Medium" value={latest.total_medium} tone="yellow" />
|
||
<SummaryCard label="Low" value={latest.total_low} tone="blue" />
|
||
<SummaryCard label="Unknown" value={latest.total_unknown} tone="slate" />
|
||
</div>
|
||
|
||
<div className="rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-6">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Latest scan</p>
|
||
<div className="mt-3 flex items-center gap-3">
|
||
<RiskBadge label={latest.risk_label} />
|
||
<span className="text-sm text-slate-400">{latest.status}</span>
|
||
</div>
|
||
</div>
|
||
{canRunScan ? (
|
||
<button
|
||
type="button"
|
||
onClick={runScan}
|
||
className="rounded-2xl bg-rose-500/80 px-5 py-3 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||
>
|
||
Run Scan
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
<dl className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||
<div>
|
||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Started</dt>
|
||
<dd className="mt-2 text-sm text-white">{formatTimestamp(latest.started_at)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finished</dt>
|
||
<dd className="mt-2 text-sm text-white">{formatTimestamp(latest.finished_at)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Triggered by</dt>
|
||
<dd className="mt-2 text-sm text-white">{latest.triggered_by || 'Unknown'}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Composer outdated</dt>
|
||
<dd className="mt-2 text-sm text-white">{latest.composer_outdated_count}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">npm outdated</dt>
|
||
<dd className="mt-2 text-sm text-white">{latest.npm_outdated_count}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Requested by</dt>
|
||
<dd className="mt-2 text-sm text-white">{latest.user ? (latest.user.username ? `@${latest.user.username}` : latest.user.name) : 'System'}</dd>
|
||
</div>
|
||
</dl>
|
||
|
||
<div className="mt-6">
|
||
<a href={latest.show_url} className="inline-flex rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-medium text-white transition hover:bg-white/[0.08]">
|
||
View Details
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="rounded-[28px] border border-amber-400/20 bg-amber-500/10 p-6 text-amber-100">
|
||
<p className="text-sm font-semibold">No security report has been generated yet.</p>
|
||
<p className="mt-2 text-sm text-amber-50/80">Run <code className="rounded bg-black/20 px-1.5 py-0.5">php artisan security:scan --notify</code> or use the manual scan button after the queue worker is running.</p>
|
||
{canRunScan ? (
|
||
<button
|
||
type="button"
|
||
onClick={runScan}
|
||
className="mt-4 rounded-2xl bg-rose-500/80 px-5 py-3 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||
>
|
||
Run Scan
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
|
||
<div className="overflow-hidden rounded-[28px] border border-white/[0.07] bg-white/[0.02]">
|
||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||
<h2 className="text-sm font-bold uppercase tracking-[0.18em] text-slate-400">Scan History</h2>
|
||
</div>
|
||
|
||
<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">ID</th>
|
||
<th className="px-5 py-4">Finished</th>
|
||
<th className="px-5 py-4">Status</th>
|
||
<th className="px-5 py-4">Risk</th>
|
||
<th className="px-5 py-4">Critical</th>
|
||
<th className="px-5 py-4">High</th>
|
||
<th className="px-5 py-4">Medium</th>
|
||
<th className="px-5 py-4">Low</th>
|
||
<th className="px-5 py-4">Outdated</th>
|
||
<th className="px-5 py-4"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-white/[0.05]">
|
||
{items.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={10} className="px-5 py-12 text-center text-slate-500">No reports recorded yet.</td>
|
||
</tr>
|
||
) : items.map((report) => (
|
||
<tr key={report.id} className="transition hover:bg-white/[0.025]">
|
||
<td className="px-5 py-4 text-white">#{report.id}</td>
|
||
<td className="px-5 py-4 text-slate-300">{formatTimestamp(report.finished_at)}</td>
|
||
<td className="px-5 py-4 text-slate-300">{report.status}</td>
|
||
<td className="px-5 py-4"><RiskBadge label={report.risk_label} /></td>
|
||
<td className="px-5 py-4 text-slate-300">{report.total_critical}</td>
|
||
<td className="px-5 py-4 text-slate-300">{report.total_high}</td>
|
||
<td className="px-5 py-4 text-slate-300">{report.total_medium}</td>
|
||
<td className="px-5 py-4 text-slate-300">{report.total_low}</td>
|
||
<td className="px-5 py-4 text-slate-300">Composer: {report.composer_outdated_count}, npm: {report.npm_outdated_count}</td>
|
||
<td className="px-5 py-4">
|
||
<a href={report.show_url} className="text-sky-200 transition hover:text-sky-100">Details</a>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{reports?.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 {reports.from}–{reports.to} of {reports.total} reports</p>
|
||
<div className="flex gap-1">
|
||
{reports.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>
|
||
</div>
|
||
</AdminLayout>
|
||
)
|
||
}
|