feat(admin): paginacao na interface, edicao de utilizadores e pagina de testes

- fila e utilizadores passam a pedir uma pagina de cada vez, com pesquisa e
  filtros; nada e cortado no browser, porque o que faltava nunca chegou a ser
  buscado. A pesquisa espera que se pare de escrever: cada tecla seria mais
  uma consulta, e as respostas podem chegar fora de ordem
- pageWindow e pageRange tem testes proprios: uma pagina fora do intervalo vem
  de um URL velho ou de uma lista que encolheu, e nao pode dar um paginador
  vazio ou negativo, nem dizer "76 a 100 de 82"
- editar utilizador: papel, quota, idioma, com os apelidos que entregam
  naquela caixa a mostra no mesmo painel
- "Caixas de e-mail" passa a chamar-se Utilizadores, que e o que sao

Pagina nova /admin/tests: verificacoes que o operador corre a mao, sobre o
servidor ou sobre um utilizador. Todas leem estado; nenhuma envia correio nem
altera definicoes -- uma pagina de diagnostico que mexe naquilo que
diagnostica e pior do que nao existir.

As regras de cada verificacao sao funcoes puras com testes, separadas da
recolha dos factos. Alguns casos que estes testes fixam: uma quota zero
significa sem limite, e um racio marcaria todas as caixas ilimitadas como
cheias; um bloqueio ja expirado nao bloqueia ninguem e nao deve mandar
ninguem investigar; e uma resposta de ausencia ligada com o periodo ja
passado esta ligada sem fazer nada, que e o que parece avariado a quem a pos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-24 09:36:35 -03:00
1 parent 52bf43b74f
commit 2b92abd185
16 files changed
+1431 -153

No files matched your search

