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:
1 parent
f801894f53
commit
bb12e603a3
9 files changed
+308
-35
No files matched your search
@@ -896,24 +896,31 @@ export async function listAliasesForMailbox(
|
|||||||
mailboxId: string,
|
mailboxId: string,
|
||||||
): Promise<unknown[] | null> {
|
): Promise<unknown[] | null> {
|
||||||
const { sql, params } = scopeClause(scope, "m.domain_id");
|
const { sql, params } = scopeClause(scope, "m.domain_id");
|
||||||
const owner = await queryOne<{ email_address: string; domain_id: string }>(
|
const owner = await queryOne<{ email_address: string }>(
|
||||||
`SELECT m.email_address, m.domain_id FROM mailboxes m WHERE m.id = $${params.length + 1} AND ${sql}`,
|
`SELECT m.email_address FROM mailboxes m WHERE m.id = $${params.length + 1} AND ${sql}`,
|
||||||
[...params, mailboxId],
|
[...params, mailboxId],
|
||||||
);
|
);
|
||||||
// Out of scope and non-existent are answered the same way, so the console
|
// Out of scope and non-existent are answered the same way, so the console
|
||||||
// of one domain cannot probe for ids belonging to another.
|
// of one domain cannot probe for ids belonging to another.
|
||||||
if (!owner) return null;
|
if (!owner) return null;
|
||||||
|
|
||||||
|
// Every domain in the caller's scope, not only the one the user's own
|
||||||
|
// address sits in. One mailbox commonly receives postmaster, abuse, dmarc
|
||||||
|
// and tlsrpt for several domains at once, and scoping this to the owner's
|
||||||
|
// domain hid all the others - the panel showed four aliases where eight
|
||||||
|
// were delivering.
|
||||||
|
const { sql: aliasSql, params: aliasParams } = scopeClause(scope, "a.domain_id");
|
||||||
return query(
|
return query(
|
||||||
`SELECT a.id, a.source_address, a.destination_addresses, a.is_catch_all, a.is_active
|
`SELECT a.id, a.source_address, a.destination_addresses, a.is_catch_all, a.is_active,
|
||||||
FROM aliases a
|
d.name AS domain_name
|
||||||
WHERE a.domain_id = $1
|
FROM aliases a JOIN domains d ON d.id = a.domain_id
|
||||||
|
WHERE ${aliasSql}
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1 FROM unnest(a.destination_addresses) AS dest
|
SELECT 1 FROM unnest(a.destination_addresses) AS dest
|
||||||
WHERE lower(dest) = lower($2)
|
WHERE lower(dest) = lower($${aliasParams.length + 1})
|
||||||
)
|
)
|
||||||
ORDER BY a.source_address`,
|
ORDER BY a.source_address`,
|
||||||
[owner.domain_id, owner.email_address],
|
[...aliasParams, owner.email_address],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -826,6 +826,26 @@ describeDb("auth API against a real database", () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// One mailbox commonly receives postmaster, abuse, dmarc and tlsrpt for
|
||||||
|
// several domains at once. Scoping the lookup to the domain the user's own
|
||||||
|
// address sits in hid every alias belonging to the others.
|
||||||
|
it("lists aliases from every domain, not only the user's own", async () => {
|
||||||
|
const mine = `cross-domain-alias@pagedtest-${PAGED_DOMAIN}.invalid`;
|
||||||
|
await query(
|
||||||
|
`INSERT INTO aliases (domain_id, source_address, destination_addresses)
|
||||||
|
VALUES ($1, $2, ARRAY[$3]) ON CONFLICT DO NOTHING`,
|
||||||
|
[PAGED_DOMAIN, mine, userEmail()],
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await get(`/api/v1/admin/mailboxes/${USER_ID}/aliases`, admin);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const sources = (res.body as unknown as { source_address: string }[]).map((a) => a.source_address);
|
||||||
|
|
||||||
|
// USER_ID lives in DOMAIN_ID; this alias lives in PAGED_DOMAIN and
|
||||||
|
// delivers to them all the same.
|
||||||
|
expect(sources).toContain(mine);
|
||||||
|
});
|
||||||
|
|
||||||
it("answers 404 for a user that does not exist rather than leaking the difference", async () => {
|
it("answers 404 for a user that does not exist rather than leaking the difference", async () => {
|
||||||
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
|
const res = await get(`/api/v1/admin/mailboxes/44444444-4444-4444-4444-444444444444/aliases`, admin);
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
|
|||||||
@@ -104,7 +104,9 @@
|
|||||||
"serverChecks": "Server",
|
"serverChecks": "Server",
|
||||||
"userChecks": "User",
|
"userChecks": "User",
|
||||||
"runChecks": "Run",
|
"runChecks": "Run",
|
||||||
"checksRun": "{count} checks"
|
"checksRun": "{count} checks",
|
||||||
|
"pickADomain": "Pick a domain.",
|
||||||
|
"aliasSource": "Alias address"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"adminLoginTitle": "Admin console",
|
"adminLoginTitle": "Admin console",
|
||||||
@@ -413,5 +415,10 @@
|
|||||||
"queueHasFrozen": "Frozen messages in the queue",
|
"queueHasFrozen": "Frozen messages in the queue",
|
||||||
"usersOverQuota": "Users over their quota",
|
"usersOverQuota": "Users over their quota",
|
||||||
"noUsersOverQuota": "No user is over quota"
|
"noUsersOverQuota": "No user is over quota"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"user": "User",
|
||||||
|
"domainAdmin": "Domain admin",
|
||||||
|
"superAdmin": "Super admin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"admin": {
|
"admin": {
|
||||||
"addAlias": "Añadir apelido",
|
"addAlias": "Anadir alias",
|
||||||
"aliasTarget": "Dirección de destino",
|
"aliasTarget": "Dirección de destino",
|
||||||
"aliasesTitle": "Alias",
|
"aliasesTitle": "Alias",
|
||||||
"auditLog": "Registro de auditoría",
|
"auditLog": "Registro de auditoría",
|
||||||
@@ -104,7 +104,9 @@
|
|||||||
"serverChecks": "Servidor",
|
"serverChecks": "Servidor",
|
||||||
"userChecks": "Usuario",
|
"userChecks": "Usuario",
|
||||||
"runChecks": "Ejecutar",
|
"runChecks": "Ejecutar",
|
||||||
"checksRun": "{count} comprobaciones"
|
"checksRun": "{count} comprobaciones",
|
||||||
|
"pickADomain": "Elija un dominio.",
|
||||||
|
"aliasSource": "Direccion del alias"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"adminLoginTitle": "Consola de administración",
|
"adminLoginTitle": "Consola de administración",
|
||||||
@@ -413,5 +415,10 @@
|
|||||||
"queueHasFrozen": "Hay mensajes congelados en la cola",
|
"queueHasFrozen": "Hay mensajes congelados en la cola",
|
||||||
"usersOverQuota": "Usuarios con la cuota excedida",
|
"usersOverQuota": "Usuarios con la cuota excedida",
|
||||||
"noUsersOverQuota": "Ningun usuario con la cuota excedida"
|
"noUsersOverQuota": "Ningun usuario con la cuota excedida"
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"user": "Usuario",
|
||||||
|
"domainAdmin": "Admin del dominio",
|
||||||
|
"superAdmin": "Super admin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,9 @@
|
|||||||
"serverChecks": "Servidor",
|
"serverChecks": "Servidor",
|
||||||
"userChecks": "Utilizador",
|
"userChecks": "Utilizador",
|
||||||
"runChecks": "Correr",
|
"runChecks": "Correr",
|
||||||
"checksRun": "{count} verificacoes"
|
"checksRun": "{count} verificacoes",
|
||||||
|
"pickADomain": "Escolha um dominio.",
|
||||||
|
"aliasSource": "Endereco do apelido"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"adminLoginTitle": "Console de administração",
|
"adminLoginTitle": "Console de administração",
|
||||||
@@ -413,5 +415,10 @@
|
|||||||
"queueHasFrozen": "Ha mensagens congeladas na fila",
|
"queueHasFrozen": "Ha mensagens congeladas na fila",
|
||||||
"usersOverQuota": "Utilizadores com a quota excedida",
|
"usersOverQuota": "Utilizadores com a quota excedida",
|
||||||
"noUsersOverQuota": "Nenhum utilizador 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 { Badge } from "../../../../components/ui/Badge";
|
||||||
import { Pagination } from "../../../../components/ui/Pagination";
|
import { Pagination } from "../../../../components/ui/Pagination";
|
||||||
import { useDebounced } from "../../../../lib/useDebounced";
|
import { useDebounced } from "../../../../lib/useDebounced";
|
||||||
|
import { formatBytes, usageRatio } from "../../../../lib/formatBytes";
|
||||||
|
import { roleLabel } from "../../../../lib/roleLabel";
|
||||||
import { initialsFor } from "../../../../lib/initials";
|
import { initialsFor } from "../../../../lib/initials";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
|
||||||
@@ -29,8 +31,8 @@ interface Mailbox {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
storageUsedMb: number;
|
usedBytes: number;
|
||||||
quotaMb: number;
|
quotaBytes: number;
|
||||||
mfaEnabled: boolean;
|
mfaEnabled: boolean;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -76,6 +78,8 @@ export default function AdminMailboxesPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<"mailboxes" | "aliases" | "csv">("mailboxes");
|
const [activeTab, setActiveTab] = useState<"mailboxes" | "aliases" | "csv">("mailboxes");
|
||||||
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
||||||
const [aliases, setAliases] = useState<Alias[]>([]);
|
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");
|
const [mfaPolicy, setMfaPolicy] = useState<"optional" | "required_admins" | "required_all">("required_admins");
|
||||||
|
|
||||||
// Create mailbox state
|
// Create mailbox state
|
||||||
@@ -111,6 +115,8 @@ export default function AdminMailboxesPage() {
|
|||||||
const [editLocale, setEditLocale] = useState("");
|
const [editLocale, setEditLocale] = useState("");
|
||||||
const [editAliases, setEditAliases] = useState<Alias[] | null>(null);
|
const [editAliases, setEditAliases] = useState<Alias[] | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [newAliasForUser, setNewAliasForUser] = useState("");
|
||||||
|
const [aliasBusy, setAliasBusy] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -125,6 +131,14 @@ export default function AdminMailboxesPage() {
|
|||||||
),
|
),
|
||||||
fetch("/api/v1/admin/aliases").then((r) => (r.ok ? r.json() : [])),
|
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;
|
const users = mb as PagedUsers;
|
||||||
setPageInfo({
|
setPageInfo({
|
||||||
total: users.total ?? 0,
|
total: users.total ?? 0,
|
||||||
@@ -137,8 +151,10 @@ export default function AdminMailboxesPage() {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
email: m.email_address,
|
email: m.email_address,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
storageUsedMb: Math.round(Number(m.used_bytes ?? 0) / 1048576),
|
// Kept as bytes. Rounding to whole megabytes here is what made a
|
||||||
quotaMb: Math.round(Number(m.quota_bytes ?? 0) / 1048576),
|
// 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),
|
mfaEnabled: Boolean(m.mfa_enrolled),
|
||||||
locked: !m.is_active,
|
locked: !m.is_active,
|
||||||
locale: m.locale ?? "",
|
locale: m.locale ?? "",
|
||||||
@@ -170,11 +186,20 @@ export default function AdminMailboxesPage() {
|
|||||||
const handleCreateMailbox = async (e: React.FormEvent) => {
|
const handleCreateMailbox = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
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", {
|
const res = await fetch("/api/v1/admin/mailboxes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
domain_id: newDomainId,
|
||||||
local_part: localPart,
|
local_part: localPart,
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
role: newRole,
|
role: newRole,
|
||||||
@@ -219,7 +244,7 @@ export default function AdminMailboxesPage() {
|
|||||||
const openUser = async (mb: Mailbox) => {
|
const openUser = async (mb: Mailbox) => {
|
||||||
setEditing(mb);
|
setEditing(mb);
|
||||||
setEditRole(mb.role);
|
setEditRole(mb.role);
|
||||||
setEditQuotaMb(mb.quotaMb);
|
setEditQuotaMb(Math.round(mb.quotaBytes / 1048576));
|
||||||
setEditLocale(mb.locale);
|
setEditLocale(mb.locale);
|
||||||
setEditAliases(null);
|
setEditAliases(null);
|
||||||
try {
|
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 () => {
|
const saveUser = async () => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -438,14 +511,33 @@ export default function AdminMailboxesPage() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="new-email"
|
id="new-email"
|
||||||
type="email"
|
type="text"
|
||||||
value={newEmail}
|
value={newEmail}
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
onChange={(e) => setNewEmail(e.target.value)}
|
||||||
placeholder="nova.conta@domain.com"
|
placeholder="nova.conta"
|
||||||
required
|
required
|
||||||
className={input}
|
className={input}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="min-w-[180px] flex-1">
|
||||||
<label htmlFor="new-mailbox-password" className={fieldLabel}>
|
<label htmlFor="new-mailbox-password" className={fieldLabel}>
|
||||||
{t("ui.newPassword")}
|
{t("ui.newPassword")}
|
||||||
@@ -470,9 +562,9 @@ export default function AdminMailboxesPage() {
|
|||||||
onChange={(e) => setNewRole(e.target.value)}
|
onChange={(e) => setNewRole(e.target.value)}
|
||||||
className={input}
|
className={input}
|
||||||
>
|
>
|
||||||
<option value="USER">USER</option>
|
<option value="USER">{roleLabel(t, "USER")}</option>
|
||||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
<option value="DOMAIN_ADMIN">{roleLabel(t, "DOMAIN_ADMIN")}</option>
|
||||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
<option value="SUPER_ADMIN">{roleLabel(t, "SUPER_ADMIN")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-[130px] flex-1">
|
<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"
|
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="">{t("ui.all")}</option>
|
||||||
<option value="USER">USER</option>
|
<option value="USER">{roleLabel(t, "USER")}</option>
|
||||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
<option value="DOMAIN_ADMIN">{roleLabel(t, "DOMAIN_ADMIN")}</option>
|
||||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
<option value="SUPER_ADMIN">{roleLabel(t, "SUPER_ADMIN")}</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
value={activeFilter}
|
value={activeFilter}
|
||||||
@@ -556,7 +648,7 @@ export default function AdminMailboxesPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Badge variant="neutral" className="whitespace-nowrap">
|
<Badge variant="neutral" className="whitespace-nowrap">
|
||||||
{mb.role}
|
{roleLabel(t, mb.role)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<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-1.5 flex-1 overflow-hidden rounded-full bg-dark-card">
|
||||||
<span
|
<span
|
||||||
className="block h-full rounded-full bg-indigo-500"
|
className="block h-full rounded-full bg-indigo-500"
|
||||||
style={{
|
style={{ width: `${usageRatio(mb.usedBytes, mb.quotaBytes) * 100}%` }}
|
||||||
width: `${Math.min((mb.storageUsedMb / (mb.quotaMb || 1)) * 100, 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="whitespace-nowrap text-[11.5px] tabular-nums text-slate-400">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -660,9 +751,9 @@ export default function AdminMailboxesPage() {
|
|||||||
onChange={(e) => setEditRole(e.target.value)}
|
onChange={(e) => setEditRole(e.target.value)}
|
||||||
className={input}
|
className={input}
|
||||||
>
|
>
|
||||||
<option value="USER">USER</option>
|
<option value="USER">{roleLabel(t, "USER")}</option>
|
||||||
<option value="DOMAIN_ADMIN">DOMAIN_ADMIN</option>
|
<option value="DOMAIN_ADMIN">{roleLabel(t, "DOMAIN_ADMIN")}</option>
|
||||||
<option value="SUPER_ADMIN">SUPER_ADMIN</option>
|
<option value="SUPER_ADMIN">{roleLabel(t, "SUPER_ADMIN")}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -700,12 +791,47 @@ export default function AdminMailboxesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 flex flex-col gap-1.5">
|
<ul className="mt-2 flex flex-col gap-1.5">
|
||||||
{editAliases.map((a) => (
|
{editAliases.map((a) => (
|
||||||
<li key={a.id} className="break-all font-mono text-[12px] text-slate-200">
|
<li
|
||||||
{a.aliasAddress}
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user