Delete people in the console, not the webmail's Administration
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m8s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 34s

Deleting a person's account is the console's now, beside locking it and
legal holds: the console asks why, for the audit log, and says when a
hold keeps the data. Where Delete was, the account's page says so and
links to the account in the console when the server names one. Groups,
lists, domains and tenants keep their delete here.

2 new strings in all nine catalogs, unreviewed.
This commit is contained in:
jcoffey-dev committed 2026-09-27 19:32:33 -07:00
1 parent 9e47437ef8
commit 5c08fb9fe9
12 files changed
+60 -63

No files matched your search

+3
View File
@@ -1754,6 +1754,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Einschätzung des Sprachmodells", "Language model's opinion": "Einschätzung des Sprachmodells",
"One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat", "One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Löschen, Sperren und rechtliche Aufbewahrungspflichten werden in der Verwaltungskonsole erledigt, die den Grund festhält und bewahrt, was eine Aufbewahrungspflicht umfasst.",
"Open in the console": "In der Konsole öffnen",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "Sie haben keinen Zugriff mehr auf {name}. Zurück zu Ihren eigenen E-Mails.", "You no longer have access to {name}. Back to your own mail.": "Sie haben keinen Zugriff mehr auf {name}. Zurück zu Ihren eigenen E-Mails.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Sie können nicht als {name} senden. Das Konto wurde Ihnen zum Lesen übergeben, nicht zum Senden.", "You can't send from {name}. It was handed to you to read, not to send as.": "Sie können nicht als {name} senden. Das Konto wurde Ihnen zum Lesen übergeben, nicht zum Senden.",
+3
View File
@@ -1727,6 +1727,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Opinión del modelo de lenguaje", "Language model's opinion": "Opinión del modelo de lenguaje",
"One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta", "One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Eliminar, bloquear y las retenciones legales se gestionan en la consola de administración, que registra el motivo y conserva lo que cubre una retención.",
"Open in the console": "Abrir en la consola",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "Ya no tiene acceso a {name}. Vuelve a su propio correo.", "You no longer have access to {name}. Back to your own mail.": "Ya no tiene acceso a {name}. Vuelve a su propio correo.",
"You can't send from {name}. It was handed to you to read, not to send as.": "No puede enviar desde {name}. Se le entregó para leerla, no para enviar en su nombre.", "You can't send from {name}. It was handed to you to read, not to send as.": "No puede enviar desde {name}. Se le entregó para leerla, no para enviar en su nombre.",
+3
View File
@@ -1732,6 +1732,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Avis du modèle de langage", "Language model's opinion": "Avis du modèle de langage",
"One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam", "One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "La suppression, le verrouillage et les conservations légales se font dans la console d’administration, qui enregistre le motif et conserve ce qu’une conservation couvre.",
"Open in the console": "Ouvrir dans la console",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "Vous n’avez plus accès à {name}. Retour à votre propre courrier.", "You no longer have access to {name}. Back to your own mail.": "Vous n’avez plus accès à {name}. Retour à votre propre courrier.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Vous ne pouvez pas envoyer depuis {name}. Ce compte vous a été confié pour le lire, pas pour envoyer en son nom.", "You can't send from {name}. It was handed to you to read, not to send as.": "Vous ne pouvez pas envoyer depuis {name}. Ce compte vous a été confié pour le lire, pas pour envoyer en son nom.",
+3
View File
@@ -1735,6 +1735,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "言語モデルの見解", "Language model's opinion": "言語モデルの見解",
"One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ", "One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "削除、ロック、訴訟ホールドは管理コンソールで行います。コンソールでは理由が記録され、ホールドの対象は保持されます。",
"Open in the console": "コンソールで開く",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "{name} にアクセスできなくなりました。自分のメールに戻ります。", "You no longer have access to {name}. Back to your own mail.": "{name} にアクセスできなくなりました。自分のメールに戻ります。",
"You can't send from {name}. It was handed to you to read, not to send as.": "{name} から送信することはできません。このアカウントは閲覧のために委任されています。", "You can't send from {name}. It was handed to you to read, not to send as.": "{name} から送信することはできません。このアカウントは閲覧のために委任されています。",
+3
View File
@@ -1727,6 +1727,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Oordeel van het taalmodel", "Language model's opinion": "Oordeel van het taalmodel",
"One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen", "One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Verwijderen, vergrendelen en juridische bewaarplichten gebeuren in de beheerconsole, die de reden vastlegt en bewaart wat onder een bewaarplicht valt.",
"Open in the console": "Openen in de console",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "U hebt geen toegang meer tot {name}. Terug naar uw eigen mail.", "You no longer have access to {name}. Back to your own mail.": "U hebt geen toegang meer tot {name}. Terug naar uw eigen mail.",
"You can't send from {name}. It was handed to you to read, not to send as.": "U kunt niet verzenden vanuit {name}. Het account is u gegeven om te lezen, niet om namens te verzenden.", "You can't send from {name}. It was handed to you to read, not to send as.": "U kunt niet verzenden vanuit {name}. Het account is u gegeven om te lezen, niet om namens te verzenden.",
+3
View File
@@ -1730,6 +1730,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Opinião do modelo de linguagem", "Language model's opinion": "Opinião do modelo de linguagem",
"One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam", "One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Excluir, bloquear e retenções legais são feitos no console de administração, que registra o motivo e preserva o que uma retenção abrange.",
"Open in the console": "Abrir no console",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "Você não tem mais acesso a {name}. De volta ao seu próprio e-mail.", "You no longer have access to {name}. Back to your own mail.": "Você não tem mais acesso a {name}. De volta ao seu próprio e-mail.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Você não pode enviar de {name}. A conta foi entregue a você para leitura, não para enviar em nome dela.", "You can't send from {name}. It was handed to you to read, not to send as.": "Você não pode enviar de {name}. A conta foi entregue a você para leitura, não para enviar em nome dela.",
+3
View File
@@ -1729,6 +1729,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Мнение языковой модели", "Language model's opinion": "Мнение языковой модели",
"One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр", "One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Удаление, блокировка и юридическое удержание выполняются в консоли администрирования: она записывает причину и сохраняет всё, что охватывает удержание.",
"Open in the console": "Открыть в консоли",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "У вас больше нет доступа к {name}. Возвращаемся к вашей почте.", "You no longer have access to {name}. Back to your own mail.": "У вас больше нет доступа к {name}. Возвращаемся к вашей почте.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Вы не можете отправлять письма от имени {name}. Учётная запись передана вам для чтения, а не для отправки.", "You can't send from {name}. It was handed to you to read, not to send as.": "Вы не можете отправлять письма от имени {name}. Учётная запись передана вам для чтения, а не для отправки.",
+3
View File
@@ -1723,6 +1723,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Думка мовної моделі", "Language model's opinion": "Думка мовної моделі",
"One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр", "One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Видалення, блокування та юридичне утримання виконуються в консолі адміністрування: вона записує причину й зберігає все, що охоплює утримання.",
"Open in the console": "Відкрити в консолі",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "У вас більше немає доступу до {name}. Повертаємося до вашої пошти.", "You no longer have access to {name}. Back to your own mail.": "У вас більше немає доступу до {name}. Повертаємося до вашої пошти.",
"You can't send from {name}. It was handed to you to read, not to send as.": "Ви не можете надсилати листи від імені {name}. Обліковий запис передано вам для читання, а не для надсилання.", "You can't send from {name}. It was handed to you to read, not to send as.": "Ви не можете надсилати листи від імені {name}. Обліковий запис передано вам для читання, а не для надсилання.",
+3
View File
@@ -1734,6 +1734,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ────────── // ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "语言模型的判断", "Language model's opinion": "语言模型的判断",
"One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一", "One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "删除、锁定和法律保留均在管理控制台中进行,控制台会记录原因,并保留保留范围内的内容。",
"Open in the console": "在控制台中打开",
// inbuxa AL-7, AL-8: a locked account handed to the reader // inbuxa AL-7, AL-8: a locked account handed to the reader
"You no longer have access to {name}. Back to your own mail.": "您已无法访问 {name}。已返回您自己的邮件。", "You no longer have access to {name}. Back to your own mail.": "您已无法访问 {name}。已返回您自己的邮件。",
"You can't send from {name}. It was handed to you to read, not to send as.": "您不能从 {name} 发送邮件。该账户交给您是为了阅读,而不是代其发送。", "You can't send from {name}. It was handed to you to read, not to send as.": "您不能从 {name} 发送邮件。该账户交给您是为了阅读,而不是代其发送。",
+17 -56
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Copy, Dices, KeyRound, Lock, Plus, Trash2, X } from "lucide-react"; import { Copy, Dices, ExternalLink, KeyRound, Lock, Plus, X } from "lucide-react";
import { import {
ADMIN_BASELINE, ADMIN_BASELINE,
can, can,
@@ -12,7 +12,6 @@ import {
aliasList, aliasList,
createAccount, createAccount,
describeDirectoryError, describeDirectoryError,
destroyAccount,
hasPassword, hasPassword,
passwordPatch, passwordPatch,
quotasWithDisk, quotasWithDisk,
@@ -25,7 +24,7 @@ import { formatSize } from "@/lib/format";
import { t, tNode } from "@/lib/i18n"; import { t, tNode } from "@/lib/i18n";
import { Link } from "wouter"; import { Link } from "wouter";
import { Avatar } from "@/ui/misc"; import { Avatar } from "@/ui/misc";
import { Dialog } from "@/ui/dialog"; import { useSession } from "@/store/session";
import { toast } from "@/ui/toast"; import { toast } from "@/ui/toast";
import { isSelf, roleName, type DirectoryContext } from "./directoryContext"; import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
import { usePermissions } from "./usePermissions"; import { usePermissions } from "./usePermissions";
@@ -39,7 +38,6 @@ interface Props {
onClose: () => void; onClose: () => void;
onChanged: () => void; onChanged: () => void;
onCreated: (id: string) => void; onCreated: (id: string) => void;
onDeleted: () => void;
} }
/** A role as one select value: "User", "Admin", or "custom:<ids>". */ /** A role as one select value: "User", "Admin", or "custom:<ids>". */
@@ -71,7 +69,7 @@ const bytesOf = (gib: string) => {
* a password and a delete are their own calls, because each is a decision of * a password and a delete are their own calls, because each is a decision of
* its own and should never ride along with a renamed display name. * its own and should never ride along with a renamed display name.
*/ */
export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDeleted }: Props) { export function AccountSheet({ account, ctx, onClose, onChanged, onCreated }: Props) {
const perms = usePermissions(); const perms = usePermissions();
const creating = account === null; const creating = account === null;
const self = account ? isSelf(account, ctx) : false; const self = account ? isSelf(account, ctx) : false;
@@ -288,9 +286,7 @@ export function AccountSheet({ account, ctx, onClose, onChanged, onCreated, onDe
{error && <p className="admin-notice error" role="alert">{error}</p>} {error && <p className="admin-notice error" role="alert">{error}</p>}
{!creating && can(perms, "Account", "Destroy") && ( {!creating && can(perms, "Account", "Destroy") && <DeleteInConsole accountId={account.id} />}
<DeleteAccount account={account} blocked={self ? t("You can't delete the account you're signed in with.") : locked ? t("This account has permissions yours doesn't.") : null} onDeleted={onDeleted} />
)}
</div> </div>
{editable && ( {editable && (
@@ -433,59 +429,24 @@ export function Aliases({ aliases, setAliases, editable, domains, defaultDomain,
); );
} }
function DeleteAccount({ account, blocked, onDeleted }: { account: DirectoryAccount; blocked: string | null; onDeleted: () => void }) { /**
const [open, setOpen] = useState(false); * inbuxa: deleting a person's account is the console's, beside locking it
const [typed, setTyped] = useState(""); * and legal holds: it asks why, for the audit log, and says when a hold
const [busy, setBusy] = useState(false); * keeps the data. Only the pointer is here.
const [error, setError] = useState<string | null>(null); */
const address = account.emailAddress ?? account.name; function DeleteInConsole({ accountId }: { accountId: string }) {
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
return ( return (
<> <>
<h3>{t("Delete")}</h3> <h3>{t("Delete")}</h3>
<div className="admin-danger"> <div className="admin-danger">
<p>{blocked ?? t("Deletes the mailbox and everything in it.")}</p> <p>{t("Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}> {adminUrl && (
<Trash2 size={14} /> {t("Delete account…")} <a className="btn btn-sm" href={`${adminUrl.replace(/\/$/, "")}/Management/x:Account/User/${accountId}`} target="_blank" rel="noopener noreferrer">
</button> <ExternalLink size={14} /> {t("Open in the console")}
</a>
)}
</div> </div>
<Dialog
open={open}
onClose={() => setOpen(false)}
title={t("Delete {address}?", { address })}
size="sm"
footer={
<>
<button className="btn" onClick={() => setOpen(false)}>{t("Cancel")}</button>
<button
className="btn btn-danger"
disabled={busy || typed.trim().toLowerCase() !== address.toLowerCase()}
onClick={async () => {
setBusy(true);
setError(null);
try {
await destroyAccount(account.id);
toast.success(t("Deleted {address}", { address }));
setOpen(false);
onDeleted();
} catch (err) {
setError(describeDirectoryError(err));
} finally {
setBusy(false);
}
}}
>
{t("Delete account")}
</button>
</>
}
>
<p style={{ marginTop: 0 }}>{t("This deletes the mail, calendars, contacts and files in this account. The server removes them in the background, and it can't be undone.")}</p>
<div className="field">
<label htmlFor="admin-delete-confirm">{t("Type {address} to confirm", { address })}</label>
<input id="admin-delete-confirm" className="input notranslate" translate="no" value={typed} autoComplete="off" spellCheck={false} onChange={(e) => setTyped(e.target.value)} />
</div>
{error && <p className="admin-notice error" role="alert">{error}</p>}
</Dialog>
</> </>
); );
} }
-4
View File
@@ -217,10 +217,6 @@ export function AccountsAdmin({ selectedId }: { selectedId?: string }) {
changed(); changed();
navigate(`/admin/accounts/${id}`); navigate(`/admin/accounts/${id}`);
}} }}
onDeleted={() => {
changed();
close();
}}
/> />
)} )}
</div> </div>
@@ -48,7 +48,7 @@ describe("the account sheet", () => {
await act(async () => { await act(async () => {
root.render( root.render(
<Router hook={hook}> <Router hook={hook}>
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} /> <AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} />
</Router>, </Router>,
); );
}); });
@@ -89,7 +89,20 @@ describe("the account sheet", () => {
expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull(); expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull();
expect(button(host, "Set a new password")).toBeUndefined(); expect(button(host, "Set a new password")).toBeUndefined();
expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true); expect((host.querySelector('select[aria-label="Role"]') as HTMLSelectElement).disabled).toBe(true);
expect(button(host, "Delete account")?.disabled).toBe(true); expect(host.textContent).not.toContain("Delete account");
expect(host.textContent).toContain("administration console");
});
it("sends deleting a person to the console, opened on that account (inbuxa)", async () => {
signIn([...HELPDESK, "sysAccountDestroy"]);
const s = useSession.getState().session!;
useSession.setState({
session: { ...s, ihasmail: { ...s.ihasmail, server: { ...(s.ihasmail?.server ?? {}), adminUrl: "https://admin.example.com/" } } } as never,
});
await render(account({ id: "k7" }));
expect(host.textContent).not.toContain("Delete account");
const link = host.querySelector('a[href="https://admin.example.com/Management/x:Account/User/k7"]');
expect(link?.textContent).toContain("Open in the console");
}); });
}); });
@@ -108,7 +121,7 @@ describe("an account's tenant", () => {
const render = async (a: DirectoryAccount) => { const render = async (a: DirectoryAccount) => {
const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` }); const { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
await act(async () => { await act(async () => {
root.render(<Router hook={hook}><AccountSheet account={a} ctx={tenantCtx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} /></Router>); root.render(<Router hook={hook}><AccountSheet account={a} ctx={tenantCtx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} /></Router>);
}); });
}; };
beforeEach(() => { beforeEach(() => {