add certificate
This commit is contained in:
@@ -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)}>«</button>
|
||||
</li>
|
||||
<li className={`page-item ${page === 1 ? 'disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setPage((p) => p - 1)}>‹</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)}>›</button>
|
||||
</li>
|
||||
<li className={`page-item ${page === totalPages ? 'disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setPage(totalPages)}>»</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</AdminParityShell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user