Delete people in the console, not the webmail's Administration
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:
1 parent
9e47437ef8
commit
5c08fb9fe9
12 files changed
+60
-63
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user