+51 -3
View File
@@ -44,7 +44,7 @@
"mailHost": "Mail host",
"mailboxCount": "{count} mailboxes",
"mailboxesSubtitle": "Accounts, aliases, CSV import and the second-factor policy.",
"mailboxesTitle": "Mailboxes",
"mailboxesTitle": "Users",
"mfaAdmins": "Required for admins",
"mfaEveryone": "Required for everyone",
"mfaOptional": "Optional",
@@ -96,7 +96,15 @@
"reconcileErrors": "The provider refused these:",
"selectDomain": "Select",
"domainSelected": "Selected",
"tlsSelfSigned": "This certificate is self-signed (issuer: {issuer}). Clients that verify certificates will refuse the connection on the mail ports. Set TRAEFIK_ACME_DIR or STATIC_CERT_PATH to serve an issued certificate."
"tlsSelfSigned": "This certificate is self-signed (issuer: {issuer}). Clients that verify certificates will refuse the connection on the mail ports. Set TRAEFIK_ACME_DIR or STATIC_CERT_PATH to serve an issued certificate.",
"aliasesForUser": "Aliases delivering to this mailbox",
"noAliasesForUser": "No alias delivers to this mailbox.",
"testsTitle": "Tests",
"testsSubtitle": "Checks you can run at any time. They all read state; none sends mail or changes a setting.",
"serverChecks": "Server",
"userChecks": "User",
"runChecks": "Run",
"checksRun": "{count} checks"
},
"auth": {
"adminLoginTitle": "Admin console",
@@ -358,12 +366,52 @@
"italic": "Italic",
"underline": "Underline",
"insertLink": "Link",
"insertImage": "Image"
"insertImage": "Image",
"showingRange": "Showing {from} to {to} of {total}",
"noResults": "No results",
"previousPage": "Previous page",
"nextPage": "Next page",
"searchPlaceholder": "Search...",
"all": "All",
"filters": "Filters",
"editUser": "Edit user",
"quotaMb": "Quota (MB)",
"selectUser": "Select a user"
},
"folders": {
"new": "New folder",
"newPlaceholder": "New folder…",
"delete": "Delete {name}",
"deleteConfirm": "Delete the folder \"{name}\" and every message in it? This cannot be undone from the interface."
},
"checks": {
"userActive": "The account is enabled",
"userDisabled": "The account is disabled",
"userLockedOut": "Locked out after failed sign-ins",
"userNotLockedOut": "No active lockout",
"userHasPassword": "Has a password set",
"userHasNoPassword": "Has no password set",
"quotaFull": "The quota is full",
"quotaNearlyFull": "The quota is nearly full",
"quotaHealthy": "The quota has headroom",
"quotaUnlimited": "No quota limit",
"mfaEnrolled": "Second factor enrolled",
"mfaNotEnrolled": "No second factor",
"aliasesDelivering": "Aliases delivering to this mailbox",
"sieveScriptPresent": "Has filing rules",
"vacationActive": "Autoresponder is on",
"vacationWindowPast": "Autoresponder is on, but its window has passed",
"vacationWindowFuture": "Autoresponder is on, its window has not started",
"dnsVerified": "DNS records verified",
"dnsNotVerified": "DNS records not verified",
"noDomains": "No domains configured",
"dkimMissing": "No DKIM key: the domain signs nothing",
"queueHasBounces": "Bounced messages in the queue",
"queueNoBounces": "No bounces in the queue",
"queueHasDeferred": "Deferred messages in the queue",
"queueNoDeferred": "No deferred messages",
"queueHasFrozen": "Frozen messages in the queue",
"usersOverQuota": "Users over their quota",
"noUsersOverQuota": "No user is over quota"
}
}
+51 -3
View File
@@ -44,7 +44,7 @@
"mailHost": "Host de correo",
"mailboxCount": "{count} buzones",
"mailboxesSubtitle": "Cuentas, alias, importación CSV y la política de segundo factor.",
"mailboxesTitle": "Buzones",
"mailboxesTitle": "Usuarios",
"mfaAdmins": "Obligatorio para administradores",
"mfaEveryone": "Obligatorio para todos",
"mfaOptional": "Opcional",
@@ -96,7 +96,15 @@
"reconcileErrors": "El proveedor rechazó estos:",
"selectDomain": "Seleccionar",
"domainSelected": "Seleccionado",
"tlsSelfSigned": "Este certificado es autofirmado (emisor: {issuer}). Los clientes que verifican el certificado rechazaran la conexion en los puertos de correo. Defina TRAEFIK_ACME_DIR o STATIC_CERT_PATH para servir un certificado emitido."
"tlsSelfSigned": "Este certificado es autofirmado (emisor: {issuer}). Los clientes que verifican el certificado rechazaran la conexion en los puertos de correo. Defina TRAEFIK_ACME_DIR o STATIC_CERT_PATH para servir un certificado emitido.",
"aliasesForUser": "Alias que entregan en este buzon",
"noAliasesForUser": "Ningun alias entrega en este buzon.",
"testsTitle": "Pruebas",
"testsSubtitle": "Comprobaciones que puede ejecutar en cualquier momento. Todas leen el estado; ninguna envia correo ni cambia ajustes.",
"serverChecks": "Servidor",
"userChecks": "Usuario",
"runChecks": "Ejecutar",
"checksRun": "{count} comprobaciones"
},
"auth": {
"adminLoginTitle": "Consola de administración",
@@ -358,12 +366,52 @@
"italic": "Cursiva",
"underline": "Subrayado",
"insertLink": "Enlace",
"insertImage": "Imagen"
"insertImage": "Imagen",
"showingRange": "Mostrando {from} a {to} de {total}",
"noResults": "Sin resultados",
"previousPage": "Pagina anterior",
"nextPage": "Pagina siguiente",
"searchPlaceholder": "Buscar...",
"all": "Todos",
"filters": "Filtros",
"editUser": "Editar usuario",
"quotaMb": "Cuota (MB)",
"selectUser": "Elegir usuario"
},
"folders": {
"new": "Nueva carpeta",
"newPlaceholder": "Nueva carpeta…",
"delete": "Eliminar {name}",
"deleteConfirm": "¿Eliminar la carpeta \"{name}\" y todos sus mensajes? Esto no se puede deshacer desde la interfaz."
},
"checks": {
"userActive": "La cuenta esta activa",
"userDisabled": "La cuenta esta desactivada",
"userLockedOut": "Bloqueada por intentos fallidos",
"userNotLockedOut": "Sin bloqueo activo",
"userHasPassword": "Tiene contrasena definida",
"userHasNoPassword": "No tiene contrasena definida",
"quotaFull": "La cuota esta llena",
"quotaNearlyFull": "La cuota esta casi llena",
"quotaHealthy": "La cuota tiene margen",
"quotaUnlimited": "Sin limite de cuota",
"mfaEnrolled": "Segundo factor activo",
"mfaNotEnrolled": "Sin segundo factor",
"aliasesDelivering": "Alias que entregan en este buzon",
"sieveScriptPresent": "Tiene reglas de filtrado",
"vacationActive": "Respuesta de ausencia activada",
"vacationWindowPast": "Respuesta de ausencia activada, pero el periodo ya paso",
"vacationWindowFuture": "Respuesta de ausencia activada, el periodo aun no empezo",
"dnsVerified": "Registros DNS verificados",
"dnsNotVerified": "Registros DNS sin verificar",
"noDomains": "Ningun dominio configurado",
"dkimMissing": "Sin clave DKIM: el dominio no firma nada",
"queueHasBounces": "Hay mensajes devueltos en la cola",
"queueNoBounces": "Sin devoluciones en la cola",
"queueHasDeferred": "Hay mensajes aplazados en la cola",
"queueNoDeferred": "Sin mensajes aplazados",
"queueHasFrozen": "Hay mensajes congelados en la cola",
"usersOverQuota": "Usuarios con la cuota excedida",
"noUsersOverQuota": "Ningun usuario con la cuota excedida"
}
}
+51 -3
View File
@@ -44,7 +44,7 @@
"mailHost": "Host de e-mail",
"mailboxCount": "{count} caixas de e-mail",
"mailboxesSubtitle": "Contas, apelidos, importação CSV e a política de segundo fator.",
"mailboxesTitle": "Caixas de e-mail",
"mailboxesTitle": "Usuarios",
"mfaAdmins": "Obrigatório para administradores",
"mfaEveryone": "Obrigatório para todos",
"mfaOptional": "Opcional",
@@ -96,7 +96,15 @@
"reconcileErrors": "O provedor recusou estes:",
"selectDomain": "Selecionar",
"domainSelected": "Selecionado",
"tlsSelfSigned": "Este certificado e autoassinado (emissor: {issuer}). Clientes que verificam o certificado vao recusar a ligacao nas portas de correio. Defina TRAEFIK_ACME_DIR ou STATIC_CERT_PATH para servir um certificado emitido."
"tlsSelfSigned": "Este certificado e autoassinado (emissor: {issuer}). Clientes que verificam o certificado vao recusar a ligacao nas portas de correio. Defina TRAEFIK_ACME_DIR ou STATIC_CERT_PATH para servir um certificado emitido.",
"aliasesForUser": "Apelidos que entregam nesta caixa",
"noAliasesForUser": "Nenhum apelido entrega nesta caixa.",
"testsTitle": "Testes",
"testsSubtitle": "Verificacoes que pode correr a qualquer momento. Todas leem o estado; nenhuma envia correio nem altera definicoes.",
"serverChecks": "Servidor",
"userChecks": "Utilizador",
"runChecks": "Correr",
"checksRun": "{count} verificacoes"
},
"auth": {
"adminLoginTitle": "Console de administração",
@@ -358,12 +366,52 @@
"italic": "Itálico",
"underline": "Sublinhado",
"insertLink": "Link",
"insertImage": "Imagem"
"insertImage": "Imagem",
"showingRange": "A mostrar {from} a {to} de {total}",
"noResults": "Nenhum resultado",
"previousPage": "Pagina anterior",
"nextPage": "Pagina seguinte",
"searchPlaceholder": "Pesquisar...",
"all": "Todos",
"filters": "Filtros",
"editUser": "Editar utilizador",
"quotaMb": "Quota (MB)",
"selectUser": "Escolher utilizador"
},
"folders": {
"new": "Nova pasta",
"newPlaceholder": "Nova pasta…",
"delete": "Apagar {name}",
"deleteConfirm": "Apagar a pasta \"{name}\" e todas as mensagens nela? Isto não pode ser desfeito pela interface."
},
"checks": {
"userActive": "A conta esta activa",
"userDisabled": "A conta esta desactivada",
"userLockedOut": "A conta esta bloqueada por tentativas falhadas",
"userNotLockedOut": "Sem bloqueio activo",
"userHasPassword": "Tem palavra-passe definida",
"userHasNoPassword": "Nao tem palavra-passe definida",
"quotaFull": "A quota esta cheia",
"quotaNearlyFull": "A quota esta quase cheia",
"quotaHealthy": "A quota tem folga",
"quotaUnlimited": "Sem limite de quota",
"mfaEnrolled": "Segundo factor activo",
"mfaNotEnrolled": "Sem segundo factor",
"aliasesDelivering": "Apelidos que entregam nesta caixa",
"sieveScriptPresent": "Tem regras de filtragem",
"vacationActive": "Resposta de ausencia ligada",
"vacationWindowPast": "Resposta de ausencia ligada, mas o periodo ja passou",
"vacationWindowFuture": "Resposta de ausencia ligada, o periodo ainda nao comecou",
"dnsVerified": "Registos DNS verificados",
"dnsNotVerified": "Registos DNS por verificar",
"noDomains": "Nenhum dominio configurado",
"dkimMissing": "Sem chave DKIM: o dominio nao assina nada",
"queueHasBounces": "Ha mensagens devolvidas na fila",
"queueNoBounces": "Sem devolucoes na fila",
"queueHasDeferred": "Ha mensagens adiadas na fila",
"queueNoDeferred": "Sem mensagens adiadas",
"queueHasFrozen": "Ha mensagens congeladas na fila",
"usersOverQuota": "Utilizadores com a quota excedida",
"noUsersOverQuota": "Nenhum utilizador com a quota excedida"
}
}
@@ -16,6 +16,7 @@ import {
X,
Lock,
Mail,
FlaskConical,
} from "lucide-react";
import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
@@ -35,6 +36,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ href: "/admin/queue", label: t("admin.queueTitle"), icon: ListOrdered },
{ href: "/admin/dmarc", label: t("admin.dmarcTitle"), icon: Activity },
{ href: "/admin/security", label: t("admin.securityTitle"), icon: ShieldCheck },
{ href: "/admin/tests", label: t("admin.testsTitle"), icon: FlaskConical },
];
const handleLogout = () => {
@@ -14,9 +14,14 @@ import {
Upload,
AlertCircle,
CheckCircle2,
Pencil,
Search,
X,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
import { Pagination } from "../../../../components/ui/Pagination";
import { useDebounced } from "../../../../lib/useDebounced";
import { initialsFor } from "../../../../lib/initials";
import { Button } from "../../../../components/ui/Button";
@@ -28,6 +33,7 @@ interface Mailbox {
quotaMb: number;
mfaEnabled: boolean;
locked: boolean;
locale: string;
}
interface Alias {
@@ -45,6 +51,16 @@ interface ApiMailbox {
quota_bytes: number;
mfa_enrolled: boolean;
is_active: boolean;
locale: string | null;
}
/** The envelope every server-paged list answers with. */
interface PagedUsers {
items: ApiMailbox[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
interface ApiAlias {
@@ -79,14 +95,45 @@ export default function AdminMailboxesPage() {
const [error, setError] = useState<string | null>(null);
// Paging and filtering are the server's job; nothing here slices a list that
// was never fully fetched in the first place.
const [page, setPage] = useState(1);
const [pageInfo, setPageInfo] = useState({ total: 0, page: 1, pageSize: 25, totalPages: 1 });
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState("");
const [activeFilter, setActiveFilter] = useState("");
const debouncedSearch = useDebounced(search, 300);
// The user being edited, and the aliases that deliver to them.
const [editing, setEditing] = useState<Mailbox | null>(null);
const [editRole, setEditRole] = useState("USER");
const [editQuotaMb, setEditQuotaMb] = useState(0);
const [editLocale, setEditLocale] = useState("");
const [editAliases, setEditAliases] = useState<Alias[] | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (debouncedSearch) params.set("search", debouncedSearch);
if (roleFilter) params.set("role", roleFilter);
if (activeFilter) params.set("active", activeFilter);
const [mb, al] = await Promise.all([
fetch("/api/v1/admin/mailboxes").then((r) => (r.ok ? r.json() : [])),
fetch(`/api/v1/admin/mailboxes?${params.toString()}`).then((r) =>
r.ok ? r.json() : { items: [], total: 0, page: 1, page_size: 25, total_pages: 1 },
),
fetch("/api/v1/admin/aliases").then((r) => (r.ok ? r.json() : [])),
]);
const users = mb as PagedUsers;
setPageInfo({
total: users.total ?? 0,
page: users.page ?? 1,
pageSize: users.page_size ?? 25,
totalPages: users.total_pages ?? 1,
});
setMailboxes(
(mb as ApiMailbox[]).map((m) => ({
(users.items ?? []).map((m) => ({
id: m.id,
email: m.email_address,
role: m.role,
@@ -94,6 +141,7 @@ export default function AdminMailboxesPage() {
quotaMb: Math.round(Number(m.quota_bytes ?? 0) / 1048576),
mfaEnabled: Boolean(m.mfa_enrolled),
locked: !m.is_active,
locale: m.locale ?? "",
}))
);
setAliases(
@@ -107,7 +155,13 @@ export default function AdminMailboxesPage() {
} catch {
setError(t("errors.loadFailed"));
}
}, []);
}, [t, page, debouncedSearch, roleFilter, activeFilter]);
// A changed filter belongs on page one; keeping the page number lands the
// reader on an empty page of a smaller result set.
useEffect(() => {
setPage(1);
}, [debouncedSearch, roleFilter, activeFilter]);
useEffect(() => {
void load();
@@ -155,6 +209,62 @@ export default function AdminMailboxesPage() {
await load();
};
/**
* Opens one user for editing, with the aliases that deliver to them.
*
* An alias belongs to a domain rather than to a person - it carries a list
* of destinations, which may point at other servers - so what is shown here
* is the set that lands in this mailbox, not a collection the user owns.
*/
const openUser = async (mb: Mailbox) => {
setEditing(mb);
setEditRole(mb.role);
setEditQuotaMb(mb.quotaMb);
setEditLocale(mb.locale);
setEditAliases(null);
try {
const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(mb.id)}/aliases`);
const data = res.ok ? await res.json() : [];
setEditAliases(
(data as ApiAlias[]).map((a) => ({
id: a.id,
aliasAddress: a.source_address,
targetAddress: (a.destination_addresses ?? []).join(", "),
domain: a.domain_name,
})),
);
} catch {
setEditAliases([]);
}
};
const saveUser = async () => {
if (!editing) return;
setSaving(true);
setError(null);
try {
const res = await fetch(`/api/v1/admin/mailboxes/${encodeURIComponent(editing.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
role: editRole,
// Stored in bytes; the field is in megabytes because that is what an
// operator thinks in.
quota_bytes: Math.max(0, Math.round(editQuotaMb * 1048576)),
locale: editLocale || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message);
setEditing(null);
await load();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
} finally {
setSaving(false);
}
};
const handleCreateAlias = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
@@ -385,6 +495,42 @@ export default function AdminMailboxesPage() {
</section>
<div className="rounded-2xl bg-dark-panel px-[18px] pb-3 pt-1.5 shadow-edge">
{/* Filtering and paging are done by the server. */}
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative min-w-[220px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("ui.searchPlaceholder")}
aria-label={t("ui.searchPlaceholder")}
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
aria-label={t("ui.role")}
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.all")}</option>
<option value="USER">USER</option>
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
</select>
<select
value={activeFilter}
onChange={(e) => setActiveFilter(e.target.value)}
aria-label={t("ui.accountStatus")}
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.all")}</option>
<option value="true">{t("ui.active")}</option>
<option value="false">{t("ui.accountLocked")}</option>
</select>
</div>
<div className="overflow-x-auto">
<table className="lm-table">
<thead>
@@ -439,6 +585,15 @@ export default function AdminMailboxesPage() {
</Badge>
</td>
<td className="whitespace-nowrap pr-0 text-right">
<button
type="button"
onClick={() => void openUser(mb)}
title={t("common.edit")}
aria-label={t("common.edit")}
className={`${iconButton} inline-flex`}
>
<Pencil className="h-[15px] w-[15px]" />
</button>
<button
type="button"
onClick={() => void handleToggleLock(mb.id, mb.locked)}
@@ -463,6 +618,105 @@ export default function AdminMailboxesPage() {
</tbody>
</table>
</div>
<Pagination
page={pageInfo.page}
pageSize={pageInfo.pageSize}
total={pageInfo.total}
totalPages={pageInfo.totalPages}
onPage={setPage}
/>
</div>
</div>
)}
{/* The user being edited, with the aliases that land in their mailbox. */}
{editing && (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-16">
<div className="w-full max-w-[560px] rounded-2xl bg-dark-panel p-5 shadow-edge">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="break-words text-[17px] font-medium leading-tight text-slate-100">
{editing.email}
</h2>
<p className="mt-1 text-[12px] text-slate-400">{t("ui.editUser")}</p>
</div>
<button
type="button"
onClick={() => setEditing(null)}
aria-label={t("common.cancel")}
className={`${iconButton} inline-flex`}
>
<X className="h-[15px] w-[15px]" />
</button>
</div>
<div className="mt-4 flex flex-col gap-3">
<div>
<label htmlFor="edit-role" className={fieldLabel}>{t("ui.role")}</label>
<select
id="edit-role"
value={editRole}
onChange={(e) => setEditRole(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>
<label htmlFor="edit-quota" className={fieldLabel}>{t("ui.quotaMb")}</label>
<input
id="edit-quota"
type="number"
min={0}
value={editQuotaMb}
onChange={(e) => setEditQuotaMb(Number(e.target.value))}
className={input}
/>
</div>
<div>
<label htmlFor="edit-locale" className={fieldLabel}>{t("settings.language")}</label>
<select
id="edit-locale"
value={editLocale}
onChange={(e) => setEditLocale(e.target.value)}
className={input}
>
<option value="">{t("common.none")}</option>
<option value="pt-BR">Portugues (BR)</option>
<option value="en">English</option>
<option value="es">Espanol</option>
</select>
</div>
<div className="rounded-xl bg-dark-card p-3.5 shadow-edge">
<div className="text-xs text-slate-400">{t("admin.aliasesForUser")}</div>
{editAliases === null ? (
<div className="mt-2 text-[12.5px] text-slate-500">{t("common.loading")}</div>
) : editAliases.length === 0 ? (
<div className="mt-2 text-[12.5px] text-slate-500">{t("admin.noAliasesForUser")}</div>
) : (
<ul className="mt-2 flex flex-col gap-1.5">
{editAliases.map((a) => (
<li key={a.id} className="break-all font-mono text-[12px] text-slate-200">
{a.aliasAddress}
</li>
))}
</ul>
)}
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" size="sm" onClick={() => setEditing(null)}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="sm" onClick={() => void saveUser()} disabled={saving}>
{saving ? t("common.loading") : t("common.save")}
</Button>
</div>
</div>
</div>
</div>
)}
@@ -1,10 +1,12 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { RefreshCw } from "lucide-react";
import { RefreshCw, Search } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
import { Pagination } from "../../../../components/ui/Pagination";
import { useDebounced } from "../../../../lib/useDebounced";
/**
* The outbound queue, read from the database.
@@ -33,6 +35,10 @@ interface QueueJob {
interface QueueSummary {
by_status: Record<string, number>;
recent: QueueJob[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
/**
@@ -55,10 +61,28 @@ export default function AdminQueuePage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Filtering and paging happen in the database. Holding them here and
// slicing in the browser would still have fetched only the first page,
// which is the whole reason a backed-up queue could not be inspected.
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const debouncedSearch = useDebounced(search, 300);
// A new search belongs on page one; keeping the old page number lands the
// reader on an empty page of a smaller result set.
useEffect(() => {
setPage(1);
}, [debouncedSearch, status]);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/v1/admin/queue");
const params = new URLSearchParams({ page: String(page), page_size: "25" });
if (debouncedSearch) params.set("search", debouncedSearch);
if (status) params.set("status", status);
const res = await fetch(`/api/v1/admin/queue?${params.toString()}`);
if (!res.ok) throw new Error();
setSummary(await res.json());
setError(null);
@@ -70,7 +94,7 @@ export default function AdminQueuePage() {
} finally {
setLoading(false);
}
}, [t]);
}, [t, page, debouncedSearch, status]);
useEffect(() => {
void load();
@@ -134,6 +158,33 @@ export default function AdminQueuePage() {
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
)}
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative min-w-[220px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("ui.searchPlaceholder")}
aria-label={t("ui.searchPlaceholder")}
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
/>
</div>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-label={t("common.status")}
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.all")}</option>
{/* Taken from the aggregate, so the choices are the states this
queue actually contains rather than a list invented here. */}
{Object.keys(summary?.by_status ?? {}).sort().map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</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-[13px] text-slate-400">{t("admin.queueEmpty")}</div>
@@ -186,6 +237,15 @@ export default function AdminQueuePage() {
</table>
</div>
)}
<Pagination
page={summary?.page ?? 1}
pageSize={summary?.page_size ?? 25}
total={summary?.total ?? 0}
totalPages={summary?.total_pages ?? 1}
onPage={setPage}
busy={loading}
/>
</div>
</div>
);
@@ -0,0 +1,217 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { PlayCircle, Server, User as UserIcon, Search } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
import { useDebounced } from "../../../../lib/useDebounced";
/**
* Checks an operator runs by hand, against the server or one user.
*
* Everything here reads state; nothing sends mail or changes a setting. A
* diagnostic screen that alters what it is diagnosing is worse than no screen.
*/
interface Check {
key: string;
status: "PASS" | "WARN" | "FAIL" | "INFO";
subject?: string;
detail?: string;
}
interface Report {
scope: string;
subject?: string;
status: Check["status"];
checks: Check[];
}
interface UserRow {
id: string;
email_address: string;
}
function variantFor(status: Check["status"]): "success" | "warning" | "danger" | "neutral" {
if (status === "PASS") return "success";
if (status === "WARN") return "warning";
if (status === "FAIL") return "danger";
return "neutral";
}
export default function AdminTestsPage() {
const t = useTranslations();
const [serverReport, setServerReport] = useState<Report | null>(null);
const [userReport, setUserReport] = useState<Report | null>(null);
const [runningServer, setRunningServer] = useState(false);
const [runningUser, setRunningUser] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [users, setUsers] = useState<UserRow[]>([]);
const [selected, setSelected] = useState("");
const debouncedSearch = useDebounced(search, 300);
// The picker asks the server for a short page rather than pulling every
// user into the browser to filter them here.
const loadUsers = useCallback(async () => {
try {
const params = new URLSearchParams({ page: "1", page_size: "20" });
if (debouncedSearch) params.set("search", debouncedSearch);
const res = await fetch(`/api/v1/admin/mailboxes?${params.toString()}`);
if (!res.ok) throw new Error();
const data = await res.json();
setUsers((data.items ?? []) as UserRow[]);
} catch {
setUsers([]);
}
}, [debouncedSearch]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
const runServer = async () => {
setRunningServer(true);
setError(null);
try {
const res = await fetch("/api/v1/admin/diagnostics/server");
if (!res.ok) throw new Error();
setServerReport(await res.json());
} catch {
setServerReport(null);
setError(t("errors.loadFailed"));
} finally {
setRunningServer(false);
}
};
const runUser = async () => {
if (!selected) return;
setRunningUser(true);
setError(null);
try {
const res = await fetch(`/api/v1/admin/diagnostics/user/${encodeURIComponent(selected)}`);
if (!res.ok) throw new Error();
setUserReport(await res.json());
} catch {
setUserReport(null);
setError(t("errors.loadFailed"));
} finally {
setRunningUser(false);
}
};
const panel = "flex flex-col gap-3.5 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
const renderReport = (report: Report | null) =>
report === null ? null : (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2.5">
<Badge variant={variantFor(report.status)}>{report.status}</Badge>
<span className="text-[12px] text-slate-400">
{t("admin.checksRun", { count: report.checks.length })}
</span>
</div>
<ul className="flex flex-col gap-1.5">
{report.checks.map((c, i) => (
<li
key={`${c.key}-${c.subject ?? i}`}
className="flex flex-wrap items-center gap-2.5 rounded-xl bg-dark-card px-3.5 py-2.5 shadow-edge"
>
<Badge variant={variantFor(c.status)} className="whitespace-nowrap">
{c.status}
</Badge>
{/* The key is translated here rather than sent as a finished
sentence, so the page reads in the interface's language. */}
<span className="min-w-0 flex-1 break-words text-[13px] text-slate-200">
{t(`checks.${c.key}`)}
{c.subject ? <span className="text-slate-400"> — {c.subject}</span> : null}
</span>
{c.detail ? (
<span className="whitespace-nowrap font-mono text-[11.5px] text-slate-400">{c.detail}</span>
) : null}
</li>
))}
</ul>
</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 className="min-w-0">
<h1 className="text-[25px] font-medium leading-tight text-slate-100">{t("admin.testsTitle")}</h1>
<p className="mt-1.5 text-[13.5px] text-slate-400">{t("admin.testsSubtitle")}</p>
</div>
{error && (
<div className="rounded-xl bg-rose-900/60 px-4 py-3 text-[12.5px] text-rose-200 shadow-edge">{error}</div>
)}
<section className={panel}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
<Server className="h-[17px] w-[17px] flex-none text-indigo-500" />
{t("admin.serverChecks")}
</h2>
<Button variant="primary" size="sm" onClick={() => void runServer()} disabled={runningServer}>
<PlayCircle className="h-3.5 w-3.5" />
<span>{runningServer ? t("common.loading") : t("admin.runChecks")}</span>
</Button>
</div>
{renderReport(serverReport)}
</section>
<section className={panel}>
<h2 className="flex items-center gap-2 text-[17px] font-medium leading-tight text-slate-100">
<UserIcon className="h-[17px] w-[17px] flex-none text-indigo-500" />
{t("admin.userChecks")}
</h2>
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative min-w-[200px] flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("ui.searchPlaceholder")}
aria-label={t("ui.searchPlaceholder")}
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
/>
</div>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
aria-label={t("admin.userChecks")}
className="min-w-[220px] rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.selectUser")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.email_address}
</option>
))}
</select>
<Button
variant="primary"
size="sm"
onClick={() => void runUser()}
disabled={runningUser || !selected}
>
<PlayCircle className="h-3.5 w-3.5" />
<span>{runningUser ? t("common.loading") : t("admin.runChecks")}</span>
</Button>
</div>
{userReport?.subject ? (
<div className="break-all text-[12.5px] text-slate-400">{userReport.subject}</div>
) : null}
{renderReport(userReport)}
</section>
</div>
);
}
@@ -0,0 +1,76 @@
"use client";
import React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "./Button";
import { pageWindow, pageRange } from "../../lib/paging";
import { useTranslations } from "../../i18n/provider";
interface PaginationProps {
page: number;
pageSize: number;
total: number;
totalPages: number;
onPage: (page: number) => void;
busy?: boolean;
}
/**
* The pager under a server-paged list.
*
* The lists it sits under fetch one page at a time, so every control here
* moves the page the server is asked for - nothing is sliced in the browser.
*/
export function Pagination({ page, pageSize, total, totalPages, onPage, busy }: PaginationProps) {
const t = useTranslations();
const { from, to } = pageRange(page, pageSize, total);
// One page of results needs no controls, but the count is still worth
// stating so an empty list is distinguishable from a broken one.
const showControls = totalPages > 1;
return (
<div className="flex flex-wrap items-center justify-between gap-3 pt-1">
<span className="text-[12px] text-slate-400">
{total > 0 ? t("ui.showingRange", { from, to, total }) : t("ui.noResults")}
</span>
{showControls && (
<div className="flex flex-wrap items-center gap-1.5">
<Button
variant="secondary"
size="sm"
onClick={() => onPage(page - 1)}
disabled={busy || page <= 1}
aria-label={t("ui.previousPage")}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
{pageWindow(page, totalPages).map((p) => (
<Button
key={p}
variant={p === page ? "primary" : "secondary"}
size="sm"
onClick={() => onPage(p)}
disabled={busy}
aria-current={p === page ? "page" : undefined}
>
<span className="tabular-nums">{p}</span>
</Button>
))}
<Button
variant="secondary"
size="sm"
onClick={() => onPage(page + 1)}
disabled={busy || page >= totalPages}
aria-label={t("ui.nextPage")}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { pageWindow, pageRange } from "./paging";
describe("pageWindow", () => {
it("shows every page when there are few", () => {
expect(pageWindow(1, 3)).toEqual([1, 2, 3]);
});
it("follows the current page without running past the ends", () => {
expect(pageWindow(10, 20)).toEqual([8, 9, 10, 11, 12]);
expect(pageWindow(1, 20)).toEqual([1, 2, 3]);
expect(pageWindow(20, 20)).toEqual([18, 19, 20]);
});
// A page number out of range comes back from a stale URL or a list that
// shrank under the reader; it must not produce an empty or negative pager.
it.each([
[0, 5],
[-3, 5],
[99, 5],
[Number.NaN, 5],
])("stays inside the range for page=%s of %s", (page, total) => {
const w = pageWindow(page, total);
expect(w.length).toBeGreaterThan(0);
for (const p of w) {
expect(p).toBeGreaterThanOrEqual(1);
expect(p).toBeLessThanOrEqual(total);
}
});
it("survives having no pages at all", () => {
expect(pageWindow(1, 0)).toEqual([1]);
});
});
describe("pageRange", () => {
it("describes the rows on this page", () => {
expect(pageRange(1, 25, 100)).toEqual({ from: 1, to: 25 });
expect(pageRange(4, 25, 100)).toEqual({ from: 76, to: 100 });
});
// The last page is usually short; saying "76 to 100 of 82" would be wrong.
it("stops at the total on a partial last page", () => {
expect(pageRange(4, 25, 82)).toEqual({ from: 76, to: 82 });
});
it("says nothing when there is nothing", () => {
expect(pageRange(1, 25, 0)).toEqual({ from: 0, to: 0 });
});
});
+24
View File
@@ -0,0 +1,24 @@
/**
* The page numbers a pager should offer.
*
* Rendering every page is fine for three and unusable for four hundred, so the
* window follows the current page and the ends stay reachable.
*/
export function pageWindow(page: number, totalPages: number, span = 2): number[] {
if (!Number.isFinite(totalPages) || totalPages < 1) return [1];
const current = Math.min(Math.max(Math.trunc(page) || 1, 1), totalPages);
const first = Math.max(1, current - span);
const last = Math.min(totalPages, current + span);
const pages: number[] = [];
for (let p = first; p <= last; p++) pages.push(p);
return pages;
}
/** The human sentence under a list: which rows these are, out of how many. */
export function pageRange(page: number, pageSize: number, total: number): { from: number; to: number } {
if (total <= 0) return { from: 0, to: 0 };
const from = (page - 1) * pageSize + 1;
return { from, to: Math.min(total, page * pageSize) };
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useEffect, useState } from "react";
/**
* Holds a value still until typing stops.
*
* Every keystroke in a search box would otherwise be one more query against
* the database, and the answers can arrive out of order - so the list flickers
* between results for "an", "ana" and "a".
*/
export function useDebounced<T>(value: T, delayMs = 300): T {
const [settled, setSettled] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setSettled(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return settled;
}