Admin Panel

Trash & Deleted Accounts

Every generated resource soft-deletes, which means a deleted row is still on disk. Two screens under System make it reachable: the bin, for records, and deleted accounts, for people. Both restore, both delete for good, and the bin expires what nobody came back for.

The bin

/system/trash, in the System group of the sidebar and on the System hub. It lists deleted rows grouped by resource, newest first:

  • A label for each row, read from the model's own display column (name, title, email, in that order of preference), with a couple of other fields beside it so one row is distinguishable from the next.
  • When it was deleted, and when it expires.
  • Restore, which clears deleted_at and puts the row back in every list that reads it.
  • Delete forever, which is a hard delete of that one row.
  • Empty, per resource, which hard-deletes everything currently in that resource's bin.

Retention is thirty days

A row deleted today is purged thirty days from today by a cron task, trash:purge_expired, which runs at 03:40. The page shows the date so nobody has to count. Nothing depends on anybody visiting the page: the table does not grow without bound whether or not the bin is ever opened.

// apps/api/internal/services/trash.go
const TrashRetentionDays = 30

Change the constant and the cron task, the expiry dates and the page copy all follow it.

How a resource gets into the bin

It already is. The bin reads the sync registry the generator populates, so grit generate resource Invoice puts invoices in the bin with no further step. There is nothing to register and nothing to opt into.

A model with no gorm.DeletedAt field cannot soft-delete, so it never appears. That is the one requirement, and the generator emits it by default.

Who can use it

Reading the bin is a system view: ADMIN or system.view. Restoring and purging are ADMIN and nothing less, and Empty additionally requires an explicit ?confirm=true on the request, so no stray click and no retried request empties a resource.

A resource's own delete permission says you may remove a row. It deliberately does not say you may undo somebody else's removal.

GET /api/admin/trash list the buckets and their counts
GET /api/admin/trash/:table the deleted rows of one resource
POST /api/admin/trash/:table/:id/restore clear deleted_at
DELETE /api/admin/trash/:table/:id hard-delete one row
DELETE /api/admin/trash/:table?confirm=true hard-delete the whole bucket

Deleted accounts

/system/deleted-accounts. Closing an account is a soft delete too, but it is a different question asked by a different person, so it is a different screen: who closed theirs, the role they had, the date, and restore or delete forever.

Restoring one lets that person sign in again with the password they already had.

Users are deliberately not in the sync registry the bin reads. A row that carries its own role is not something a generic restore should be writing, which is why these three endpoints live on the user handler and take ADMIN alone.

An administrator cannot close their own account

It is how an organisation locks itself out of its own admin panel, and the account that did it is the one that could have undone it. The API returns 403 with a message saying what to do instead: have another administrator remove it from the Users screen, or change the role first. The account page states this rather than offering a button that fails.

This is not erasure

Deleting an account for good removes the account row. What that person left behind across every other table stays where it is. Erasing that is a separate operation with different law attached to it, and it lives on the GDPR page, with an export, an erasure and a tamper-evident journal of both.

Existing projects

grit upgrade delivers both screens. The routes go into internal/routes/routes.go, which is your file, so the upgrade edits it in place and reports what it added. Each block is checked for on its own: a project that received the bin from an earlier run still receives the deleted accounts.

If the upgrade cannot find the lines it anchors on, because that file has been reorganised, it changes nothing and prints what to add. The routes are in the v3.378.0 changelog entry.