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

+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(() => {