Skip to content

Commit 0d6ee8e

Browse files
committed
dev-demo: setup crud-approve plugin on dev-demo
AdminForth/1897/show-view-column-nama-renderer
1 parent 8431c5d commit 0d6ee8e

6 files changed

Lines changed: 222 additions & 4 deletions

File tree

dev-demo/Taskfile.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ vars:
3636
- "adminforth-dashboard"
3737
- "adminforth-json-editor"
3838
- "adminforth-json-form"
39+
- "af-crud-approve-plugin"
3940

4041
ADAPTERS:
4142
- "adminforth-email-adapter-aws-ses"

dev-demo/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import carsDescriptionImage from './resources/cars_description_image.js';
3333
import translations from "./resources/translations.js";
3434
import adminExternalIdentitiesResource from './resources/adminUserExternalIdentities.js';
3535
import key_value_resource from './resources/key_value_resource.js';
36+
import crudManualApproveResource from './resources/crud_manual_approve.js';
3637

3738
import { logger } from '../adminforth/modules/logger.js';
3839

@@ -163,6 +164,7 @@ export const admin = new AdminForth({
163164
dashboardConfigsResource,
164165
adminExternalIdentitiesResource,
165166
key_value_resource,
167+
crudManualApproveResource,
166168
],
167169
menu: [
168170
{ type: 'heading', label: 'SYSTEM' },
@@ -261,6 +263,11 @@ export const admin = new AdminForth({
261263
label: 'Key-Value Store',
262264
icon: 'material-symbols:key',
263265
resourceId: 'key_values',
266+
},
267+
{
268+
label: 'Approvals',
269+
icon: 'flowbite:clipboard-check-solid',
270+
resourceId: 'crud_manual_approve',
264271
}
265272
],
266273
globalPlugins: globalPlugins,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- CreateTable
2+
CREATE TABLE "crud_manual_approve" (
3+
"id" TEXT NOT NULL PRIMARY KEY,
4+
"record_id" TEXT,
5+
"resource_id" TEXT NOT NULL,
6+
"action" TEXT NOT NULL,
7+
"data" JSONB NOT NULL,
8+
"user_id" TEXT NOT NULL,
9+
"responser_id" TEXT,
10+
"status" INTEGER NOT NULL DEFAULT 1,
11+
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
12+
"extra" JSONB
13+
);
14+
15+
-- CreateIndex
16+
CREATE INDEX "crud_manual_approve_status_created_at_idx" ON "crud_manual_approve"("status", "created_at");
17+
18+
-- CreateIndex
19+
CREATE INDEX "crud_manual_approve_resource_id_idx" ON "crud_manual_approve"("resource_id");

dev-demo/migrations/prisma/sqlite/schema.prisma

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,4 +203,20 @@ model key_values {
203203
value String
204204
collection String?
205205
expire_at DateTime?
206-
}
206+
}
207+
208+
model crud_manual_approve {
209+
id String @id
210+
record_id String?
211+
resource_id String
212+
action String
213+
data Json
214+
user_id String
215+
responser_id String?
216+
status Int @default(1)
217+
created_at DateTime @default(now())
218+
extra Json?
219+
220+
@@index([status, created_at])
221+
@@index([resource_id])
222+
}

dev-demo/resources/adminuser.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,49 @@ import OAuthPlugin from './configs/oauthPluginConfig.js';
1111
import TwoFactorsAuthPlugin from './configs/twoFactorAuthPluginConfig.js';
1212
import EmailInvitePlugin from '../../plugins/adminforth-email-invite/index.js';
1313
import EmailPasswordResetPlugin from '../../plugins/adminforth-email-password-reset/index.js';
14+
import { crudApprovePlugin } from './crud_manual_approve.js';
1415

1516
async function allowedForSuperAdmin({ adminUser }: { adminUser: AdminUser }): Promise<boolean> {
1617
return adminUser.dbUser.role === 'superadmin';
1718
}
1819

20+
/**
21+
* Sends the mutation to the CRUD approve queue instead of applying it right away.
22+
* Returns `{ ok: true }` when the change must proceed normally: either the plugin
23+
* itself is re-applying an already approved change, or the request was queued and
24+
* the caller should stop.
25+
*/
26+
async function sendChangeToApproval({
27+
resource, action, record, updates, oldRecord, recordId, adminUser, extra,
28+
}: any) {
29+
// when the plugin applies an approved change it marks the call with this flag,
30+
// otherwise we would queue the very same change again, forever
31+
if (extra?.adminforth_plugin_crud_approve?.callingFromApprovalPlugin) {
32+
return { ok: true };
33+
}
34+
35+
const pkColumnName = resource.columns.find((c: AdminForthResourceColumn) => c.primaryKey)?.name || 'id';
36+
const data = recordId ? { [pkColumnName]: recordId } : record;
37+
38+
const result = await crudApprovePlugin.createApprovalRequest({
39+
resource,
40+
action,
41+
data,
42+
user: adminUser,
43+
record,
44+
oldRecord,
45+
updates,
46+
extra,
47+
});
48+
console.log('sendChangeToApproval result', result);
49+
if (result.error) {
50+
return { ok: false, error: result.error };
51+
}
52+
53+
// stop the original mutation, it will be executed only if a reviewer approves it
54+
return { ok: true, error: 'Action sent for manual approval', redirectTo: '/adminuser' };
55+
}
56+
1957
const fakeEmailAdapter = {
2058
validate: async () => {
2159
// Implement validation logic if needed
@@ -197,27 +235,46 @@ export default {
197235
userResetTokensKeyValueAdapter: new KeyValueAdapterRam(),
198236
expectedOrigin: process.env.RESET_PASSWORD_ORIGIN || 'http://localhost:3123',
199237
}),
200-
],
238+
// each plugin repo installs its own copy of adminforth (and of @types/express
239+
// through it), so their IAdminForthPlugin is a different type identity than the
240+
// one of the core we import from source here. Cast only the plugins array, so
241+
// the rest of the resource is still checked against AdminForthResourceInput
242+
] as any,
201243
hooks: {
202244
create: {
203-
beforeSave: async ({ record, adminUser, resource }: { record: any, adminUser: AdminUser, resource: AdminForthResource }) => {
245+
beforeSave: async (args: any) => {
246+
const approval = await sendChangeToApproval({ ...args, action: 'create' });
247+
if (approval.error) {
248+
return approval;
249+
}
250+
const { record }: { record: any } = args;
204251
if (record.password) {
205252
record.password_hash = await AdminForth.Utils.generatePasswordHash(record.password);
206253
}
207254
return { ok: true };
208255
}
209256
},
210257
edit: {
211-
beforeSave: async ({ oldRecord, updates, adminUser, resource }: { oldRecord: any, updates: any, adminUser: AdminUser, resource: AdminForthResource }) => {
258+
beforeSave: async (args: any) => {
259+
const { oldRecord, updates, adminUser }: { oldRecord: any, updates: any, adminUser: AdminUser } = args;
212260
logger.info('Updating user', updates);
213261
if (oldRecord.id === adminUser.dbUser.id && updates.role) {
214262
return { ok: false, error: 'You cannot change your own role' };
215263
}
264+
const approval = await sendChangeToApproval({ ...args, action: 'edit' });
265+
if (approval.error) {
266+
return approval;
267+
}
216268
if (updates.password) {
217269
updates.password_hash = await AdminForth.Utils.generatePasswordHash(updates.password);
218270
}
219271
return { ok: true }
220272
},
221273
},
274+
delete: {
275+
beforeSave: async (args: any) => {
276+
return sendChangeToApproval({ ...args, action: 'delete' });
277+
},
278+
},
222279
},
223280
} as AdminForthResourceInput;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import CRUDApprovePlugin from '../../plugins/af-crud-approve-plugin/index.js';
2+
import { AdminForthDataTypes } from '../../adminforth/index.js';
3+
import type { AdminForthResourceInput, AdminUser } from '../../adminforth/index.js';
4+
5+
async function allowedForSuperAdmin({ adminUser }: { adminUser: AdminUser }): Promise<boolean> {
6+
return adminUser.dbUser.role === 'superadmin';
7+
}
8+
9+
export const crudApprovePlugin = new CRUDApprovePlugin({
10+
resourceColumns: {
11+
idColumnName: 'id',
12+
recordIdColumnName: 'record_id',
13+
resourceIdColumnName: 'resource_id',
14+
actionColumnName: 'action',
15+
dataColumnName: 'data',
16+
userIdColumnName: 'user_id',
17+
responserIdColumnName: 'responser_id',
18+
statusColumnName: 'status',
19+
createdAtColumnName: 'created_at',
20+
extraColumnName: 'extra',
21+
},
22+
// dev-demo is usually used with a single superadmin login, so allow the author
23+
// of the request to approve it himself. Never do this in production: it makes
24+
// the four-eyes principle void.
25+
allowSelfApproval: true,
26+
});
27+
28+
export default {
29+
dataSource: 'sqlite',
30+
table: 'crud_manual_approve',
31+
resourceId: 'crud_manual_approve',
32+
label: 'CRUD approvals',
33+
recordLabel: (r: any) => `${r.resource_id} / ${r.action}`,
34+
columns: [
35+
{
36+
name: 'id',
37+
primaryKey: true,
38+
showIn: { list: false, show: true, edit: false, create: false },
39+
},
40+
{
41+
name: 'record_id',
42+
label: 'Record ID',
43+
showIn: { all: true, edit: false, create: false },
44+
},
45+
{
46+
name: 'resource_id',
47+
label: 'Resource',
48+
showIn: { all: true, edit: false, create: false },
49+
},
50+
{
51+
name: 'action',
52+
enum: [
53+
{ value: 'create', label: 'Create' },
54+
{ value: 'edit', label: 'Edit' },
55+
{ value: 'delete', label: 'Delete' },
56+
],
57+
showIn: { all: true, edit: false, create: false },
58+
},
59+
{
60+
name: 'data',
61+
type: AdminForthDataTypes.JSON,
62+
sortable: false,
63+
showIn: { all: true, edit: false, create: false },
64+
},
65+
{
66+
name: 'user_id',
67+
label: 'Requested by',
68+
foreignResource: {
69+
resourceId: 'adminuser',
70+
},
71+
showIn: { all: true, edit: false, create: false },
72+
},
73+
{
74+
name: 'responser_id',
75+
label: 'Responded by',
76+
foreignResource: {
77+
resourceId: 'adminuser',
78+
},
79+
showIn: { all: true, edit: false, create: false },
80+
},
81+
{
82+
name: 'status',
83+
enum: [
84+
{ value: 1, label: 'Pending' },
85+
{ value: 2, label: 'Approved' },
86+
{ value: 3, label: 'Rejected' },
87+
],
88+
showIn: { all: true, edit: false, create: false },
89+
},
90+
{
91+
name: 'created_at',
92+
type: AdminForthDataTypes.DATETIME,
93+
allowMinMaxQuery: true,
94+
showIn: { all: true, edit: false, create: false },
95+
},
96+
{
97+
name: 'extra',
98+
type: AdminForthDataTypes.JSON,
99+
showIn: { all: false },
100+
backendOnly: true,
101+
},
102+
],
103+
options: {
104+
listPageSize: 10,
105+
allowedActions: {
106+
create: false,
107+
edit: false,
108+
delete: false,
109+
// this is not only a UI matter: the plugin resolves `show` on every
110+
// approve/reject request, so it is the actual approval permission
111+
show: allowedForSuperAdmin,
112+
filter: allowedForSuperAdmin,
113+
},
114+
},
115+
// cast for the same reason as in adminuser.ts: the plugin repo has its own
116+
// adminforth copy, so its IAdminForthPlugin is a different type identity
117+
plugins: [crudApprovePlugin] as any,
118+
} as AdminForthResourceInput;

0 commit comments

Comments
 (0)