feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were hard and unrecoverable. Now they soft-delete into a recycle bin. Schema (migration 0012): nullable deleted_at + deleted_by on users, roles, subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified against a copy of the live DB. Backend: each resource's DELETE route STAMPS instead of removing; every catalog list filters deleted_at IS NULL. New recycle-bin module + routes (GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a new recyclebin:read/update/delete permission. A 6-hourly + startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever). Invariants: soft-deleted users can't log in (login rejects deleted_at; no-lockout counts live admins only); a soft-deleted subscription doesn't open the barrier; plans are versioned so a delete stamps all versions of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role (guard resolves missing role to empty perms). The signed append-only ledger is OUT of scope (no delete path). Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge + purge confirm; api client + i18n (sq + en parity). Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4 integration: delete -> can't-login -> restore -> login, purge, gating, 409 reuse). server 103/103; build+lint+test 19/19. Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
can,
|
||||
fetchRecycleBin,
|
||||
purgeRecycleItem,
|
||||
restoreRecycleItem,
|
||||
type RecycleBinItem,
|
||||
type RecycleKind,
|
||||
type SessionUser,
|
||||
} from "./api.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { formatRelativeDateTime } from "./lib/format.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Recycle bin — the way back from an accidental delete. Lists everything soft-deleted
|
||||
// across users/roles/subscriptions/plans/tariffs; an admin can Restore (back to its
|
||||
// catalog) or Purge (permanent). Items auto-purge after the retention window. Gated by
|
||||
// recyclebin:* (read to view, update to restore, delete to purge). See
|
||||
// apps/server/src/recycle-bin.ts, wiki/concepts/soft-delete.md.
|
||||
|
||||
const KIND_KEY: Record<RecycleKind, string> = {
|
||||
user: "recycleBin.kind.user",
|
||||
role: "recycleBin.kind.role",
|
||||
subscription: "recycleBin.kind.subscription",
|
||||
plan: "recycleBin.kind.plan",
|
||||
tariff: "recycleBin.kind.tariff",
|
||||
};
|
||||
|
||||
export function RecycleBin({ user }: { user: SessionUser | null }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const binQ = useQuery({ queryKey: qk.recycleBin, queryFn: fetchRecycleBin });
|
||||
|
||||
const canRestore = can(user, "recyclebin:update");
|
||||
const canPurge = can(user, "recyclebin:delete");
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [purging, setPurging] = useState<RecycleBinItem | null>(null);
|
||||
|
||||
const onError = (e: unknown) => setError(e instanceof ApiError ? e.message : (e as Error).message);
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.recycleBin });
|
||||
// A restore/purge can change any catalog — refresh the ones a restore touches.
|
||||
for (const key of [["users"], ["roles"], ["subscriptions"], ["subscription-plans"], ["tariff"]]) {
|
||||
void qc.invalidateQueries({ queryKey: key });
|
||||
}
|
||||
};
|
||||
|
||||
const restoreM = useMutation({
|
||||
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => restoreRecycleItem(kind, id),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
});
|
||||
const purgeM = useMutation({
|
||||
mutationFn: ({ kind, id }: { kind: RecycleKind; id: string }) => purgeRecycleItem(kind, id),
|
||||
onSuccess: () => {
|
||||
setPurging(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
setPurging(null);
|
||||
onError(e);
|
||||
},
|
||||
});
|
||||
|
||||
const items = binQ.data?.items ?? [];
|
||||
const retentionDays = binQ.data?.retentionDays ?? 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<h1 className="text-base font-bold uppercase tracking-widest text-term-amber">
|
||||
{t("recycleBin.title")}
|
||||
</h1>
|
||||
{retentionDays > 0 && (
|
||||
<span className="text-[12px] text-term-muted">
|
||||
{t("recycleBin.retentionNote", { days: retentionDays })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-2 text-[12px] text-term-red">{error}</p>}
|
||||
{binQ.isLoading && <p className="text-term-muted">{t("common.loading")}</p>}
|
||||
|
||||
{!binQ.isLoading && items.length === 0 ? (
|
||||
<p className="rounded-term border border-term-border bg-term-panel p-6 text-center text-term-muted">
|
||||
{t("recycleBin.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b border-term-border text-left text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.type")}</th>
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.item")}</th>
|
||||
<th className="py-1.5 pr-3">{t("recycleBin.col.deleted")}</th>
|
||||
<th className="py-1.5 text-right">{t("recycleBin.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
<tr key={`${it.kind}:${it.id}`} className="border-b border-term-border/50">
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className="rounded-term border border-term-border px-1.5 py-0.5 text-[11px] text-term-muted">
|
||||
{t(KIND_KEY[it.kind])}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3 text-term-text">{it.label}</td>
|
||||
<td className="py-1.5 pr-3 text-term-muted">
|
||||
{formatRelativeDateTime(it.deletedAt, t)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{canRestore && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={restoreM.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
restoreM.mutate({ kind: it.kind, id: it.id });
|
||||
}}
|
||||
>
|
||||
{t("recycleBin.restore")}
|
||||
</button>
|
||||
)}
|
||||
{canPurge && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost ml-1 text-term-red"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setPurging(it);
|
||||
}}
|
||||
>
|
||||
{t("recycleBin.purge")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{purging && (
|
||||
<Modal open onClose={() => setPurging(null)} title={t("recycleBin.purgeConfirmTitle")}>
|
||||
<p className="text-[13px] text-term-text">
|
||||
{t("recycleBin.purgeConfirmBody", { label: purging.label })}
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-term-red">{t("recycleBin.purgeIrreversible")}</p>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setPurging(null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
disabled={purgeM.isPending}
|
||||
onClick={() => purgeM.mutate({ kind: purging.kind, id: purging.id })}
|
||||
>
|
||||
{t("recycleBin.purge")}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user