fix(admin): dominio trocado ao criar, apelidos escondidos e quota a zero

Criar "lucas@cienciaembarcada.com.br" criava "lucas@lucasrguerra.dev.br". O
formulario partia o endereco e deitava fora a metade do dominio, e nao enviava
domain_id nenhum -- o servidor caia entao no dominio do admin com sessao
iniciada. Passa a ser parte local mais um dominio escolhido de uma lista: a
metade que decide o endereco deixa de poder ser descartada em silencio.

Apelidos de outros dominios estavam escondidos, e a culpa era minha: procurava
so no dominio onde a morada do proprio utilizador vive. Uma caixa recebe
postmaster, abuse, dmarc e tlsrpt de varios dominios ao mesmo tempo, por isso
o painel mostrava quatro onde entregavam oito. Ha um teste que falha com a
consulta antiga.

Os apelidos passam a poder ser criados e removidos dentro do modal. O dominio
sai do endereco escrito, para nao repetir a armadilha do formulario de cima.

USER, DOMAIN_ADMIN e SUPER_ADMIN eram constantes impressas no ecra, logo
ficavam em ingles em qualquer idioma. Um papel desconhecido continua a ser
mostrado tal como esta guardado, porque quem o ve precisa de saber o que a
base de dados tem.

A quota estava certa e mal mostrada. used_bytes vale 140771 -- os 137,5 KB
reais -- mas a pagina arredondava para megabytes inteiros, portanto qualquer
coisa abaixo de 512 KB lia-se "0 / 2 MB" e a barra ficava vazia, calculada
sobre os megabytes ja arredondados. Passa a formatar a partir dos bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-24 11:02:02 -03:00
1 parent f801894f53
commit bb12e603a3
9 files changed
+308 -35

No files matched your search

