Guide to the CRUD Approve plugin, including installation, approval storage resource setup, wiring protected resources, approval flow, reviewer access, and optional two-factor verification for approved changes.
The CRUD Approve plugin adds a manual approval step for changes made from the AdminForth back-office. Instead of applying create, edit, or delete operations immediately, selected resources can send these operations to an approval queue. A reviewer can then inspect the JSON diff and either approve or reject the pending change.
This is useful when several administrators can modify important records, but final responsibility for applying changes should stay with a smaller group of trusted users.
The plugin stores approval requests in a separate resource. Each request contains the target resource id, target record id, action type, request status, user who initiated the change, optional responder, old/new record data, and extra request metadata.
Getting Started will be used as the base for this example. The protected resource example below extends the aparts resource created in the Getting Started tutorial.
Install the plugin:
pnpm add @adminforth/crud-approve-plugin --saveTo import:
import CRUDApprovePlugin from '@adminforth/crud-approve-plugin';
import { AdminForthDataTypes, type AdminForthResourceInput } from 'adminforth';Note: An early draft of this plugin documentation used
@adminforth/crud-approve. The current package name is@adminforth/crud-approve-plugin, so the examples below use that package name.
First, add a table for manual approval requests. If you use the Prisma setup from the Getting Started tutorial, add the model below to schema.prisma:
model crud_manual_approve {
id String @id
record_id String?
resource_id String
action String
data Json
user_id String
responser_id String?
status Int @default(1)
created_at DateTime @default(now())
extra Json?
@@index([status, created_at])
@@index([resource_id])
}Then run a migration:
pnpm makemigration --name add-crud-manual-approve ; pnpm migrate:localThe status column uses numeric values:
1— pending2— approved3— rejected
Create crud_manual_approve.ts in the resources folder:
import CRUDApprovePlugin from '@adminforth/crud-approve-plugin';
import { AdminForthDataTypes, type AdminForthResourceInput } from 'adminforth';
export const crudApprovePlugin = new CRUDApprovePlugin({
resourceColumns: {
idColumnName: 'id',
recordIdColumnName: 'record_id',
resourceIdColumnName: 'resource_id',
actionColumnName: 'action',
dataColumnName: 'data',
userIdColumnName: 'user_id',
responserIdColumnName: 'responser_id',
statusColumnName: 'status',
createdAtColumnName: 'created_at',
extraColumnName: 'extra',
},
});
export default {
dataSource: 'maindb',
table: 'crud_manual_approve',
resourceId: 'crud_manual_approve',
label: 'CRUD approvals',
recordLabel: (record: any) => `${record.resource_id} / ${record.action}`,
columns: [
{
name: 'id',
primaryKey: true,
showIn: { list: false, show: true, edit: false, create: false },
},
{
name: 'record_id',
label: 'Record ID',
showIn: { all: true, edit: false, create: false },
},
{
name: 'resource_id',
label: 'Resource',
showIn: { all: true, edit: false, create: false },
},
{
name: 'action',
enum: [
{ value: 'create', label: 'Create' },
{ value: 'edit', label: 'Edit' },
{ value: 'delete', label: 'Delete' },
],
showIn: { all: true, edit: false, create: false },
},
{
name: 'data',
type: AdminForthDataTypes.JSON,
sortable: false,
showIn: { all: true, edit: false, create: false },
},
{
name: 'user_id',
label: 'Requested by',
foreignResource: {
resourceId: 'adminuser',
},
showIn: { all: true, edit: false, create: false },
},
{
name: 'responser_id',
label: 'Responded by',
foreignResource: {
resourceId: 'adminuser',
},
showIn: { all: true, edit: false, create: false },
},
{
name: 'status',
enum: [
{ value: 1, label: 'Pending' },
{ value: 2, label: 'Approved' },
{ value: 3, label: 'Rejected' },
],
showIn: { all: true, edit: false, create: false },
},
{
name: 'created_at',
type: AdminForthDataTypes.DATETIME,
allowMinMaxQuery: true,
showIn: { all: true, edit: false, create: false },
},
{
name: 'extra',
type: AdminForthDataTypes.JSON,
showIn: { all: false },
backendOnly: true,
},
],
options: {
listPageSize: 10,
allowedActions: {
create: false,
edit: false,
delete: false,
show: async ({ adminUser }: any) => {
return ['superadmin', 'reviewer'].includes(adminUser.dbUser.role);
},
filter: async ({ adminUser }: any) => {
return ['superadmin', 'reviewer'].includes(adminUser.dbUser.role);
},
},
},
plugins: [crudApprovePlugin],
} as AdminForthResourceInput;The plugin replaces the default rendering of the data column with a diff viewer on list and show pages. For pending requests, the viewer also renders Approve and Reject actions.
Note: Do not attach the approval interception hook to the
crud_manual_approveresource itself. This resource is the queue used by the plugin.
Import the approval resource in index.ts and add it to the AdminForth resource list and menu:
//diff-add
import crudManualApproveResource from './resources/crud_manual_approve.js';
export const admin = new AdminForth({
...
resources: [
...
//diff-add
crudManualApproveResource,
],
menu: [
...
//diff-add
{
//diff-add
label: 'Approvals',
//diff-add
icon: 'flowbite:clipboard-check-solid',
//diff-add
resourceId: 'crud_manual_approve',
//diff-add
},
],
});Now choose the resource whose changes should require review. The example below protects the aparts resource from the Getting Started tutorial. Users can submit changes, but these changes are stored as approval requests instead of being applied immediately.
Open resources/apartments.ts and add the helper and hooks below:
import { AdminForthDataTypes, type AdminForthResourceInput } from 'adminforth';
//diff-add
import { crudApprovePlugin } from './crud_manual_approve.js';
//diff-add
async function sendChangeToApproval({
//diff-add
resource,
//diff-add
action,
//diff-add
record,
//diff-add
updates,
//diff-add
oldRecord,
//diff-add
recordId,
//diff-add
adminUser,
//diff-add
extra,
//diff-add
}: any) {
//diff-add
// When the CRUD Approve plugin applies an already approved change,
//diff-add
// it marks the request with this flag. In that case we must not create
//diff-add
// another approval request, otherwise the operation would loop forever.
//diff-add
if (extra?.adminforth_plugin_crud_approve?.callingFromApprovalPlugin) {
//diff-add
return { ok: true };
//diff-add
}
//diff-add
//diff-add
const pkColumnName = resource.columns.find((column: any) => column.primaryKey)?.name || 'id';
//diff-add
const data = recordId ? { [pkColumnName]: recordId } : record;
//diff-add
//diff-add
const result = await crudApprovePlugin.createApprovalRequest({
//diff-add
resource,
//diff-add
action,
//diff-add
data,
//diff-add
user: adminUser,
//diff-add
record,
//diff-add
oldRecord,
//diff-add
updates,
//diff-add
extra,
//diff-add
});
//diff-add
//diff-add
if (result.error) {
//diff-add
return { ok: false, error: result.error };
//diff-add
}
//diff-add
//diff-add
// Stop the original mutation. The real create/edit/delete will be executed
//diff-add
// later only if a reviewer approves the request.
//diff-add
return { ok: true, error: "Action sent for manual approval" };
//diff-add
}
export default {
dataSource: 'maindb',
table: 'apartments',
resourceId: 'aparts',
label: 'Apartments',
...
columns: [
...
],
//diff-add
hooks: {
//diff-add
create: {
//diff-add
beforeSave: async (args: any) => {
//diff-add
return sendChangeToApproval({ ...args, action: 'create' });
//diff-add
},
//diff-add
},
//diff-add
edit: {
//diff-add
beforeSave: async (args: any) => {
//diff-add
return sendChangeToApproval({ ...args, action: 'edit' });
//diff-add
},
//diff-add
},
//diff-add
delete: {
//diff-add
beforeSave: async (args: any) => {
//diff-add
return sendChangeToApproval({ ...args, action: 'delete' });
//diff-add
},
//diff-add
},
//diff-add
},
options: {
listPageSize: 10,
allowedActions: {
create: true,
edit: true,
delete: true,
show: true,
filter: true,
},
},
} as AdminForthResourceInput;That is it. Now create, edit, and delete operations on aparts will create pending records in the CRUD approvals resource.
When a user changes a protected resource:
- The resource
beforeSavehook callscrudApprovePlugin.createApprovalRequest(...). - The original mutation is stopped.
- A pending approval request is stored in
crud_manual_approve. - A reviewer opens the
Approvalsresource and reviews the diff. - If the reviewer rejects the request, only the request status changes to
Rejected. - If the reviewer approves the request, the plugin applies the original create/edit/delete operation and changes the request status to
Approved.
The plugin also calls the protected resource hooks while applying approved changes. Because of that, the callingFromApprovalPlugin guard in the example above is important.
Who may approve is decided by the show permission of the approval resource itself: the plugin resolves options.allowedActions.show of the approval resource on every approve and reject request, and rejects the request with 403 if the user is not allowed to see the queue. So the allowedActions below is not only a UI matter, it is the actual approval permission and it is enforced on the backend:
options: {
allowedActions: {
create: false,
edit: false,
delete: false,
show: async ({ adminUser }: any) => {
return ['superadmin', 'reviewer'].includes(adminUser.dbUser.role);
},
filter: async ({ adminUser }: any) => {
return ['superadmin', 'reviewer'].includes(adminUser.dbUser.role);
},
},
},You can also use a stricter policy and allow only superadmin users to open the approval queue.
The show callback receives the usual { adminUser, resource, meta, source, adminforth } arguments, and for approve/reject calls meta contains the approval request being reviewed (meta.record) and its primary key (meta.pk). This allows granular policies, for example letting reviewers approve requests for some resources only:
show: async ({ adminUser, meta }: any) => {
if (adminUser.dbUser.role === 'superadmin') {
return true;
}
return adminUser.dbUser.role === 'reviewer' && meta?.record?.resource_id !== 'adminuser';
},On top of the show check, the plugin does not allow a user to approve or reject a request he created himself, so the four-eyes principle holds even if requester and reviewer share the same role. If you really need to allow self-approval (for example in a single-admin development setup), pass allowSelfApproval: true:
export const crudApprovePlugin = new CRUDApprovePlugin({
resourceColumns: { ... },
// do not use in production: makes the four-eyes principle void
allowSelfApproval: true,
});If your application already uses the Two-Factor Authentication plugin, you can verify the confirmation result before the approved change is applied. Frontend confirmation alone is not a security boundary, so the backend must still verify the confirmation result in a protected resource hook.
The approval request handler passes approval request data through extra.body. If your approval UI sends a 2FA confirmation result in meta.confirmationResult, you can verify it before allowing the approved operation to continue.
async function verifyApproval2FA({ adminUser, adminforth, extra }: any) {
if (!extra?.adminforth_plugin_crud_approve?.callingFromApprovalPlugin) {
return { ok: true };
}
const confirmationResult = extra?.body?.meta?.confirmationResult;
if (!confirmationResult) {
return { ok: false, error: 'Two-factor confirmation is required to approve this change' };
}
const t2fa = adminforth.getPluginByClassName('TwoFactorsAuthPlugin');
if (!t2fa) {
return { ok: false, error: 'TwoFactorsAuthPlugin is not configured' };
}
const verifyRes = await t2fa.verify(confirmationResult, {
adminUser,
userPk: adminUser.pk,
extra,
});
if (!verifyRes?.ok) {
return { ok: false, error: verifyRes?.error || 'Two-factor verification failed' };
}
return { ok: true };
}Then call this check before sendChangeToApproval in the same protected resource hooks:
hooks: {
edit: {
beforeSave: async (args: any) => {
const twoFaResult = await verifyApproval2FA(args);
if (twoFaResult.error) {
return twoFaResult;
}
return sendChangeToApproval({ ...args, action: 'edit' });
},
},
},For create and delete, use the same pattern and pass action: 'create' or action: 'delete'.
The plugin stores oldRecord and newRecord in the data JSON column. This makes the pending request easy to inspect in the diff viewer.
You can pass additional metadata through extra when creating the approval request:
const result = await crudApprovePlugin.createApprovalRequest({
resource,
action: 'edit',
data: { id: recordId },
user: adminUser,
oldRecord,
updates,
extra: {
...extra,
reason: 'Submitted from editorial workflow',
source: 'admin-panel',
},
});This is useful when you want to keep extra audit context, for example request source, UI flow, or a business reason entered by the user.
The same approval queue can be reused for multiple resources. Add the sendChangeToApproval hook to every resource that must be reviewed:
hooks: {
create: {
beforeSave: async (args: any) => sendChangeToApproval({ ...args, action: 'create' }),
},
edit: {
beforeSave: async (args: any) => sendChangeToApproval({ ...args, action: 'edit' }),
},
delete: {
beforeSave: async (args: any) => sendChangeToApproval({ ...args, action: 'delete' }),
},
},Each approval request stores its resource_id, so reviewers can see which resource the request belongs to.
Make sure your protected resource hook returns:
return { ok: false, error: null };This stops the original mutation and leaves the change pending for review.
Make sure sendChangeToApproval starts with the guard below:
if (extra?.adminforth_plugin_crud_approve?.callingFromApprovalPlugin) {
return { ok: true };
}Without this guard, the approved operation can be intercepted again by the same hook.
Check that:
- the plugin is installed on the approval resource;
dataColumnNamepoints to a JSON column;- the
resourceColumnsmapping matches real column names in the approval table.
Approve and reject controls are shown only for requests with status = 1.
The reviewer did not pass the canApprove check, or he is the author of the request himself. Both checks run on the backend for every approve/reject call. See Protect reviewer access.
Check options.allowedActions on the approval resource. The examples above allow only users with superadmin or reviewer role to show and filter approval requests.