add certificate

This commit is contained in:
root
2026-05-18 00:07:40 -04:00
parent dc7acf2536
commit 0d3cdd3857
13 changed files with 869 additions and 44 deletions
+109 -25
View File
@@ -114,6 +114,21 @@ function formatDateInput(value?: string | null) {
return value ? String(value).slice(0, 10) : ''
}
function SortTh({
col, label, sortCol, sortDir, onSort, style,
}: {
col: string; label: string; sortCol: string; sortDir: 'asc' | 'desc'
onSort: (c: string) => void; style?: React.CSSProperties
}) {
const active = sortCol === col
return (
<th style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', ...style }} onClick={() => onSort(col)}>
{label}{' '}
<span style={{ opacity: active ? 1 : 0.3 }}>{active && sortDir === 'desc' ? '▼' : '▲'}</span>
</th>
)
}
function normalizeAttendanceSearch(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
}
@@ -3043,12 +3058,17 @@ export function AdminIpBansPage() {
const [rows, setRows] = useState<Array<Record<string, unknown>>>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [sortCol, setSortCol] = useState('id')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
const [pageSize, setPageSize] = useState(25)
const [page, setPage] = useState(1)
async function load(currentStatus = status) {
setLoading(true)
try {
const data = await fetchIpBans({ status: currentStatus, perPage: 100 })
const data = await fetchIpBans({ status: currentStatus, perPage: 200 })
setRows((data.data?.bans as Array<Record<string, unknown>>) ?? [])
setPage(1)
setError(null)
} catch (e) {
setError(e instanceof Error ? e.message : 'Unable to load IP bans.')
@@ -3057,9 +3077,36 @@ export function AdminIpBansPage() {
}
}
useEffect(() => {
void load(status)
}, [status])
useEffect(() => { void load(status) }, [status])
function handleSort(col: string) {
if (col === sortCol) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortCol(col)
setSortDir('asc')
}
setPage(1)
}
const sortedRows = useMemo(() => {
const copy = [...rows]
copy.sort((a, b) => {
let av: string | number = String(a[sortCol] ?? '')
let bv: string | number = String(b[sortCol] ?? '')
if (['id', 'attempts'].includes(sortCol)) {
av = Number(av)
bv = Number(bv)
}
if (av < bv) return sortDir === 'asc' ? -1 : 1
if (av > bv) return sortDir === 'asc' ? 1 : -1
return 0
})
return copy
}, [rows, sortCol, sortDir])
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize))
const pagedRows = sortedRows.slice((page - 1) * pageSize, page * pageSize)
async function onUnban(id?: number, all = false) {
await unbanIp(all ? { all: true } : { id })
@@ -3074,13 +3121,17 @@ export function AdminIpBansPage() {
return (
<AdminParityShell title="IP Bans">
{error ? <div className="alert alert-danger">{error}</div> : null}
<div className="d-flex justify-content-between align-items-center mb-3">
<div className="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div className="d-flex align-items-center gap-2">
<label>Show</label>
<select className="form-select form-select-sm" style={{ minWidth: 160 }} value={status} onChange={(e) => setStatus(e.target.value as 'all' | 'active')}>
<select className="form-select form-select-sm" style={{ minWidth: 160 }} value={status} onChange={(e) => { setStatus(e.target.value as 'all' | 'active'); setPage(1) }}>
<option value="all">All entries</option>
<option value="active">Active bans only</option>
</select>
<label className="ms-2">Per page</label>
<select className="form-select form-select-sm" style={{ width: 80 }} value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1) }}>
{[10, 25, 50, 100].map((n) => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<button className="btn btn-danger btn-sm" type="button" onClick={() => void onUnban(undefined, true)}>
Unban All
@@ -3090,23 +3141,23 @@ export function AdminIpBansPage() {
<table className="table table-striped table-hover align-middle">
<thead className="table-dark">
<tr>
<th>ID</th>
<th>IP Address</th>
<th>Attempts</th>
<th>Last Attempt</th>
<th>Blocked Until</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<SortTh col="id" label="ID" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="ip_address" label="IP Address" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="attempts" label="Attempts" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="last_attempt_at" label="Last Attempt" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="blocked_until" label="Blocked Until" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="is_active" label="Status" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="created_at" label="Created" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<SortTh col="updated_at" label="Updated" sortCol={sortCol} sortDir={sortDir} onSort={handleSort} />
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} className="text-muted">Loading IP bans</td></tr>
) : rows.length === 0 ? (
) : pagedRows.length === 0 ? (
<tr><td colSpan={9} className="text-muted">No IP ban records found.</td></tr>
) : rows.map((row) => {
) : pagedRows.map((row) => {
const id = Number(row.id ?? 0)
const active = Boolean(row.is_active)
return (
@@ -3121,17 +3172,11 @@ export function AdminIpBansPage() {
<td>{formatDate(String(row.updated_at ?? ''))}</td>
<td className="d-flex gap-2">
{active ? (
<button className="btn btn-sm btn-primary" type="button" onClick={() => void onUnban(id)}>
Unban
</button>
<button className="btn btn-sm btn-primary" type="button" onClick={() => void onUnban(id)}>Unban</button>
) : (
<>
<button className="btn btn-sm btn-warning" type="button" onClick={() => void onBan(id)}>
Ban 24h
</button>
<button className="btn btn-sm btn-secondary" type="button" onClick={() => void onUnban(id)}>
Clear Attempts
</button>
<button className="btn btn-sm btn-warning" type="button" onClick={() => void onBan(id)}>Ban 24h</button>
<button className="btn btn-sm btn-secondary" type="button" onClick={() => void onUnban(id)}>Clear Attempts</button>
</>
)}
</td>
@@ -3141,6 +3186,45 @@ export function AdminIpBansPage() {
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="d-flex align-items-center justify-content-between mt-2 flex-wrap gap-2">
<small className="text-muted">
Showing {Math.min((page - 1) * pageSize + 1, sortedRows.length)}{Math.min(page * pageSize, sortedRows.length)} of {sortedRows.length}
</small>
<nav>
<ul className="pagination pagination-sm mb-0">
<li className={`page-item ${page === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage(1)}>&laquo;</button>
</li>
<li className={`page-item ${page === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage((p) => p - 1)}>&lsaquo;</button>
</li>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 2)
.reduce<(number | '…')[]>((acc, p, i, arr) => {
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('…')
acc.push(p)
return acc
}, [])
.map((p, i) =>
p === '…' ? (
<li key={`ellipsis-${i}`} className="page-item disabled"><span className="page-link"></span></li>
) : (
<li key={p} className={`page-item ${page === p ? 'active' : ''}`}>
<button className="page-link" onClick={() => setPage(p as number)}>{p}</button>
</li>
)
)}
<li className={`page-item ${page === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage((p) => p + 1)}>&rsaquo;</button>
</li>
<li className={`page-item ${page === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setPage(totalPages)}>&raquo;</button>
</li>
</ul>
</nav>
</div>
)}
</AdminParityShell>
)
}