+8 -1
View File
@@ -104,7 +104,9 @@
"serverChecks": "Server",
"userChecks": "User",
"runChecks": "Run",
"checksRun": "{count} checks"
"checksRun": "{count} checks",
"pickADomain": "Pick a domain.",
"aliasSource": "Alias address"
},
"auth": {
"adminLoginTitle": "Admin console",
@@ -413,5 +415,10 @@
"queueHasFrozen": "Frozen messages in the queue",
"usersOverQuota": "Users over their quota",
"noUsersOverQuota": "No user is over quota"
},
"roles": {
"user": "User",
"domainAdmin": "Domain admin",
"superAdmin": "Super admin"
}
}
+9 -2
View File
@@ -1,6 +1,6 @@
{
"admin": {
"addAlias": "Añadir apelido",
"addAlias": "Anadir alias",
"aliasTarget": "Dirección de destino",
"aliasesTitle": "Alias",
"auditLog": "Registro de auditoría",
@@ -104,7 +104,9 @@
"serverChecks": "Servidor",
"userChecks": "Usuario",
"runChecks": "Ejecutar",
"checksRun": "{count} comprobaciones"
"checksRun": "{count} comprobaciones",
"pickADomain": "Elija un dominio.",
"aliasSource": "Direccion del alias"
},
"auth": {
"adminLoginTitle": "Consola de administración",
@@ -413,5 +415,10 @@
"queueHasFrozen": "Hay mensajes congelados en la cola",
"usersOverQuota": "Usuarios con la cuota excedida",
"noUsersOverQuota": "Ningun usuario con la cuota excedida"
},
"roles": {
"user": "Usuario",
"domainAdmin": "Admin del dominio",
"superAdmin": "Super admin"
}
}
+8 -1
View File
@@ -104,7 +104,9 @@
"serverChecks": "Servidor",
"userChecks": "Utilizador",
"runChecks": "Correr",
"checksRun": "{count} verificacoes"
"checksRun": "{count} verificacoes",
"pickADomain": "Escolha um dominio.",
"aliasSource": "Endereco do apelido"
},
"auth": {
"adminLoginTitle": "Console de administração",
@@ -413,5 +415,10 @@
"queueHasFrozen": "Ha mensagens congeladas na fila",
"usersOverQuota": "Utilizadores com a quota excedida",
"noUsersOverQuota": "Nenhum utilizador com a quota excedida"
},
"roles": {
"user": "Utilizador",
"domainAdmin": "Admin do dominio",
"superAdmin": "Super admin"
}
}
@@ -22,6 +22,8 @@ import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
import { Pagination } from "../../../../components/ui/Pagination";
import { useDebounced } from "../../../../lib/useDebounced";
import { formatBytes, usageRatio } from "../../../../lib/formatBytes";
import { roleLabel } from "../../../../lib/roleLabel";
import { initialsFor } from "../../../../lib/initials";
import { Button } from "../../../../components/ui/Button";
@@ -29,8 +31,8 @@ interface Mailbox {
id: string;
email: string;
role: string;
storageUsedMb: number;
quotaMb: number;
usedBytes: number;
quotaBytes: number;
mfaEnabled: boolean;
locked: boolean;
locale: string;
@@ -76,6 +78,8 @@ export default function AdminMailboxesPage() {
const [activeTab, setActiveTab] = useState<"mailboxes" | "aliases" | "csv">("mailboxes");
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
const [aliases, setAliases] = useState<Alias[]>([]);
const [domains, setDomains] = useState<{ id: string; name: string }[]>([]);
const [newDomainId, setNewDomainId] = useState("");
const [mfaPolicy, setMfaPolicy] = useState<"optional" | "required_admins" | "required_all">("required_admins");
// Create mailbox state
@@ -111,6 +115,8 @@ export default function AdminMailboxesPage() {
const [editLocale, setEditLocale] = useState("");
const [editAliases, setEditAliases] = useState<Alias[] | null>(null);
const [saving, setSaving] = useState(false);
const [newAliasForUser, setNewAliasForUser] = useState("");
const [aliasBusy, setAliasBusy] = useState(false);
const load = useCallback(async () => {
try {
@@ -125,6 +131,14 @@ export default function AdminMailboxesPage() {
),
fetch("/api/v1/admin/aliases").then((r) => (r.ok ? r.json() : [])),
]);
// The address to create is local part plus a chosen domain. Without the
// list there is nothing to choose from, and the server falls back to the
// signed-in admin's own domain.
const dom = await fetch("/api/v1/admin/domains").then((r) => (r.ok ? r.json() : []));
const domainRows = (dom as { id: string; name: string }[]) ?? [];
setDomains(domainRows);
setNewDomainId((prev) => (domainRows.some((d) => d.id === prev) ? prev : (domainRows[0]?.id ?? "")));
const users = mb as PagedUsers;
setPageInfo({
total: users.total ?? 0,
@@ -137,8 +151,10 @@ export default function AdminMailboxesPage() {
id: m.id,
email: m.email_address,
role: m.role,
storageUsedMb: Math.round(Number(m.used_bytes ?? 0) / 1048576),
quotaMb: Math.round(Number(m.quota_bytes ?? 0) / 1048576),
// Kept as bytes. Rounding to whole megabytes here is what made a
// mailbox holding 137.5 KB read as "0 / 2 MB" with an empty bar.
usedBytes: Number(m.used_bytes ?? 0),
quotaBytes: Number(m.quota_bytes ?? 0),
mfaEnabled: Boolean(m.mfa_enrolled),
locked: !m.is_active,
locale: m.locale ?? "",
@@ -170,11 +186,20 @@ export default function AdminMailboxesPage() {
const handleCreateMailbox = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const localPart = newEmail.split("@")[0];
// The domain half used to be split off and thrown away, and no domain_id
// was sent - so the server fell back to the signed-in admin's own domain
// and "lucas@cienciaembarcada.com.br" was created as
// "lucas@lucasrguerra.dev.br", silently.
const localPart = newEmail.split("@")[0].trim();
if (!newDomainId) {
setError(t("admin.pickADomain"));
return;
}
const res = await fetch("/api/v1/admin/mailboxes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
domain_id: newDomainId,
local_part: localPart,
password: newPassword,
role: newRole,
@@ -219,7 +244,7 @@ export default function AdminMailboxesPage() {
const openUser = async (mb: Mailbox) => {
setEditing(mb);
setEditRole(mb.role);
setEditQuotaMb(mb.quotaMb);
setEditQuotaMb(Math.round(mb.quotaBytes / 1048576));
setEditLocale(mb.locale);
setEditAliases(null);
try {
@@ -238,6 +263,54 @@ export default function AdminMailboxesPage() {
}
};
/**
* Adds an alias that delivers to the user being edited.
*
* The domain is taken from the address typed, so an alias for a second
* domain does not silently land in the admin's own - the same trap the
* mailbox form fell into.
*/
const addAliasForUser = async () => {
if (!editing || !newAliasForUser.trim()) return;
setAliasBusy(true);
setError(null);
try {
const source = newAliasForUser.trim().toLowerCase();
const domainName = source.split("@")[1] ?? "";
const domain = domains.find((d) => d.name.toLowerCase() === domainName);
if (!domain) {
setError(t("admin.pickADomain"));
return;
}
const res = await fetch("/api/v1/admin/aliases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain_id: domain.id, source, destinations: [editing.email] }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message);
setNewAliasForUser("");
await openUser(editing);
await load();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t("errors.serverError"));
} finally {
setAliasBusy(false);
}
};
const removeAliasForUser = async (id: string) => {
if (!editing) return;
setAliasBusy(true);
try {
await fetch(`/api/v1/admin/aliases/${encodeURIComponent(id)}`, { method: "DELETE" });
await openUser(editing);
await load();
} finally {
setAliasBusy(false);
}
};
const saveUser = async () => {
if (!editing) return;
setSaving(true);
@@ -438,14 +511,33 @@ export default function AdminMailboxesPage() {
</label>
<input
id="new-email"
type="email"
type="text"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="nova.conta@domain.com"
placeholder="nova.conta"
required
className={input}
/>
</div>
{/* The domain is chosen, not typed. Typing a full address let
the domain half be discarded without anyone noticing. */}
<div className="min-w-[200px] flex-1">
<label htmlFor="new-domain" className={fieldLabel}>
{t("admin.domainsTitle")}
</label>
<select
id="new-domain"
value={newDomainId}
onChange={(e) => setNewDomainId(e.target.value)}
className={input}
>
{domains.map((d) => (
<option key={d.id} value={d.id}>
@{d.name}
</option>
))}
</select>
</div>
<div className="min-w-[180px] flex-1">
<label htmlFor="new-mailbox-password" className={fieldLabel}>
{t("ui.newPassword")}
@@ -470,9 +562,9 @@ export default function AdminMailboxesPage() {
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>
<option value="USER">{roleLabel(t, "USER")}</option>
<option value="DOMAIN_ADMIN">{roleLabel(t, "DOMAIN_ADMIN")}</option>
<option value="SUPER_ADMIN">{roleLabel(t, "SUPER_ADMIN")}</option>
</select>
</div>
<div className="min-w-[130px] flex-1">
@@ -515,9 +607,9 @@ export default function AdminMailboxesPage() {
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>
<option value="USER">{roleLabel(t, "USER")}</option>
<option value="DOMAIN_ADMIN">{roleLabel(t, "DOMAIN_ADMIN")}</option>
<option value="SUPER_ADMIN">{roleLabel(t, "SUPER_ADMIN")}</option>
</select>
<select
value={activeFilter}
@@ -556,7 +648,7 @@ export default function AdminMailboxesPage() {
</td>
<td>
<Badge variant="neutral" className="whitespace-nowrap">
{mb.role}
{roleLabel(t, mb.role)}
</Badge>
</td>
<td>
@@ -564,13 +656,12 @@ export default function AdminMailboxesPage() {
<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)}%`,
}}
style={{ width: `${usageRatio(mb.usedBytes, mb.quotaBytes) * 100}%` }}
/>
</span>
<span className="whitespace-nowrap text-[11.5px] tabular-nums text-slate-400">
{mb.storageUsedMb} / {mb.quotaMb} MB
{formatBytes(mb.usedBytes)} /{" "}
{mb.quotaBytes > 0 ? formatBytes(mb.quotaBytes) : t("checks.quotaUnlimited")}
</span>
</div>
</td>
@@ -660,9 +751,9 @@ export default function AdminMailboxesPage() {
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>
<option value="USER">{roleLabel(t, "USER")}</option>
<option value="DOMAIN_ADMIN">{roleLabel(t, "DOMAIN_ADMIN")}</option>
<option value="SUPER_ADMIN">{roleLabel(t, "SUPER_ADMIN")}</option>
</select>
</div>
<div>
@@ -700,12 +791,47 @@ export default function AdminMailboxesPage() {
) : (
<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
key={a.id}
className="flex items-center gap-2 rounded-lg bg-dark-panel px-2.5 py-1.5"
>
<span className="min-w-0 flex-1 break-all font-mono text-[12px] text-slate-200">
{a.aliasAddress}
</span>
<button
type="button"
onClick={() => void removeAliasForUser(a.id)}
disabled={aliasBusy}
title={t("common.delete")}
aria-label={`${t("common.delete")} ${a.aliasAddress}`}
className={`${iconButton} inline-flex flex-none hover:text-rose-400`}
>
<Trash2 className="h-[14px] w-[14px]" />
</button>
</li>
))}
</ul>
)}
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<input
type="email"
value={newAliasForUser}
onChange={(e) => setNewAliasForUser(e.target.value)}
placeholder={t("admin.aliasSource")}
aria-label={t("admin.aliasSource")}
className={`${input} min-w-[200px] flex-1`}
/>
<Button
variant="secondary"
size="sm"
onClick={() => void addAliasForUser()}
disabled={aliasBusy || !newAliasForUser.trim()}
>
<Plus className="h-3.5 w-3.5" />
<span>{t("admin.addAlias")}</span>
</Button>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { formatBytes, usageRatio } from "./formatBytes";
describe("formatBytes", () => {
// The case from the console: 140771 bytes rendered as "0 / 2 MB".
it("shows a mailbox smaller than a megabyte as itself", () => {
expect(formatBytes(140771)).toBe("137.5 KB");
});
it("climbs through the units", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1024)).toBe("1.0 KB");
expect(formatBytes(2097152)).toBe("2.0 MB");
expect(formatBytes(5_368_709_120)).toBe("5.0 GB");
});
it("keeps one decimal at every size, so the reading stays comparable", () => {
expect(formatBytes(157_286_400)).toBe("150.0 MB");
});
it.each([-1, Number.NaN, Number.POSITIVE_INFINITY])("survives %s", (value) => {
expect(typeof formatBytes(value)).toBe("string");
});
});
describe("usageRatio", () => {
it("measures from the raw bytes, not from rounded megabytes", () => {
// Rounding first gave 0/2 = 0%, and the bar stayed empty on a mailbox
// that was really 6.7% full.
expect(usageRatio(140771, 2097152)).toBeCloseTo(0.0671, 3);
});
// Zero means unlimited; dividing by it reported every unlimited mailbox as
// over its limit.
it("never calls an unlimited mailbox full", () => {
expect(usageRatio(5_000_000, 0)).toBe(0);
});
it("stops at full rather than overflowing the bar", () => {
expect(usageRatio(3_000_000, 2_000_000)).toBe(1);
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* A byte count as a person reads it.
*
* The admin list rounded bytes to whole megabytes, so a mailbox holding
* 137.5 KB displayed as "0 / 2 MB" and its usage bar sat empty - the figure
* was right in the database and lost on the way to the screen.
*/
export function formatBytes(bytes: number): string {
const value = Number(bytes);
if (!Number.isFinite(value) || value <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let n = value;
let unit = 0;
while (n >= 1024 && unit < units.length - 1) {
n /= 1024;
unit++;
}
// Bytes are whole things; above that always one decimal, which is what
// tells 137.5 KB from 137 KB. Dropping it on larger numbers would have hid
// exactly the digit that made the reading recognisable.
const rendered = unit === 0 ? String(Math.round(n)) : n.toFixed(1);
return `${rendered} ${units[unit]}`;
}
/**
* How full a mailbox is, as a percentage, computed from the raw byte counts.
*
* A quota of zero means unlimited, so it is never full - dividing by it would
* report every unlimited mailbox as over its limit.
*/
export function usageRatio(usedBytes: number, quotaBytes: number): number {
const used = Number(usedBytes);
const quota = Number(quotaBytes);
if (!Number.isFinite(used) || !Number.isFinite(quota) || quota <= 0 || used <= 0) return 0;
return Math.min(used / quota, 1);
}
+18
View File
@@ -0,0 +1,18 @@
/**
* A role as a person reads it.
*
* The console printed the stored constants - USER, DOMAIN_ADMIN, SUPER_ADMIN -
* straight onto the screen, so those three words stayed in English whatever
* the interface was set to.
*/
export function roleLabel(t: (key: string) => string, role: string): string {
const known: Record<string, string> = {
USER: "roles.user",
DOMAIN_ADMIN: "roles.domainAdmin",
SUPER_ADMIN: "roles.superAdmin",
};
const key = known[String(role).toUpperCase()];
// An unknown role is shown as stored rather than as a missing-translation
// marker: whoever sees it needs to know what the database actually holds.
return key ? t(key) : String(role);
}