Delete people in the console, not the webmail's Administration #27

Merged
jcoffey-dev merged 1 commits from feature/delete-in-console into main 2026-09-28 02:34:53 +00:00
12 changed files with 60 additions and 63 deletions

No files matched your search

+3
View File
@@ -1754,6 +1754,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"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",
// 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
"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.",
+3
View File
@@ -1727,6 +1727,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"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",
// 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
"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.",
+3
View File
@@ -1732,6 +1732,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"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",
// 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
"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.",
+3
View File
@@ -1735,6 +1735,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "言語モデルの見解",
"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
"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} から送信することはできません。このアカウントは閲覧のために委任されています。",
+3
View File
@@ -1727,6 +1727,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"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",
// 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
"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.",
+3
View File
@@ -1730,6 +1730,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"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",
// 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
"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.",
+3
View File
@@ -1729,6 +1729,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Мнение языковой модели",
"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
"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}. Учётная запись передана вам для чтения, а не для отправки.",
+3
View File
@@ -1723,6 +1723,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Думка мовної моделі",
"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
"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}. Обліковий запис передано вам для читання, а не для надсилання.",
+3
View File
@@ -1734,6 +1734,9 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "语言模型的判断",
"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
"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} 发送邮件。该账户交给您是为了阅读,而不是代其发送。",
+17 -56
View File
@@ -1,5 +1,5 @@
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 {
ADMIN_BASELINE,
can,
@@ -12,7 +12,6 @@ import {
aliasList,
createAccount,
describeDirectoryError,
destroyAccount,
hasPassword,
passwordPatch,
quotasWithDisk,
@@ -25,7 +24,7 @@ import { formatSize } from "@/lib/format";
import { t, tNode } from "@/lib/i18n";
import { Link } from "wouter";
import { Avatar } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { useSession } from "@/store/session";
import { toast } from "@/ui/toast";
import { isSelf, roleName, type DirectoryContext } from "./directoryContext";
import { usePermissions } from "./usePermissions";
@@ -39,7 +38,6 @@ interface Props {
onClose: () => void;
onChanged: () => void;
onCreated: (id: string) => void;
onDeleted: () => void;
}
/** 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
* 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 creating = account === null;
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>}
{!creating && can(perms, "Account", "Destroy") && (
<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} />
)}
{!creating && can(perms, "Account", "Destroy") && <DeleteInConsole accountId={account.id} />}
</div>
{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);
const [typed, setTyped] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const address = account.emailAddress ?? account.name;
/**
* inbuxa: deleting a person's account is the console's, beside locking it
* and legal holds: it asks why, for the audit log, and says when a hold
* keeps the data. Only the pointer is here.
*/
function DeleteInConsole({ accountId }: { accountId: string }) {
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
return (
<>
<h3>{t("Delete")}</h3>
<div className="admin-danger">
<p>{blocked ?? t("Deletes the mailbox and everything in it.")}</p>
<button className="btn btn-sm admin-danger-btn" disabled={!!blocked} onClick={() => { setTyped(""); setError(null); setOpen(true); }}>
<Trash2 size={14} /> {t("Delete account…")}
</button>
<p>{t("Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.")}</p>
{adminUrl && (
<a className="btn btn-sm" href={`${adminUrl.replace(/\/$/, "")}/Management/x:Account/User/${accountId}`} target="_blank" rel="noopener noreferrer">
<ExternalLink size={14} /> {t("Open in the console")}
</a>
)}
</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();
navigate(`/admin/accounts/${id}`);
}}
onDeleted={() => {
changed();
close();
}}
/>
)}
</div>
@@ -48,7 +48,7 @@ describe("the account sheet", () => {
await act(async () => {
root.render(
<Router hook={hook}>
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} onDeleted={() => {}} />
<AccountSheet account={a} ctx={ctx} onClose={() => {}} onChanged={() => {}} onCreated={() => {}} />
</Router>,
);
});
@@ -89,7 +89,20 @@ describe("the account sheet", () => {
expect(host.querySelector('a[href="/settings/security"]')).not.toBeNull();
expect(button(host, "Set a new password")).toBeUndefined();
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 { hook } = memoryLocation({ path: `/admin/accounts/${a.id}` });
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(() => {