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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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