#35: replace both web surfaces with the Nocturne redesign
The webmail was indigo over glass and the console was green over glass, so the
two halves of one product did not look related, and neither followed a system
anything could be checked against. This replaces the interface with the
Nocturne design system from Claude Design: a near-neutral blue-grey ground,
Inter at weight 500, solid surfaces with a 1px inset edge instead of blur, and
a single blurple accent used as a line, a mark and a glow rather than a flood.
The palette is remapped at the source rather than renamed at every call site.
tailwind.config.js points slate, indigo, emerald, teal and the rest at the
Nocturne ramps, and globals.css carries the same values as variables, so a
utility class and a token cannot drift apart and no colour can enter the
interface from outside the system. The console's green is gone because it
resolves to the one accent; that was the point.
Structurally, both surfaces now share one rail - the same brand mark, the same
3px accent mark on the active item, the same account tile - with a segmented
control for crossing between them. Crossing into the console still goes to the
step-up rather than the admin sign-in, and leaving it is still a plain link.
Settings moves from eight stacked panels to a tab strip, so enabling a second
factor and writing a filter are no longer separated by everything else.
Nothing is sized by the length of its text any more. Labels wrap, tab strips
wrap instead of scrolling, subjects and queue diagnostics wrap instead of
truncating, and the message list's snippet is clamped to two lines rather than
one. That is what broke when the interface was read in Portuguese or Spanish.
Defects found while doing it:
- The queue and DMARC screens styled their status pills with "badge-danger",
"badge-warning" and "badge-verified" - three class names no stylesheet in
this project defines - so every verdict rendered as identical unstyled
text. They are Badge variants now, which exist.
- The DMARC screen printed the literal string "quarantine" as the
enforcement level whatever the domains publish. The report endpoint returns
no policy at all, so it was asserting a configuration nobody had made. It
shows the DMARC pass ratio it genuinely has.
- Half the mailboxes screen and two lines of the audit trace were hardcoded
strings, some Portuguese and some English, so they stayed in one language
whatever the interface was set to. They come from the bundle now, with the
new keys added to all three locales.
- Avatars printed email[0], so every account at one domain collapsed onto the
same letter. initialsFor takes two letters from the local part's own word
boundaries, behind tests.
Trivy reported one HIGH: nanoid 3.3.16, pulled in transitively by postcss,
vulnerable to a denial of service via an infinite loop in ID generation
(CVE-2026-67213). Pinned to ^3.3.18 through the overrides block the package
already uses for sharp and postcss.
Verified: 98 webmail tests green, tsc clean, production build clean, and the
compiled stylesheet serves the Nocturne values with no stock Tailwind indigo,
emerald or slate hex left in it. Trivy fs with the CI scanners, severities and
skip-dirs exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8b69521c76
commit
b33fc12f08
29 files changed
+2596
-1893
No files matched your search
@@ -7,7 +7,6 @@ import {
|
||||
Send,
|
||||
Layers,
|
||||
HardDrive,
|
||||
CheckCircle2,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Globe,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
|
||||
@@ -70,30 +68,27 @@ export default function AdminDashboardPage() {
|
||||
const shown = (value: number | undefined, format?: (n: number) => string) =>
|
||||
value === undefined || value === null ? t("ui.noData") : format ? format(value) : String(value);
|
||||
|
||||
/* The KPI tiles no longer each carry their own hue. Four colours across four
|
||||
numbers implied four kinds of thing; they are all just counts, and the one
|
||||
accent marks the icon. */
|
||||
const kpis = [
|
||||
{
|
||||
title: t("ui.inboundVolume"),
|
||||
value: shown(metrics?.inbound_24h),
|
||||
subtext: t("ui.messageVolume"),
|
||||
icon: Inbox,
|
||||
color: "text-indigo-400",
|
||||
bg: "bg-indigo-500/10",
|
||||
},
|
||||
{
|
||||
title: t("ui.outboundDelivered"),
|
||||
value: shown(metrics?.outbound_24h),
|
||||
subtext: t("ui.daneVerified"),
|
||||
icon: Send,
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-500/10",
|
||||
},
|
||||
{
|
||||
title: t("ui.queueDepth"),
|
||||
value: shown(metrics?.queue_depth),
|
||||
subtext: t("ui.postgresRunner"),
|
||||
icon: Layers,
|
||||
color: "text-cyan-400",
|
||||
bg: "bg-cyan-500/10",
|
||||
},
|
||||
{
|
||||
title: t("ui.diskUsed"),
|
||||
@@ -102,42 +97,42 @@ export default function AdminDashboardPage() {
|
||||
? `${t("ui.storageQuota")}: ${formatBytes(metrics.storage_quota_bytes)}`
|
||||
: t("ui.localDisk"),
|
||||
icon: HardDrive,
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-500/10",
|
||||
},
|
||||
];
|
||||
|
||||
const panel = "flex flex-col gap-3.5 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-5 px-5 pb-11 pt-7 sm:px-8">
|
||||
{/* Header Bar */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">
|
||||
{t("admin.dashboardTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">{t("admin.dashboardSubtitle")}</p>
|
||||
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.dashboardSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Reports the count it can actually see. The old badge asserted
|
||||
"PREFLIGHT: HEALTHY" from a field the endpoint never sends, so it
|
||||
said healthy unconditionally. The real checks live on the
|
||||
security screen. */}
|
||||
{metrics && (
|
||||
<Badge variant={metrics.domains_verified === metrics.domains_total ? "success" : "warning"}>
|
||||
<Zap className="w-3.5 h-3.5 mr-1" />
|
||||
<Zap className="h-3.5 w-3.5 flex-none" />
|
||||
{t("ui.domainsVerified")}: {metrics.domains_verified}/{metrics.domains_total}
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={fetchMetrics} disabled={loading}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
<Button variant="secondary" size="sm" onClick={fetchMetrics} disabled={loading}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
<span>{t("common.refresh")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{kpis.map((kpi, idx) => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
@@ -146,22 +141,16 @@ export default function AdminDashboardPage() {
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: idx * 0.05 }}
|
||||
className="flex flex-col gap-2.5 rounded-2xl bg-dark-panel p-4 shadow-edge"
|
||||
>
|
||||
<Card hoverable className="relative overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-400">{kpi.title}</span>
|
||||
<div className={`w-9 h-9 rounded-xl ${kpi.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-5 h-5 ${kpi.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-black text-white mt-3 tracking-tight">
|
||||
{kpi.value}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-2 flex items-center gap-1 font-mono">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 inline" />
|
||||
{kpi.subtext}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Icon className="h-4 w-4 flex-none text-indigo-500" />
|
||||
<span className="text-[12.5px] leading-snug">{kpi.title}</span>
|
||||
</div>
|
||||
<div className="text-[30px] font-medium leading-none tracking-[-0.02em] tabular-nums text-slate-100">
|
||||
{kpi.value}
|
||||
</div>
|
||||
<div className="text-[11.5px] leading-snug text-slate-400">{kpi.subtext}</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
@@ -179,46 +168,48 @@ export default function AdminDashboardPage() {
|
||||
compose healthchecks, and the per-domain DNS state lives on the domains
|
||||
screen, which reads it from the database.
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-indigo-400" />
|
||||
<section className={panel}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||
{t("ui.domainVerification")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</h2>
|
||||
</div>
|
||||
{metrics ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
||||
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
||||
{t("ui.domainsVerified")}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
<div className="text-2xl font-medium tabular-nums text-slate-100">
|
||||
{metrics.domains_verified}
|
||||
<span className="text-slate-500 text-base"> / {metrics.domains_total}</span>
|
||||
<span className="text-base text-slate-500"> / {metrics.domains_total}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
||||
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
||||
<Server className="w-3.5 h-3.5 text-indigo-400" />
|
||||
<div className="flex flex-col gap-1.5 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<Server className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
||||
{t("ui.mailboxesActive")}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">{metrics.mailboxes_active}</div>
|
||||
<div className="text-2xl font-medium tabular-nums text-slate-100">
|
||||
{metrics.mailboxes_active}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800">
|
||||
<div className="text-xs text-slate-400 mb-1.5 flex items-center gap-1.5">
|
||||
<Send className="w-3.5 h-3.5 text-amber-400" />
|
||||
<div className="flex flex-col gap-1.5 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-400">
|
||||
<Send className="h-3.5 w-3.5 flex-none text-indigo-500" />
|
||||
{t("ui.bounceRate")}
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
<div className="text-2xl font-medium tabular-nums text-slate-100">
|
||||
{(metrics.bounce_rate * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center text-xs text-slate-500">{t("ui.noData")}</div>
|
||||
<div className="p-6 text-center text-[13px] text-slate-400">{t("ui.noData")}</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
|
||||
interface DmarcSource {
|
||||
ip: string;
|
||||
@@ -30,78 +31,75 @@ export default function AdminDmarcPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const pct = (part: number) =>
|
||||
data && data.total_messages > 0 ? `${Math.round((part / data.total_messages) * 100)}%` : t("ui.noData");
|
||||
|
||||
const tiles = [
|
||||
{ label: t("ui.totalEvaluated"), value: data ? String(data.total_messages) : t("ui.noData") },
|
||||
{ label: t("ui.spfRatio"), value: data ? pct(data.spf_pass_count) : t("ui.noData") },
|
||||
{ label: t("ui.dkimAlignment"), value: data ? pct(data.dkim_pass_count) : t("ui.noData") },
|
||||
/* This tile printed the literal string "quarantine" whatever the domains
|
||||
actually publish - the report endpoint returns no policy at all, so it
|
||||
was asserting an enforcement level nobody had configured. It reports the
|
||||
DMARC pass ratio it genuinely has instead. */
|
||||
{ label: t("ui.dmarcEnforcement"), value: data ? pct(data.dmarc_pass_count) : t("ui.noData") },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.dmarcTitle")}</h1>
|
||||
<p className="text-xs text-slate-400">{t("admin.dmarcSubtitle")}</p>
|
||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.dmarcTitle")}</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.dmarcSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* DMARC Stats Summary Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
||||
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.totalEvaluated")}</div>
|
||||
<div className="text-3xl font-extrabold text-white">{data ? data.total_messages : t("ui.noData")}</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
||||
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.spfRatio")}</div>
|
||||
<div className="text-3xl font-extrabold text-emerald-400">
|
||||
{data && data.total_messages > 0 ? `${Math.round((data.spf_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{tiles.map((tile) => (
|
||||
<div key={tile.label} className="flex flex-col gap-2.5 rounded-2xl bg-dark-panel p-4 shadow-edge">
|
||||
<div className="text-[12.5px] leading-snug text-slate-400">{tile.label}</div>
|
||||
<div className="text-[30px] font-medium leading-none tracking-[-0.02em] tabular-nums text-slate-100">
|
||||
{tile.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
||||
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.dkimAlignment")}</div>
|
||||
<div className="text-3xl font-extrabold text-emerald-400">
|
||||
{data && data.total_messages > 0 ? `${Math.round((data.dkim_pass_count / data.total_messages) * 100)}%` : t("ui.noData")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
||||
<div className="text-xs font-medium text-slate-400 mb-2">{t("ui.dmarcEnforcement")}</div>
|
||||
<div className="text-3xl font-extrabold text-indigo-400">quarantine</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sending Sources Table */}
|
||||
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
|
||||
{t("ui.sourceIp")}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<table className="lm-table">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||
<th className="p-3">{t("ui.sourceIp")}</th>
|
||||
<th className="p-3">{t("ui.reportingOrg")}</th>
|
||||
<th className="p-3">{t("ui.messageVolume")}</th>
|
||||
<th className="p-3">{t("ui.spfResult")}</th>
|
||||
<th className="p-3">{t("ui.dkimResult")}</th>
|
||||
<tr>
|
||||
<th className="pl-0">{t("ui.sourceIp")}</th>
|
||||
<th>{t("ui.reportingOrg")}</th>
|
||||
<th>{t("ui.messageVolume")}</th>
|
||||
<th>{t("ui.spfResult")}</th>
|
||||
<th className="pr-0 text-right">{t("ui.dkimResult")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||
<tbody>
|
||||
{(data?.sources || []).map((src, idx) => (
|
||||
<tr key={idx} className="hover:bg-slate-900/30">
|
||||
<td className="p-3 font-bold text-slate-200">{src.ip}</td>
|
||||
<td className="p-3 text-slate-300">{src.org}</td>
|
||||
<td className="p-3 text-slate-400">{src.count}</td>
|
||||
<td className="p-3">
|
||||
<span className={src.spf === "pass" ? "badge-verified px-2 py-0.5 rounded text-[10px]" : "badge-danger px-2 py-0.5 rounded text-[10px]"}>
|
||||
{src.spf.toUpperCase()}
|
||||
</span>
|
||||
<tr key={idx}>
|
||||
<td className="pl-0 font-mono text-[12.5px] text-slate-100">{src.ip}</td>
|
||||
<td className="break-words text-[13px] text-slate-300">{src.org}</td>
|
||||
<td className="text-[13px] tabular-nums text-slate-400">{src.count}</td>
|
||||
<td>
|
||||
{/* These were "badge-verified" and "badge-danger", class
|
||||
names no stylesheet defines, so both verdicts rendered
|
||||
as identical unstyled text. */}
|
||||
<Badge variant={src.spf === "pass" ? "success" : "danger"}>{src.spf.toUpperCase()}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className={src.dkim === "pass" ? "badge-verified px-2 py-0.5 rounded text-[10px]" : "badge-danger px-2 py-0.5 rounded text-[10px]"}>
|
||||
{src.dkim.toUpperCase()}
|
||||
</span>
|
||||
<td className="pr-0 text-right">
|
||||
<Badge variant={src.dkim === "pass" ? "success" : "danger"}>{src.dkim.toUpperCase()}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(data?.sources ?? []).length === 0 && (
|
||||
<div className="p-8 text-center text-[13px] text-slate-400">{t("ui.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Card } from "../../../../components/ui/Card";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
|
||||
@@ -197,144 +196,147 @@ export default function AdminDomainsPage() {
|
||||
{ id: "dkim" as const, label: "DKIM", icon: KeyRound },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||
{t("admin.domainsTitle")}
|
||||
</h1>
|
||||
</div>
|
||||
const panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
|
||||
const input =
|
||||
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
|
||||
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||
|
||||
<div className="flex bg-slate-900/90 p-1.5 rounded-xl border border-slate-800 text-xs font-medium self-start md:self-auto">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
||||
activeTab === tab.id
|
||||
? "bg-emerald-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||
<div>
|
||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.domainsTitle")}</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.verifyIntro")}</p>
|
||||
</div>
|
||||
|
||||
<div className="lm-tabstrip self-start">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
data-active={activeTab === tab.id}
|
||||
aria-pressed={activeTab === tab.id}
|
||||
className="lm-tab"
|
||||
>
|
||||
<Icon className="h-[15px] w-[15px] flex-none" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div className="p-4 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{notice}</span>
|
||||
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-4 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||
<span className="break-words">{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
|
||||
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] leading-relaxed text-rose-200 shadow-edge">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "domains" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{domains.length === 0 && !loading ? (
|
||||
<Card className="p-8 text-center text-xs text-slate-500">{t("admin.noDomains")}</Card>
|
||||
<div className="rounded-2xl bg-dark-panel p-8 text-center text-[13px] text-slate-400 shadow-edge">
|
||||
{t("admin.noDomains")}
|
||||
</div>
|
||||
) : (
|
||||
domains.map((d) => (
|
||||
<Card key={d.id} className="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-xl bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center text-indigo-400 flex-shrink-0">
|
||||
<Globe className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-bold text-white truncate">{d.name}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
{t("admin.mailboxCount", { count: d.mailbox_count })}
|
||||
{d.dmarc_policy ? ` | DMARC: ${d.dmarc_policy}` : ""}
|
||||
{d.mta_sts_mode ? ` | MTA-STS: ${d.mta_sts_mode}` : ""}
|
||||
{d.dane_enabled ? " | DANE" : ""}
|
||||
</div>
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex flex-col gap-3.5 rounded-2xl bg-dark-panel px-[18px] py-4 shadow-edge"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3.5">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<div className="break-words text-lg font-medium leading-tight text-slate-100">{d.name}</div>
|
||||
<div className="mt-1 text-[12.5px] leading-relaxed text-slate-400">
|
||||
{t("admin.mailboxCount", { count: d.mailbox_count })}
|
||||
{d.dmarc_policy ? ` · DMARC: ${d.dmarc_policy}` : ""}
|
||||
{d.mta_sts_mode ? ` · MTA-STS: ${d.mta_sts_mode}` : ""}
|
||||
{d.dane_enabled ? " · DANE" : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex flex-none flex-wrap items-center gap-2.5">
|
||||
<Badge variant={statusVariant(d.dns_status)}>{d.dns_status}</Badge>
|
||||
<Button
|
||||
variant={selectedId === d.id ? "primary" : "outline"}
|
||||
variant={selectedId === d.id ? "primary" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
>
|
||||
{selectedId === d.id ? <CheckCircle2 className="w-3.5 h-3.5" /> : null}
|
||||
{selectedId === d.id ? <CheckCircle2 className="h-3.5 w-3.5" /> : null}
|
||||
<span>{selectedId === d.id ? t("common.status") : t("common.add")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-slate-800 text-[11px] text-slate-500 font-mono">
|
||||
<div className="text-[11.5px] text-slate-500">
|
||||
{t("admin.lastChecked", {
|
||||
when: d.dns_last_checked_at
|
||||
? new Date(d.dns_last_checked_at).toLocaleString()
|
||||
: t("common.never"),
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<Card className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<p className="text-xs text-slate-400">{t("admin.verifyIntro")}</p>
|
||||
<Button variant="outline" size="sm" onClick={handleReconcile} disabled={reconciling || !selected}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${reconciling ? "animate-spin" : ""}`} />
|
||||
<section className={panel}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||
{selected ? t("admin.dnsRecordsFor", { domain: selected.name }) : t("ui.domainVerification")}
|
||||
</h2>
|
||||
<Button variant="secondary" size="sm" onClick={handleReconcile} disabled={reconciling || !selected}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${reconciling ? "animate-spin" : ""}`} />
|
||||
<span>{t("admin.reconcileDns")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{verification && (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-2xl font-black font-mono ${
|
||||
verification.verified === verification.total ? "text-emerald-400" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="font-mono text-2xl font-medium tabular-nums text-slate-100">
|
||||
{verification.verified} / {verification.total}
|
||||
</span>
|
||||
<Badge variant={statusVariant(verification.status)}>{verification.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<table className="lm-table">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||
<th className="p-2.5">{t("ui.recordName")}</th>
|
||||
<th className="p-2.5">{t("ui.expectedValue")}</th>
|
||||
<th className="p-2.5">{t("common.status")}</th>
|
||||
<tr>
|
||||
<th className="pl-0">{t("ui.recordName")}</th>
|
||||
<th>{t("ui.expectedValue")}</th>
|
||||
<th className="pr-0 text-right">{t("common.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||
<tbody>
|
||||
{verification.records.map((rec, i) => (
|
||||
<tr key={i} className="hover:bg-slate-900/30 align-top">
|
||||
<td className="p-2.5 text-slate-200 whitespace-nowrap">
|
||||
<span className="text-emerald-400 font-bold">{rec.type}</span> {rec.name}
|
||||
<tr key={i} className="align-top">
|
||||
<td className="whitespace-nowrap pl-0 text-[13px]">
|
||||
<Badge variant="neutral" className="mr-2 font-mono">
|
||||
{rec.type}
|
||||
</Badge>
|
||||
<span className="font-mono text-[12.5px] text-slate-200">{rec.name}</span>
|
||||
</td>
|
||||
<td className="p-2.5 text-slate-400 break-all max-w-md">{rec.expected}</td>
|
||||
<td className="p-2.5 whitespace-nowrap">
|
||||
<td className="max-w-[420px] break-all font-mono text-xs text-slate-400">
|
||||
{rec.expected}
|
||||
</td>
|
||||
<td className="whitespace-nowrap pr-0 text-right">
|
||||
{rec.verified ? (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span className="inline-flex items-center gap-1.5 text-[12.5px] text-indigo-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
{rec.proxied ? t("admin.presentProxied") : t("admin.dnsStatusVerified")}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="flex items-center gap-1 text-amber-400"
|
||||
className="inline-flex items-center gap-1.5 text-[12.5px] text-amber-400"
|
||||
title={rec.detail}
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
{t("admin.notPublished")}
|
||||
</span>
|
||||
)}
|
||||
@@ -346,89 +348,105 @@ export default function AdminDomainsPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "onboarding" && (
|
||||
<Card className="max-w-2xl space-y-4">
|
||||
<section className={`${panel} max-w-[620px]`}>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white mb-1">{t("admin.onboardTitle")}</h2>
|
||||
<p className="text-xs text-slate-400">{t("admin.onboardIntro")}</p>
|
||||
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("admin.onboardTitle")}</h2>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.onboardIntro")}</p>
|
||||
</div>
|
||||
<form onSubmit={handleOnboard} className="space-y-3 text-xs">
|
||||
<form onSubmit={handleOnboard} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="font-semibold text-slate-300 block mb-1">{t("admin.domainName")}</label>
|
||||
<label htmlFor="onboard-domain" className={fieldLabel}>
|
||||
{t("admin.domainName")}
|
||||
</label>
|
||||
<input
|
||||
id="onboard-domain"
|
||||
type="text"
|
||||
value={onboardDomain}
|
||||
onChange={(e) => setOnboardDomain(e.target.value)}
|
||||
placeholder="example.org"
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" size="md" disabled={onboarding}>
|
||||
<Plus className="w-4 h-4" />
|
||||
<Button type="submit" variant="primary" size="md" className="self-start" disabled={onboarding}>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>{onboarding ? t("common.loading") : t("common.add")}</span>
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === "dkim" && (
|
||||
<Card className="max-w-3xl space-y-5">
|
||||
<section className={`${panel} max-w-[720px]`}>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white mb-1 flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-indigo-400" />
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<KeyRound className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.dkimTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
{selected ? selected.name : t("admin.noDomains")}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] text-slate-400">{selected ? selected.name : t("admin.noDomains")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.dkimAlgorithm")}</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<label htmlFor="dkim-algorithm" className={fieldLabel}>
|
||||
{t("admin.dkimAlgorithm")}
|
||||
</label>
|
||||
<select
|
||||
id="dkim-algorithm"
|
||||
value={dkimAlgorithm}
|
||||
onChange={(e) => setDkimAlgorithm(e.target.value as "ed25519" | "rsa2048")}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-emerald-500"
|
||||
className={input}
|
||||
>
|
||||
<option value="ed25519">Ed25519</option>
|
||||
<option value="rsa2048">RSA-2048</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.dkimSelector")}</label>
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<label htmlFor="dkim-selector" className={fieldLabel}>
|
||||
{t("admin.dkimSelector")}
|
||||
</label>
|
||||
<input
|
||||
id="dkim-selector"
|
||||
type="text"
|
||||
value={dkimSelector}
|
||||
onChange={(e) => setDkimSelector(e.target.value)}
|
||||
pattern="[A-Za-z0-9-]{1,63}"
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500"
|
||||
className={`${input} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" size="md" onClick={handleRotateDkim} disabled={rotating || !selected}>
|
||||
<KeyRound className="w-4 h-4" />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="self-start"
|
||||
onClick={handleRotateDkim}
|
||||
disabled={rotating || !selected}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
<span>{rotating ? t("common.loading") : t("admin.dkimRotate")}</span>
|
||||
</Button>
|
||||
|
||||
{dkimRecord && (
|
||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-3 text-xs">
|
||||
<div className="font-bold text-white">{t("admin.dkimPublishRecord")}</div>
|
||||
<div className="p-3 bg-slate-950 rounded-lg border border-slate-800 font-mono text-[11px] text-emerald-400 break-all">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="text-xs text-slate-400">{t("admin.dkimPublishRecord")}</div>
|
||||
<div className="lm-code break-all px-3.5 py-3">
|
||||
{dkimRecord.name} IN TXT "{dkimRecord.value}"
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-amber-500/15 border border-amber-500/25 text-amber-300">
|
||||
{t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
|
||||
<div className="flex items-start gap-2.5 rounded-xl bg-dark-card px-3.5 py-3 shadow-edge">
|
||||
<AlertTriangle className="mt-px h-4 w-4 flex-none text-amber-400" />
|
||||
<span className="text-[12.5px] leading-relaxed text-slate-300">
|
||||
{t("admin.dkimOverlap", { days: dkimRecord.overlapDays })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,13 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Gauge,
|
||||
Globe,
|
||||
Users,
|
||||
ListOrdered,
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
LogOut,
|
||||
Sliders,
|
||||
Menu,
|
||||
X,
|
||||
Lock,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
import { useTranslations } from "../../../i18n/provider";
|
||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
import { useAccount } from "../../../lib/useAccount";
|
||||
import { initialsFor } from "../../../lib/initials";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations();
|
||||
@@ -29,7 +29,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const account = useAccount("admin");
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin/dashboard", label: t("admin.dashboardTitle"), icon: LayoutDashboard },
|
||||
{ href: "/admin/dashboard", label: t("admin.dashboardTitle"), icon: Gauge },
|
||||
{ href: "/admin/domains", label: t("admin.domainsTitle"), icon: Globe },
|
||||
{ href: "/admin/mailboxes", label: t("admin.mailboxesTitle"), icon: Users },
|
||||
{ href: "/admin/queue", label: t("admin.queueTitle"), icon: ListOrdered },
|
||||
@@ -43,90 +43,90 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
});
|
||||
};
|
||||
|
||||
/* The console's rail is the webmail's rail. It used to be green where the
|
||||
other was indigo, which made one product look like two; the only thing
|
||||
that now says which surface you are in is the line under the brand and
|
||||
which half of the switch is lit. */
|
||||
const SidebarContent = (
|
||||
<div className="flex flex-col justify-between h-full p-4">
|
||||
<div>
|
||||
{/* Admin Surface Branding */}
|
||||
<div className="flex items-center gap-3 px-2 mb-6">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-emerald-600 to-teal-500 border border-emerald-400/30 flex items-center justify-center text-white font-bold shadow-lg shadow-emerald-500/20">
|
||||
<Sliders className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-base text-white tracking-tight">LambdaMail</div>
|
||||
<div className="text-[10px] text-emerald-400 uppercase tracking-widest font-mono font-semibold">
|
||||
{t("common.adminPortal")}
|
||||
</div>
|
||||
<div className="flex h-full flex-col gap-4 p-3">
|
||||
<div className="flex items-center gap-2.5 px-2 py-1">
|
||||
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
λ
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
|
||||
{t("common.adminPortal")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
data-active={isActive}
|
||||
className="lm-nav"
|
||||
>
|
||||
<span className="lm-nav-mark" />
|
||||
<Icon className={`h-[17px] w-[17px] flex-none ${isActive ? "text-indigo-500" : "text-slate-400"}`} />
|
||||
<span className="min-w-0 flex-1 text-[13.5px] leading-snug">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2.5">
|
||||
{/* Leaving the console needs no ceremony - the webmail session was
|
||||
never given up to get here, so this is a plain link back. Coming the
|
||||
other way is what costs a second factor. */}
|
||||
<Link
|
||||
href="/user/mail/inbox"
|
||||
className="mb-6 flex items-center gap-2 p-2.5 rounded-xl bg-indigo-500/10 border border-indigo-500/25 text-xs font-semibold text-indigo-300 hover:border-indigo-500/50 hover:bg-indigo-500/15 transition-all"
|
||||
>
|
||||
<Mail className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{t("admin.backToWebmail")}</span>
|
||||
</Link>
|
||||
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
|
||||
<Link
|
||||
href="/user/mail/inbox"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="min-w-0 truncate">{t("admin.backToWebmail")}</span>
|
||||
</Link>
|
||||
<span className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] bg-dark-card px-2 py-1.5 text-[12px] leading-snug text-indigo-500 shadow-edge-accent">
|
||||
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="min-w-0 truncate">{t("common.adminPortal")}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Admin Navigation */}
|
||||
<nav className="space-y-1 text-sm font-medium">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
<div className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge">
|
||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||
{initialsFor(account?.email)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block break-words text-[12.5px] leading-tight">
|
||||
{account?.email ?? t("common.loading")}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1 text-[10.5px] text-slate-400">
|
||||
<Lock className="h-3 w-3 flex-none text-indigo-500" />
|
||||
{account?.role ?? ""}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-150 ${
|
||||
isActive
|
||||
? "bg-emerald-500/15 text-emerald-300 border border-emerald-500/30 shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isActive ? "text-emerald-400" : "text-slate-400"}`} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Language and session controls */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2 p-2 rounded-xl bg-slate-900/60 border border-slate-800/80">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LanguageSwitcher />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-slate-700/80 text-xs font-medium text-slate-300 hover:text-rose-400 hover:border-rose-500/40 hover:bg-rose-500/10 transition-all"
|
||||
title={t("settings.signOut")}
|
||||
aria-label={t("settings.signOut")}
|
||||
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>{t("settings.signOut")}</span>
|
||||
<LogOut className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Operator Profile Footer */}
|
||||
<div className="pt-3 border-t border-slate-800/80">
|
||||
<div className="flex items-center justify-between p-2.5 rounded-xl bg-slate-900 border border-slate-800">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 flex items-center justify-center font-bold text-xs flex-shrink-0 uppercase">
|
||||
{account?.email?.[0] ?? "?"}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div className="text-xs font-semibold text-slate-200 truncate">
|
||||
{account?.email ?? t("common.loading")}
|
||||
</div>
|
||||
<div className="text-[10px] text-emerald-400 font-mono flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
{account?.role ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -134,25 +134,27 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
||||
{/* Mobile Top Header */}
|
||||
<div className="md:hidden flex items-center justify-between p-4 border-b border-slate-800 bg-slate-900/90 z-20 w-full fixed top-0 left-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center text-white font-bold">
|
||||
<Sliders className="w-4 h-4" />
|
||||
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] md:hidden">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
λ
|
||||
</div>
|
||||
<span className="font-bold text-slate-100">{t("common.appName")} {t("common.adminPortal")}</span>
|
||||
<span className="min-w-0 truncate font-medium text-slate-100">
|
||||
{t("common.appName")} {t("common.adminPortal")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label={t("ui.toggleMenu")}
|
||||
aria-expanded={mobileOpen}
|
||||
className="p-2 rounded-lg bg-slate-800 text-slate-300 hover:text-white"
|
||||
className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
||||
>
|
||||
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop Executive Sidebar */}
|
||||
<aside className="hidden md:flex w-64 border-r border-emerald-950/60 bg-slate-900/70 backdrop-blur-xl flex-col flex-shrink-0">
|
||||
{/* Desktop rail */}
|
||||
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
|
||||
{SidebarContent}
|
||||
</aside>
|
||||
|
||||
@@ -164,7 +166,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-0 z-30 bg-slate-950 w-72 border-r border-slate-800 pt-16 md:hidden"
|
||||
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:hidden"
|
||||
>
|
||||
{SidebarContent}
|
||||
</motion.aside>
|
||||
@@ -172,7 +174,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col overflow-y-auto pt-16 md:pt-0 bg-dark-bg">
|
||||
<main className="flex flex-1 flex-col overflow-y-auto bg-dark-bg pt-14 md:pt-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
import { initialsFor } from "../../../../lib/initials";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
|
||||
interface Mailbox {
|
||||
@@ -213,12 +213,12 @@ export default function AdminMailboxesPage() {
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
setCsvSuccessMessage(`Imported ${data.imported ?? csvPreview.length} mailboxes.`);
|
||||
setCsvSuccessMessage(t("admin.csvImported", { count: data.imported ?? csvPreview.length }));
|
||||
setCsvText("");
|
||||
setCsvPreview([]);
|
||||
await load();
|
||||
} else {
|
||||
setError(data.message ?? "Falha ao importar CSV");
|
||||
setError(data.message ?? t("admin.csvImportFailed"));
|
||||
}
|
||||
} catch {
|
||||
setError(t("errors.serverError"));
|
||||
@@ -226,367 +226,392 @@ export default function AdminMailboxesPage() {
|
||||
setTimeout(() => setCsvSuccessMessage(null), 4000);
|
||||
};
|
||||
|
||||
/* The tab labels, the lock buttons and the two-factor and lock states used to
|
||||
be literal strings - some Portuguese, some English - so half this screen
|
||||
stayed in one language whatever the interface was set to. They all come
|
||||
from the bundle now. */
|
||||
const tabs = [
|
||||
{ id: "mailboxes" as const, label: `${t("admin.tabAccounts")} (${mailboxes.length})`, icon: Users },
|
||||
{ id: "aliases" as const, label: `${t("admin.tabAliases")} (${aliases.length})`, icon: Mail },
|
||||
{ id: "csv" as const, label: t("admin.tabCsv"), icon: FileSpreadsheet },
|
||||
];
|
||||
|
||||
const mfaPolicies = [
|
||||
{ id: "optional" as const, label: t("admin.mfaOptional") },
|
||||
{ id: "required_admins" as const, label: t("admin.mfaAdmins") },
|
||||
{ id: "required_all" as const, label: t("admin.mfaEveryone") },
|
||||
];
|
||||
|
||||
const panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
|
||||
const input =
|
||||
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
|
||||
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||
const iconButton =
|
||||
"flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||
{/* Header & Tabs */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
|
||||
{t("admin.mailboxesTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
{t("admin.mailboxesSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.mailboxesTitle")}</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.mailboxesSubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Selection Navigation */}
|
||||
<div className="flex bg-slate-900/90 p-1.5 rounded-xl border border-slate-800 text-xs font-medium self-start md:self-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab("mailboxes")}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
||||
activeTab === "mailboxes"
|
||||
? "bg-emerald-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Contas ({mailboxes.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("aliases")}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
||||
activeTab === "aliases"
|
||||
? "bg-emerald-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
<span>Aliases ({aliases.length})</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("csv")}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-lg transition-all ${
|
||||
activeTab === "csv"
|
||||
? "bg-emerald-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5" />
|
||||
<span>CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="lm-tabstrip self-start">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
data-active={activeTab === tab.id}
|
||||
aria-pressed={activeTab === tab.id}
|
||||
className="lm-tab"
|
||||
>
|
||||
<Icon className="h-[15px] w-[15px] flex-none" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-sm flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-rose-400 flex-shrink-0" />
|
||||
<div className="flex items-center gap-2.5 rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] leading-relaxed text-rose-200 shadow-edge">
|
||||
<AlertCircle className="h-4 w-4 flex-none" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain MFA Policy Panel */}
|
||||
<Card>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-slate-100 flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||
{t("admin.mfaPolicyTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{t("admin.mfaPolicyIntro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 text-xs">
|
||||
<button
|
||||
onClick={() => setMfaPolicy("optional")}
|
||||
className={`px-3 py-1.5 rounded-lg border font-medium transition-all ${
|
||||
mfaPolicy === "optional"
|
||||
? "bg-slate-800 border-slate-600 text-white"
|
||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t("admin.mfaOptional")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMfaPolicy("required_admins")}
|
||||
className={`px-3 py-1.5 rounded-lg border font-medium transition-all ${
|
||||
mfaPolicy === "required_admins"
|
||||
? "bg-emerald-600/20 border-emerald-500 text-emerald-300"
|
||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t("admin.mfaAdmins")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMfaPolicy("required_all")}
|
||||
className={`px-3 py-1.5 rounded-lg border font-medium transition-all ${
|
||||
mfaPolicy === "required_all"
|
||||
? "bg-indigo-600/20 border-indigo-500 text-indigo-300"
|
||||
: "border-slate-800 text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t("admin.mfaEveryone")}
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-3.5 rounded-2xl bg-dark-panel px-[18px] py-4 shadow-edge">
|
||||
<div className="min-w-[240px] flex-1">
|
||||
<div className="flex items-center gap-2 text-[15px] font-medium leading-snug text-slate-100">
|
||||
<ShieldCheck className="h-4 w-4 flex-none text-indigo-500" />
|
||||
{t("admin.mfaPolicyTitle")}
|
||||
</div>
|
||||
<p className="mt-1 text-[12.5px] leading-relaxed text-slate-400">{t("admin.mfaPolicyIntro")}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* One segmented control instead of three buttons in three different
|
||||
colours: these are three values of one setting, not three actions. */}
|
||||
<div className="flex flex-none flex-wrap gap-[3px] rounded-[10px] bg-dark-card p-[3px] shadow-edge">
|
||||
{mfaPolicies.map((policy) => (
|
||||
<button
|
||||
key={policy.id}
|
||||
type="button"
|
||||
onClick={() => setMfaPolicy(policy.id)}
|
||||
aria-pressed={mfaPolicy === policy.id}
|
||||
className={`rounded-lg px-3 py-1.5 text-[12.5px] leading-snug transition-colors ${
|
||||
mfaPolicy === policy.id
|
||||
? "text-indigo-500 shadow-edge-accent"
|
||||
: "text-slate-400 hover:text-slate-100"
|
||||
}`}
|
||||
>
|
||||
{policy.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TAB 1: MAILBOXES */}
|
||||
{activeTab === "mailboxes" && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4 text-emerald-400" />
|
||||
{t("ui.provisionMailbox")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleCreateMailbox} className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 text-xs">
|
||||
<input
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
placeholder="nova.conta@domain.com"
|
||||
required
|
||||
className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={t("ui.newPassword")}
|
||||
minLength={12}
|
||||
required
|
||||
className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value="USER">USER</option>
|
||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={newQuota}
|
||||
onChange={(e) => setNewQuota(parseInt(e.target.value, 10))}
|
||||
placeholder={t("ui.storageQuota")}
|
||||
className="px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="md">
|
||||
{t("admin.createAccount")}
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<section className={panel}>
|
||||
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||
{t("ui.provisionMailbox")}
|
||||
</h2>
|
||||
<form onSubmit={handleCreateMailbox} className="flex flex-wrap items-end gap-2.5">
|
||||
<div className="min-w-[220px] flex-[2]">
|
||||
<label htmlFor="new-email" className={fieldLabel}>
|
||||
{t("ui.emailAddress")}
|
||||
</label>
|
||||
<input
|
||||
id="new-email"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
placeholder="nova.conta@domain.com"
|
||||
required
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[180px] flex-1">
|
||||
<label htmlFor="new-mailbox-password" className={fieldLabel}>
|
||||
{t("ui.newPassword")}
|
||||
</label>
|
||||
<input
|
||||
id="new-mailbox-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
minLength={12}
|
||||
required
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[160px] flex-1">
|
||||
<label htmlFor="new-role" className={fieldLabel}>
|
||||
{t("ui.role")}
|
||||
</label>
|
||||
<select
|
||||
id="new-role"
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className={input}
|
||||
>
|
||||
<option value="USER">USER</option>
|
||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-[130px] flex-1">
|
||||
<label htmlFor="new-quota" className={fieldLabel}>
|
||||
{t("admin.quotaMb")}
|
||||
</label>
|
||||
<input
|
||||
id="new-quota"
|
||||
type="number"
|
||||
value={newQuota}
|
||||
onChange={(e) => setNewQuota(parseInt(e.target.value, 10))}
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>{t("admin.createAccount")}</span>
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-800 bg-slate-900/80 font-bold text-xs text-slate-300 flex items-center justify-between">
|
||||
<span>Contas Cadastradas ({mailboxes.length})</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<table className="lm-table">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||
<th className="p-3.5">{t("ui.emailAddress")}</th>
|
||||
<th className="p-3.5">{t("ui.accountStatus")}</th>
|
||||
<th className="p-3.5">{t("ui.storageQuota")}</th>
|
||||
<th className="p-3.5">{t("ui.twoFactorSection")}</th>
|
||||
<th className="p-3.5">{t("ui.accountStatus")}</th>
|
||||
<th className="p-3.5">{t("ui.actions")}</th>
|
||||
<tr>
|
||||
<th className="pl-0">{t("ui.emailAddress")}</th>
|
||||
<th>{t("ui.role")}</th>
|
||||
<th>{t("ui.storageQuota")}</th>
|
||||
<th>{t("ui.twoFactorSection")}</th>
|
||||
<th>{t("ui.accountStatus")}</th>
|
||||
<th className="pr-0 text-right">{t("ui.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||
<tbody>
|
||||
{mailboxes.map((mb) => (
|
||||
<tr key={mb.id} className="hover:bg-slate-900/40 transition-colors">
|
||||
<td className="p-3.5 font-bold text-slate-100">{mb.email}</td>
|
||||
<td className="p-3.5 text-slate-300">
|
||||
<Badge variant="neutral">{mb.role}</Badge>
|
||||
<tr key={mb.id}>
|
||||
<td className="pl-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-indigo-900 text-[11px] uppercase text-indigo-300">
|
||||
{initialsFor(mb.email)}
|
||||
</span>
|
||||
<span className="break-words text-[13.5px] text-slate-100">{mb.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 h-2 bg-slate-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full"
|
||||
style={{ width: `${Math.min((mb.storageUsedMb / (mb.quotaMb || 1)) * 100, 100)}%` }}
|
||||
<td>
|
||||
<Badge variant="neutral" className="whitespace-nowrap">
|
||||
{mb.role}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex min-w-[150px] items-center gap-2.5">
|
||||
<span className="block h-1.5 flex-1 overflow-hidden rounded-full bg-dark-card">
|
||||
<span
|
||||
className="block h-full rounded-full bg-indigo-500"
|
||||
style={{
|
||||
width: `${Math.min((mb.storageUsedMb / (mb.quotaMb || 1)) * 100, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
{mb.storageUsedMb}MB / {mb.quotaMb}MB
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-[11.5px] tabular-nums text-slate-400">
|
||||
{mb.storageUsedMb} / {mb.quotaMb} MB
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3.5">
|
||||
<Badge variant={mb.mfaEnabled ? "success" : "warning"}>
|
||||
{mb.mfaEnabled ? "TOTP ATIVO" : "SEM 2FA"}
|
||||
<td>
|
||||
<Badge variant={mb.mfaEnabled ? "success" : "warning"} className="whitespace-nowrap">
|
||||
{mb.mfaEnabled ? t("ui.mfaActive") : t("ui.mfaNone")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3.5">
|
||||
<Badge variant={mb.locked ? "danger" : "success"}>
|
||||
{mb.locked ? "BLOQUEADA" : t("ui.active")}
|
||||
<td>
|
||||
<Badge variant={mb.locked ? "danger" : "info"} className="whitespace-nowrap">
|
||||
{mb.locked ? t("ui.accountLocked") : t("ui.active")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3.5 flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<td className="whitespace-nowrap pr-0 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleLock(mb.id, mb.locked)}
|
||||
title={mb.locked ? t("admin.unlockAccount") : t("admin.lockAccount")}
|
||||
aria-label={mb.locked ? t("admin.unlockAccount") : t("admin.lockAccount")}
|
||||
className={`${iconButton} inline-flex`}
|
||||
>
|
||||
{mb.locked ? <Unlock className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
|
||||
<span>{mb.locked ? "Desbloquear" : "Bloquear"}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
{mb.locked ? <Unlock className="h-[15px] w-[15px]" /> : <Lock className="h-[15px] w-[15px]" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteMailbox(mb.id)}
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
className={`${iconButton} inline-flex hover:text-rose-400`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
<Trash2 className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: ALIASES */}
|
||||
{activeTab === "aliases" && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Plus className="w-4 h-4 text-emerald-400" />
|
||||
{t("admin.createAlias")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleCreateAlias} className="flex flex-col sm:flex-row gap-3 text-xs">
|
||||
<input
|
||||
type="email"
|
||||
value={newAliasAddr}
|
||||
onChange={(e) => setNewAliasAddr(e.target.value)}
|
||||
placeholder="alias@domain.com"
|
||||
required
|
||||
className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={newTargetAddr}
|
||||
onChange={(e) => setNewTargetAddr(e.target.value)}
|
||||
placeholder="destino@domain.com"
|
||||
required
|
||||
className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="md">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<section className={panel}>
|
||||
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("admin.createAlias")}</h2>
|
||||
<form onSubmit={handleCreateAlias} className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<label htmlFor="new-alias" className={fieldLabel}>
|
||||
{t("admin.aliasesTitle")}
|
||||
</label>
|
||||
<input
|
||||
id="new-alias"
|
||||
type="email"
|
||||
value={newAliasAddr}
|
||||
onChange={(e) => setNewAliasAddr(e.target.value)}
|
||||
placeholder="alias@domain.com"
|
||||
required
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
<ArrowRight className="mb-2.5 h-[18px] w-[18px] flex-none text-slate-500" />
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<label htmlFor="new-alias-target" className={fieldLabel}>
|
||||
{t("admin.aliasTarget")}
|
||||
</label>
|
||||
<input
|
||||
id="new-alias-target"
|
||||
type="email"
|
||||
value={newTargetAddr}
|
||||
onChange={(e) => setNewTargetAddr(e.target.value)}
|
||||
placeholder="destino@domain.com"
|
||||
required
|
||||
className={input}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||
{t("admin.addAlias")}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-800 bg-slate-900/80 font-bold text-xs text-slate-300">
|
||||
Aliases de Roteamento ({aliases.length})
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<table className="lm-table">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||
<th className="p-3.5">{t("ui.emailAddress")}</th>
|
||||
<th className="p-3.5">{t("ui.destination")}</th>
|
||||
<th className="p-3.5">{t("admin.domainsTitle")}</th>
|
||||
<th className="p-3.5">{t("ui.actions")}</th>
|
||||
<tr>
|
||||
<th className="pl-0">{t("admin.aliasesTitle")}</th>
|
||||
<th>{t("ui.destination")}</th>
|
||||
<th>{t("admin.domainsTitle")}</th>
|
||||
<th className="pr-0 text-right">{t("ui.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||
<tbody>
|
||||
{aliases.map((al) => (
|
||||
<tr key={al.id} className="hover:bg-slate-900/40 transition-colors">
|
||||
<td className="p-3.5 font-bold text-emerald-400">{al.aliasAddress}</td>
|
||||
<td className="p-3.5 text-slate-200 flex items-center gap-2">
|
||||
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span>{al.targetAddress}</span>
|
||||
</td>
|
||||
<td className="p-3.5 text-slate-400">{al.domain}</td>
|
||||
<td className="p-3.5">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
<tr key={al.id}>
|
||||
<td className="break-words pl-0 text-[13.5px] text-slate-100">{al.aliasAddress}</td>
|
||||
<td className="break-words text-[13px] text-slate-300">{al.targetAddress}</td>
|
||||
<td className="text-[13px] text-slate-400">{al.domain}</td>
|
||||
<td className="pr-0 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteAlias(al.id)}
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
className={`${iconButton} inline-flex hover:text-rose-400`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
<Trash2 className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: CSV BULK IMPORT */}
|
||||
{activeTab === "csv" && (
|
||||
<Card className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2 mb-1">
|
||||
<Upload className="w-5 h-5 text-indigo-400" />
|
||||
Bulk mailbox import (CSV)
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Paste CSV to provision several accounts with their RFC 6154 folders. Format: <code>email, role, quota_mb</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{csvSuccessMessage && (
|
||||
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>{csvSuccessMessage}</span>
|
||||
<div className="flex flex-wrap gap-3.5">
|
||||
<section className={`${panel} min-w-[320px] flex-1`}>
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<Upload className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.csvTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.csvIntro")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
rows={6}
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
placeholder={`email,role,quota_mb\nalice@example.com,USER,5000\nbob@example.com,DOMAIN_ADMIN,2000`}
|
||||
className="w-full px-4 py-3 rounded-xl bg-slate-900/90 border border-slate-800 font-mono text-xs text-slate-200 placeholder-slate-600 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" size="md" onClick={handleParseCsv}>
|
||||
{t("admin.csvPreview")}
|
||||
</Button>
|
||||
|
||||
{csvPreview.length > 0 && (
|
||||
<Button variant="primary" size="md" onClick={handleExecuteCsvImport}>
|
||||
Run import ({csvPreview.length} Contas)
|
||||
</Button>
|
||||
{csvSuccessMessage && (
|
||||
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-3.5 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||
<span>{csvSuccessMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={8}
|
||||
value={csvText}
|
||||
onChange={(e) => setCsvText(e.target.value)}
|
||||
aria-label={t("admin.csvTitle")}
|
||||
placeholder={`email,role,quota_mb\nalice@example.com,USER,5000\nbob@example.com,DOMAIN_ADMIN,2000`}
|
||||
className="lm-code w-full px-4 py-3 text-slate-200 placeholder-slate-600 focus:outline-none focus-visible:shadow-edge-accent"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="md" onClick={handleParseCsv}>
|
||||
{t("admin.csvPreview")}
|
||||
</Button>
|
||||
{csvPreview.length > 0 && (
|
||||
<Button variant="primary" size="md" onClick={handleExecuteCsvImport}>
|
||||
{t("admin.csvRun")} ({csvPreview.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{csvPreview.length > 0 && (
|
||||
<div className="mt-4 border border-slate-800 rounded-xl overflow-hidden bg-slate-950 p-4 space-y-2">
|
||||
<h3 className="font-bold text-xs text-white">Preview ({csvPreview.length} linhas)</h3>
|
||||
<div className="divide-y divide-slate-800/80 font-mono text-[11px]">
|
||||
<section className={`${panel} min-w-[300px] flex-1`}>
|
||||
<div className="flex items-center justify-between gap-2.5">
|
||||
<h3 className="text-[17px] font-medium leading-tight text-slate-100">
|
||||
{t("admin.csvPreview")}
|
||||
</h3>
|
||||
<Badge variant="neutral">{csvPreview.length}</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{csvPreview.map((row, idx) => (
|
||||
<div key={idx} className="py-2 flex justify-between items-center">
|
||||
<span className="text-emerald-400">{row.email}</span>
|
||||
<Badge variant="neutral">{row.role}</Badge>
|
||||
<span className="text-slate-400">Cota: {row.quotaMb} MB</span>
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-wrap items-center gap-2.5 rounded-xl bg-dark-card px-3 py-2.5 shadow-edge"
|
||||
>
|
||||
<span className="min-w-0 flex-1 break-words text-[13px] text-slate-100">{row.email}</span>
|
||||
<Badge variant="neutral" className="flex-none whitespace-nowrap">
|
||||
{row.role}
|
||||
</Badge>
|
||||
<span className="flex-none whitespace-nowrap text-[11.5px] tabular-nums text-slate-400">
|
||||
{row.quotaMb} MB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
|
||||
/**
|
||||
* The outbound queue, read from the database.
|
||||
@@ -33,11 +35,17 @@ interface QueueSummary {
|
||||
recent: QueueJob[];
|
||||
}
|
||||
|
||||
/** Only failure states earn a warning colour; the rest are ordinary progress. */
|
||||
function statusClass(status: string): string {
|
||||
if (status === "BOUNCED" || status === "FROZEN") return "badge-danger";
|
||||
if (status === "DEFERRED") return "badge-warning";
|
||||
return "badge-verified";
|
||||
/**
|
||||
* Only failure states earn a warning colour; the rest are ordinary progress.
|
||||
*
|
||||
* These used to return "badge-danger", "badge-warning" and "badge-verified" -
|
||||
* three class names no stylesheet in this project ever defined, so every status
|
||||
* rendered as unstyled text. They are Badge variants now, which exist.
|
||||
*/
|
||||
function statusVariant(status: string): "danger" | "warning" | "neutral" {
|
||||
if (status === "BOUNCED" || status === "FROZEN") return "danger";
|
||||
if (status === "DEFERRED") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export default function AdminQueuePage() {
|
||||
@@ -92,21 +100,16 @@ export default function AdminQueuePage() {
|
||||
const jobs = summary?.recent ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6 max-w-7xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
|
||||
<p className="text-xs text-slate-400">{t("admin.queueSubtitle")}</p>
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.queueTitle")}</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.queueSubtitle")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 self-start rounded-xl border border-slate-700 px-3.5 py-2 text-xs font-medium text-slate-300 transition-colors hover:border-emerald-500/50 hover:text-white disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
<Button variant="secondary" size="sm" onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
<span>{t("common.refresh")}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Counts per status, straight from the by_status aggregate. */}
|
||||
@@ -115,80 +118,67 @@ export default function AdminQueuePage() {
|
||||
{Object.entries(summary.by_status).map(([status, count]) => (
|
||||
<span
|
||||
key={status}
|
||||
className="rounded-lg border border-slate-800 bg-slate-900/70 px-3 py-1.5 text-xs text-slate-300"
|
||||
className="flex items-center gap-2.5 rounded-xl bg-dark-panel px-3.5 py-2.5 shadow-edge"
|
||||
>
|
||||
<span className="font-mono font-bold text-white">{count}</span> {status}
|
||||
<span className="text-[19px] font-medium leading-none tabular-nums text-slate-100">{count}</span>
|
||||
<span className="text-xs text-slate-400">{status}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs">
|
||||
{message}
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-card px-4 py-3 text-[12.5px] text-slate-200 shadow-edge">{message}</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="p-4 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs">
|
||||
{error}
|
||||
</div>
|
||||
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="glass-panel rounded-2xl border border-slate-800 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-800 bg-slate-900/60 font-bold text-xs text-slate-300">
|
||||
{t("admin.queueTitle")} ({jobs.length})
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
{jobs.length === 0 && !loading ? (
|
||||
<div className="p-8 text-center text-xs text-slate-500">{t("admin.queueEmpty")}</div>
|
||||
<div className="p-8 text-center text-[13px] text-slate-400">{t("admin.queueEmpty")}</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<table className="lm-table">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||
<th className="p-3">{t("ui.jobId")}</th>
|
||||
<th className="p-3">{t("ui.sender")}</th>
|
||||
<th className="p-3">{t("ui.recipient")}</th>
|
||||
<th className="p-3">{t("ui.destination")}</th>
|
||||
<th className="p-3">{t("ui.attempts")}</th>
|
||||
<th className="p-3">{t("ui.lastError")}</th>
|
||||
<th className="p-3">{t("common.status")}</th>
|
||||
<th className="p-3">{t("ui.actions")}</th>
|
||||
<tr>
|
||||
<th className="pl-0">{t("ui.jobId")}</th>
|
||||
<th>{t("ui.sender")}</th>
|
||||
<th>{t("ui.recipient")}</th>
|
||||
<th>{t("ui.destination")}</th>
|
||||
<th>{t("ui.attempts")}</th>
|
||||
<th>{t("ui.lastError")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th className="pr-0 text-right">{t("ui.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.id} className="hover:bg-slate-900/30">
|
||||
<td className="p-3 font-bold text-slate-200">{j.id.slice(0, 8)}</td>
|
||||
<td className="p-3 text-slate-300">{j.envelope_from}</td>
|
||||
<td className="p-3 text-slate-300">{j.envelope_to}</td>
|
||||
<td className="p-3 text-emerald-400">{j.destination_domain}</td>
|
||||
<td className="p-3 text-slate-400">{j.attempt}</td>
|
||||
<td className="p-3 text-slate-400 truncate max-w-xs" title={j.last_error ?? ""}>
|
||||
<tr key={j.id}>
|
||||
<td className="pl-0 font-mono text-[12.5px] text-slate-100">{j.id.slice(0, 8)}</td>
|
||||
<td className="max-w-[180px] break-words text-[12.5px] text-slate-300">{j.envelope_from}</td>
|
||||
<td className="max-w-[180px] break-words text-[12.5px] text-slate-300">{j.envelope_to}</td>
|
||||
<td className="break-words text-[12.5px] text-slate-300">{j.destination_domain}</td>
|
||||
<td className="whitespace-nowrap text-[12.5px] tabular-nums text-slate-300">{j.attempt}</td>
|
||||
{/* The diagnostic wraps instead of being truncated: a queue
|
||||
screen exists to show why a message did not leave. */}
|
||||
<td className="max-w-[250px] text-xs leading-relaxed text-slate-400">
|
||||
{j.last_error
|
||||
? `${j.last_smtp_code ? `${j.last_smtp_code} ` : ""}${j.last_error}`
|
||||
: t("common.none")}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className={`${statusClass(j.status)} px-2 py-0.5 rounded text-[10px]`}>
|
||||
<td>
|
||||
<Badge variant={statusVariant(j.status)} className="whitespace-nowrap">
|
||||
{j.status}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void act(j.id, "retry")}
|
||||
className="px-2 py-1 rounded bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 text-[10px] font-bold"
|
||||
>
|
||||
<td className="whitespace-nowrap pr-0 text-right">
|
||||
<Button variant="secondary" size="sm" onClick={() => void act(j.id, "retry")}>
|
||||
{t("admin.retryJob")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void act(j.id, "cancel")}
|
||||
className="px-2 py-1 rounded bg-red-600/20 hover:bg-red-600/30 text-red-300 text-[10px] font-bold"
|
||||
>
|
||||
</Button>{" "}
|
||||
<Button variant="ghost" size="sm" onClick={() => void act(j.id, "cancel")}>
|
||||
{t("admin.cancelJob")}
|
||||
</button>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
|
||||
import { Badge } from "../../../../components/ui/Badge";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
|
||||
@@ -172,123 +171,97 @@ export default function AdminSecurityPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const securityTabs = [
|
||||
{ id: "readiness" as const, label: t("admin.tabReadiness"), icon: ShieldCheck },
|
||||
{ id: "tls" as const, label: t("admin.tabTls"), icon: Lock },
|
||||
{ id: "rspamd" as const, label: t("admin.tabRspamd"), icon: Sliders },
|
||||
{ id: "audit" as const, label: t("admin.tabAudit"), icon: ListOrdered },
|
||||
];
|
||||
|
||||
const panel = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
|
||||
const input =
|
||||
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
|
||||
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-8 max-w-7xl mx-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||
{/* Header & Tabs */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight flex items-center gap-3">
|
||||
{t("admin.securityTitle")}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">{t("admin.securitySubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Selector Controls */}
|
||||
<div className="flex bg-slate-900/90 p-1.5 rounded-xl border border-slate-800 text-xs font-medium self-start md:self-auto overflow-x-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab("readiness")}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
activeTab === "readiness"
|
||||
? "bg-emerald-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<ShieldCheck className="w-3.5 h-3.5" />
|
||||
<span>{t("admin.tabReadiness")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("tls")}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
activeTab === "tls"
|
||||
? "bg-emerald-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span>{t("admin.tabTls")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("rspamd")}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
activeTab === "rspamd"
|
||||
? "bg-emerald-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<Sliders className="w-3.5 h-3.5" />
|
||||
<span>{t("admin.tabRspamd")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab("audit")}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||
activeTab === "audit"
|
||||
? "bg-emerald-600 text-white shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<ListOrdered className="w-3.5 h-3.5" />
|
||||
<span>{t("admin.tabAudit")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.securityTitle")}</h1>
|
||||
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.securitySubtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* TAB 1: 10/10 READINESS SCORECARD */}
|
||||
{/* Tab Selector Controls */}
|
||||
<div className="lm-tabstrip self-start">
|
||||
{securityTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
data-active={activeTab === tab.id}
|
||||
aria-pressed={activeTab === tab.id}
|
||||
className="lm-tab"
|
||||
>
|
||||
<Icon className="h-[15px] w-[15px] flex-none" />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* TAB 1: READINESS SCORECARD */}
|
||||
{activeTab === "readiness" && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
{/* The score is counted from the checks that came back. It used to
|
||||
read a hardcoded "10 / 10" with an "all checks passed" badge
|
||||
regardless of what the preflight endpoint reported - including
|
||||
when it reported failures, or nothing at all. */}
|
||||
<Card className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-emerald-400" />
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge">
|
||||
<div className="min-w-[260px] flex-1">
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<Shield className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.preflightTitle")}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t("admin.readinessIntro")}</p>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.readinessIntro")}</p>
|
||||
</div>
|
||||
{readiness && readiness.length > 0 ? (
|
||||
(() => {
|
||||
const passed = readiness.filter((c) => c.status === "PASSED").length;
|
||||
const total = readiness.length;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`text-3xl font-black font-mono ${
|
||||
passed === total ? "text-emerald-400" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-none items-center gap-3">
|
||||
<span className="font-mono text-[30px] font-medium leading-none tabular-nums text-slate-100">
|
||||
{passed} / {total}
|
||||
</span>
|
||||
<Badge variant={passed === total ? "success" : "warning"}>
|
||||
{passed === total
|
||||
? t("ui.allChecksPassed")
|
||||
: t("admin.checksPassed", { passed, total })}
|
||||
{passed === total ? t("ui.allChecksPassed") : t("admin.checksPassed", { passed, total })}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">{t("admin.noChecks")}</span>
|
||||
<span className="text-[13px] text-slate-400">{t("admin.noChecks")}</span>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
{(readiness ?? []).map((chk) => (
|
||||
<Card key={chk.id} hoverable className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-bold text-slate-100">
|
||||
<div key={chk.id} className="flex flex-col gap-2 rounded-2xl bg-dark-panel p-4 shadow-edge">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="min-w-0 text-[13.5px] text-slate-100">
|
||||
#{chk.id}: {chk.name}
|
||||
</span>
|
||||
<Badge variant={chk.status === "PASSED" ? "success" : chk.status === "FAILED" ? "danger" : "warning"}>
|
||||
<Badge
|
||||
variant={chk.status === "PASSED" ? "success" : chk.status === "FAILED" ? "danger" : "warning"}
|
||||
className="flex-none"
|
||||
>
|
||||
{chk.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 leading-relaxed font-mono">{chk.details}</div>
|
||||
</Card>
|
||||
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">{chk.details}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,204 +269,223 @@ export default function AdminSecurityPage() {
|
||||
|
||||
{/* TAB 2: TLS - read from the protocols service cert watcher */}
|
||||
{activeTab === "tls" && (
|
||||
<div className="space-y-6 text-xs">
|
||||
<Card className="space-y-4">
|
||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-indigo-400" />
|
||||
{t("admin.tlsTitle")}
|
||||
</h2>
|
||||
<section className={panel}>
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<Lock className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.tlsTitle")}
|
||||
</h2>
|
||||
|
||||
{tlsStatus ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
||||
<div className="text-slate-400 font-medium">{t("admin.certificateState")}</div>
|
||||
<div
|
||||
className={`font-bold text-sm ${
|
||||
tlsStatus.state === "OK" ? "text-emerald-400" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{String(tlsStatus.state ?? "UNKNOWN")}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">
|
||||
{typeof tlsStatus.expires_in_days === "number"
|
||||
? t("admin.expiresInDays", { days: tlsStatus.expires_in_days })
|
||||
: t("admin.noCertificate")}
|
||||
</div>
|
||||
{tlsStatus ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
<div className="text-xs text-slate-400">{t("admin.certificateState")}</div>
|
||||
<div
|
||||
className={`text-[15px] font-medium ${
|
||||
tlsStatus.state === "OK" ? "text-indigo-400" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{String(tlsStatus.state ?? "UNKNOWN")}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
||||
<div className="text-slate-400 font-medium">{t("admin.mailHost")}</div>
|
||||
<div className="text-slate-100 font-bold text-sm break-all">
|
||||
{String(tlsStatus.mail_host ?? "-")}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">
|
||||
{t("admin.tlsMode")}: {String(tlsStatus.tls_mode ?? "-")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-900/90 rounded-xl border border-slate-800 space-y-1">
|
||||
<div className="text-slate-400 font-medium">{t("admin.watcherHealthy")}</div>
|
||||
<div
|
||||
className={`font-bold text-sm ${
|
||||
tlsStatus.watcher_healthy ? "text-emerald-400" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{tlsStatus.watcher_healthy ? t("ui.healthy") : t("common.unavailable")}
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-mono">
|
||||
{t("admin.lastReload")}:{" "}
|
||||
{tlsStatus.last_reload
|
||||
? new Date(String(tlsStatus.last_reload)).toLocaleString()
|
||||
: t("common.never")}
|
||||
</div>
|
||||
<div className="text-[11.5px] text-slate-500">
|
||||
{typeof tlsStatus.expires_in_days === "number"
|
||||
? t("admin.expiresInDays", { days: tlsStatus.expires_in_days })
|
||||
: t("admin.noCertificate")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center text-slate-500">{t("errors.loadFailed")}</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
<div className="text-xs text-slate-400">{t("admin.mailHost")}</div>
|
||||
<div className="break-all text-[15px] font-medium text-slate-100">
|
||||
{String(tlsStatus.mail_host ?? "-")}
|
||||
</div>
|
||||
<div className="text-[11.5px] text-slate-500">
|
||||
{t("admin.tlsMode")}: {String(tlsStatus.tls_mode ?? "-")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
<div className="text-xs text-slate-400">{t("admin.watcherHealthy")}</div>
|
||||
<div
|
||||
className={`text-[15px] font-medium ${
|
||||
tlsStatus.watcher_healthy ? "text-indigo-400" : "text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{tlsStatus.watcher_healthy ? t("ui.healthy") : t("common.unavailable")}
|
||||
</div>
|
||||
<div className="text-[11.5px] text-slate-500">
|
||||
{t("admin.lastReload")}:{" "}
|
||||
{tlsStatus.last_reload
|
||||
? new Date(String(tlsStatus.last_reload)).toLocaleString()
|
||||
: t("common.never")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 text-center text-[13px] text-slate-400">{t("errors.loadFailed")}</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* TAB 3: RSPAMD ANTI-SPAM THRESHOLDS */}
|
||||
{activeTab === "rspamd" && (
|
||||
<Card className="space-y-6 text-xs max-w-3xl">
|
||||
<section className={`${panel} max-w-[620px]`}>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
|
||||
<Sliders className="w-5 h-5 text-indigo-400" />
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<Sliders className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.tabRspamd")}
|
||||
</h2>
|
||||
<p className="text-slate-400">{t("admin.rspamdIntro")}</p>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.rspamdIntro")}</p>
|
||||
</div>
|
||||
|
||||
{rspamdSaved && (
|
||||
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<div className="flex items-center gap-2 rounded-xl bg-dark-card px-3.5 py-3 text-[12.5px] text-slate-200 shadow-edge">
|
||||
<CheckCircle2 className="h-4 w-4 flex-none text-indigo-500" />
|
||||
<span>{t("admin.rspamdSaved")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveRspamd} className="space-y-4">
|
||||
<form onSubmit={handleSaveRspamd} className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.greylistScore")}</label>
|
||||
<label htmlFor="greylist-score" className={fieldLabel}>
|
||||
{t("admin.greylistScore")}
|
||||
</label>
|
||||
<input
|
||||
id="greylist-score"
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={greylistScore}
|
||||
onChange={(e) => setGreylistScore(parseFloat(e.target.value))}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500"
|
||||
className={`${input} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.headerScore")}</label>
|
||||
<label htmlFor="header-score" className={fieldLabel}>
|
||||
{t("admin.headerScore")}
|
||||
</label>
|
||||
<input
|
||||
id="header-score"
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={addHeaderScore}
|
||||
onChange={(e) => setAddHeaderScore(parseFloat(e.target.value))}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500"
|
||||
className={`${input} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-semibold text-slate-300 mb-1.5 block">{t("admin.rejectScore")}</label>
|
||||
<label htmlFor="reject-score" className={fieldLabel}>
|
||||
{t("admin.rejectScore")}
|
||||
</label>
|
||||
<input
|
||||
id="reject-score"
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={rejectScore}
|
||||
onChange={(e) => setRejectScore(parseFloat(e.target.value))}
|
||||
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono focus:outline-none focus:border-emerald-500"
|
||||
className={`${input} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" size="md">
|
||||
<Button type="submit" variant="primary" size="md" className="self-start">
|
||||
{t("admin.saveThresholds")}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* TAB 4: AUDIT LOG & QUEUE ID TRANSACTION TRACE */}
|
||||
{activeTab === "audit" && (
|
||||
<div className="space-y-6 text-xs">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
{/* Queue ID Search Box */}
|
||||
<Card className="space-y-4">
|
||||
<section className={panel}>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-white mb-1 flex items-center gap-2">
|
||||
<Search className="w-5 h-5 text-indigo-400" />
|
||||
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
|
||||
<Search className="h-[17px] w-[17px] flex-none text-indigo-500" />
|
||||
{t("admin.traceTitle")}
|
||||
</h2>
|
||||
<p className="text-slate-400">{t("admin.tracePlaceholder")}</p>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-slate-400">{t("admin.tracePlaceholder")}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearchQueueId} className="flex gap-3">
|
||||
<form onSubmit={handleSearchQueueId} className="flex flex-wrap gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQueueId}
|
||||
onChange={(e) => setSearchQueueId(e.target.value)}
|
||||
placeholder={t("admin.tracePlaceholder")}
|
||||
aria-label={t("admin.traceTitle")}
|
||||
required
|
||||
className="flex-1 px-3.5 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white font-mono placeholder-slate-500 focus:outline-none focus:border-emerald-500"
|
||||
className={`${input} min-w-[240px] flex-1 font-mono`}
|
||||
/>
|
||||
<Button type="submit" variant="primary" size="md">
|
||||
<Button type="submit" variant="primary" size="md" className="flex-none">
|
||||
{t("admin.traceTitle")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{tracedSteps && (
|
||||
<div className="mt-4 border border-slate-800 rounded-xl overflow-hidden bg-slate-950 p-4 space-y-3">
|
||||
<h3 className="font-bold text-white">Resultado do Rastreamento para {searchQueueId}</h3>
|
||||
<div className="space-y-2">
|
||||
{tracedSteps.map((st) => (
|
||||
<div key={st.step} className="p-3 rounded-lg bg-slate-900/90 border border-slate-800 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-bold text-slate-200">Etapa {st.step}: {st.title}</div>
|
||||
<div className="text-[11px] text-slate-400 font-mono mt-0.5">{st.detail}</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* These two lines were literal Portuguese, so an interface
|
||||
running in English still said "Resultado do Rastreamento"
|
||||
and "Etapa". */}
|
||||
<h3 className="break-all text-[13.5px] text-slate-100">
|
||||
{t("admin.traceResultFor", { id: searchQueueId })}
|
||||
</h3>
|
||||
{tracedSteps.map((st) => (
|
||||
<div
|
||||
key={st.step}
|
||||
className="flex flex-wrap items-center gap-3 rounded-xl bg-dark-card px-3.5 py-3 shadow-edge"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="break-words text-[13.5px] text-slate-100">
|
||||
{t("admin.traceStep", { n: st.step })}: {st.title}
|
||||
</div>
|
||||
<Badge variant="success">{st.status}</Badge>
|
||||
<div className="mt-0.5 break-words text-[11.5px] text-slate-400">{st.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Badge variant="info" className="flex-none">
|
||||
{st.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Immutable System Audit Log Table */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-800 bg-slate-900/80 font-bold text-xs text-slate-300">
|
||||
{t("admin.auditLog")}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<table className="lm-table">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 bg-slate-900/40 text-slate-400">
|
||||
<th className="p-3.5">{t("ui.timestamp")}</th>
|
||||
<th className="p-3.5">Queue ID</th>
|
||||
<th className="p-3.5">{t("ui.actor")}</th>
|
||||
<th className="p-3.5">{t("ui.eventAction")}</th>
|
||||
<th className="p-3.5">{t("ui.targetObject")}</th>
|
||||
<th className="p-3.5">{t("ui.clientIp")}</th>
|
||||
<tr>
|
||||
<th className="pl-0">{t("ui.timestamp")}</th>
|
||||
<th>{t("ui.jobId")}</th>
|
||||
<th>{t("ui.actor")}</th>
|
||||
<th>{t("ui.eventAction")}</th>
|
||||
<th>{t("ui.targetObject")}</th>
|
||||
<th className="pr-0 text-right">{t("ui.clientIp")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 font-mono">
|
||||
<tbody>
|
||||
{auditEntries.map((aud) => (
|
||||
<tr key={aud.id} className="hover:bg-slate-900/40 transition-colors">
|
||||
<td className="p-3.5 text-slate-400">{aud.timestamp}</td>
|
||||
<td className="p-3.5 text-indigo-400 font-bold">{aud.queueId}</td>
|
||||
<td className="p-3.5 font-bold text-slate-200">{aud.actor}</td>
|
||||
<td className="p-3.5 font-bold">
|
||||
<Badge variant="success">{aud.action}</Badge>
|
||||
<tr key={aud.id}>
|
||||
<td className="whitespace-nowrap pl-0 text-[12.5px] text-slate-400">{aud.timestamp}</td>
|
||||
<td className="font-mono text-[12.5px] text-slate-200">{aud.queueId}</td>
|
||||
<td className="break-words text-[13px] text-slate-100">{aud.actor}</td>
|
||||
<td>
|
||||
<Badge variant="info" className="whitespace-nowrap">
|
||||
{aud.action}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3.5 text-slate-300">{aud.target}</td>
|
||||
<td className="p-3.5 text-slate-500">{aud.ip}</td>
|
||||
<td className="break-words text-[13px] text-slate-300">{aud.target}</td>
|
||||
<td className="pr-0 text-right font-mono text-[12.5px] text-slate-500">{aud.ip}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
{auditEntries.length === 0 && (
|
||||
<div className="p-8 text-center text-[13px] text-slate-400">{t("ui.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user