Files
EpicNext-Cms/src/app/admin/alerts/page.tsx
T
openhands 942bc6fc8d Security hardening, code quality, and ESLint setup
- Remove production DB dump (db_backup_*.sql) and update.log from git tracking
- Add DB backups to .gitignore
- Replace all console.log/console.error with structured logger module
- Translate Dutch error messages to English (link-discord.ts)
- Remove dead code blocks (register-form.tsx false && pattern)
- Add ESLint flat config with TypeScript, React, Next.js, jsx-a11y, and security plugins
- Add Prettier config
- Add eslint-plugin-security for security-aware linting
- Fix all 119+ ESLint warnings across the codebase:
  - Resolve security/detect-object-injection with safe access patterns
  - Resolve security/detect-non-literal-fs-filename with path traversal validation
  - Replace <img> with next/image <Image> component
  - Remove unused variables and imports
  - Replace non-null assertions with proper type guards
  - Replace <a> with <Link> for internal navigation
  - Use next/script Script component for external scripts
- Fix setState-in-useEffect anti-patterns (navbar-color-picker, logo-generator, theme-switcher)
- Add lint and format scripts to package.json

All checks: typecheck ✓, tests 58/58 ✓, lint 0 errors 0 warnings ✓
2026-07-10 22:48:22 +02:00

167 lines
5.1 KiB
TypeScript

import { getTranslations } from "next-intl/server";
import { Bell } from "lucide-react";
import { sendHotelAlert } from "@/actions/admin-alerts";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
const CRITICAL = new Set(["critical", "error", "danger"]);
const WARNING = new Set(["warning", "warn"]);
function fromDate(d: Date | null | undefined): string {
return d ? d.toISOString().slice(0, 16).replace("T", " ") : "—";
}
function severityColor(severity: string): string {
switch (severity.toLowerCase()) {
case "critical":
case "error":
case "danger":
return "var(--color-danger)";
case "warning":
case "warn":
return "var(--color-primary)";
case "success":
return "var(--color-secondary)";
case "info":
case "notice":
return "var(--color-accent)";
default:
return "var(--color-text-muted)";
}
}
function Badge({ label, color }: { label: string; color: string }) {
return (
<span
style={{
display: "inline-block",
padding: "0.1rem 0.55rem",
borderRadius: "999px",
background: color,
color: "#fff",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.02em",
textTransform: "uppercase",
whiteSpace: "nowrap",
}}
>
{label}
</span>
);
}
export default async function AdminAlerts() {
const t = await getTranslations("pages.admin.alerts");
let alerts: Awaited<ReturnType<typeof prisma.alertLogs.findMany>>;
try {
alerts = await prisma.alertLogs.findMany({
orderBy: { id: "desc" },
take: 100,
});
} catch {
alerts = [];
}
const critical = alerts.filter((a) => CRITICAL.has((a.severity || "").toLowerCase())).length;
const warnings = alerts.filter((a) => WARNING.has((a.severity || "").toLowerCase())).length;
const unread = alerts.filter((a) => !a.isRead).length;
return (
<main>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
<Bell size={20} className="text-[var(--color-primary)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("alertsShown")} value={alerts.length} icon="🔔" />
<StatusCard
label={t("critical")}
value={critical}
state={critical > 0 ? "danger" : "ok"}
icon="🚨"
/>
<StatusCard
label={t("warnings")}
value={warnings}
state={warnings > 0 ? "warn" : "neutral"}
icon="⚠️"
/>
<StatusCard
label={t("unread")}
value={unread}
state={unread > 0 ? "warn" : "ok"}
icon="📨"
/>
</div>
<form action={sendHotelAlert} className="admin-card mb-6">
<h3 className="mt-0">{t("form.send")}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t.rich("form.description", {
code: (chunks) => <code>{chunks}</code>,
})}
</p>
<div className="flex gap-2 flex-wrap items-start">
<textarea
name="message"
required
maxLength={1000}
rows={2}
placeholder={t("form.messagePlaceholder")}
className="flex-1 min-w-[240px]"
/>
<button type="submit" className="btn btn-primary">
{t("form.send")}
</button>
</div>
</form>
<section className="admin-card">
<h3 className="mt-0">{t("recentAlerts", { count: alerts.length })}</h3>
{alerts.length === 0 ? (
<div className="admin-empty">{t("noAlerts")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colWhen")}</th>
<th>{t("colSeverity")}</th>
<th>{t("colType")}</th>
<th>{t("colMessage")}</th>
</tr>
</thead>
<tbody>
{alerts.map((a) => (
<tr key={String(a.id)}>
<td className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{fromDate(a.createdAt)}
</td>
<td>
<Badge label={a.severity || "—"} color={severityColor(a.severity)} />
</td>
<td>
<Badge label={a.type || "—"} color="var(--color-text-muted)" />
</td>
<td>{a.message}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}