@adminforth/crud-approve-plugin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,422 @@
1
+ import { ActionCheckSource, AdminForthPlugin, AdminForthSortDirections, Filters, IAdminForthDataSourceConnectorBase, IHttpServer } from "adminforth";
2
+ import { IAdminForth, AdminForthDataTypes, AdminForthResource, AllowedActionsEnum, HttpExtra, AdminUser } from "adminforth";
3
+ import { AllowedForReviewActionsEnum, ApprovalStatusEnum, type PluginOptions } from './types.js';
4
+
5
+ import dayjs from "dayjs";
6
+ import utc from "dayjs/plugin/utc.js";
7
+ import { randomUUID } from "crypto";
8
+ dayjs.extend(utc);
9
+
10
+
11
+ export default class CRUDApprovePlugin extends AdminForthPlugin {
12
+ options: PluginOptions;
13
+ adminforth: IAdminForth;
14
+ diffResource: AdminForthResource;
15
+
16
+ constructor(options: PluginOptions) {
17
+ super(options, import.meta.url);
18
+ this.options = options;
19
+ }
20
+
21
+ async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
22
+ // simply modify resourceConfig or adminforth.config. You can get access to plugin options via this.options;
23
+ super.modifyResourceConfig(adminforth, resourceConfig);
24
+ this.adminforth = adminforth;
25
+
26
+ this.diffResource = this.resourceConfig;
27
+ let diffColumn = resourceConfig.columns.find((c) => c.name === this.options.resourceColumns.dataColumnName);
28
+ if (!diffColumn) {
29
+ throw new Error(`Column ${this.options.resourceColumns.dataColumnName} not found in ${resourceConfig.label}`)
30
+ }
31
+ if (diffColumn.type !== AdminForthDataTypes.JSON) {
32
+ // throw new Error(`Column ${this.options.resourceColumns.dataColumnName} must be of type 'json'`)
33
+ }
34
+
35
+ diffColumn.components = {
36
+ show: {
37
+ file: this.componentPath('ShowPageDiffView.vue'),
38
+ meta: {
39
+ ...this.options,
40
+ pluginInstanceId: this.pluginInstanceId
41
+ }
42
+ },
43
+ list: {
44
+ file: this.componentPath('ListPageDiffView.vue'),
45
+ meta: {
46
+ ...this.options,
47
+ pluginInstanceId: this.pluginInstanceId
48
+ }
49
+ }
50
+ }
51
+ resourceConfig.options.defaultSort = {
52
+ columnName: this.options.resourceColumns.createdAtColumnName,
53
+ direction: AdminForthSortDirections.desc
54
+ }
55
+ }
56
+
57
+ createApprovalRequest = async (resource: AdminForthResource, action: AllowedForReviewActionsEnum, data: Object, user: AdminUser, oldRecord?: Object, updates?: Object, extra?: HttpExtra) => {
58
+ if (extra && extra.adminforth_plugin_crud_approve && extra.adminforth_plugin_crud_approve.callingFromApprovalPlugin) {
59
+ return { ok: true, error: 'Approval request creation aborted to avoid infinite loop' };
60
+ }
61
+
62
+ const recordIdFieldName = resource.columns.find((c) => c.primaryKey === true)?.name;
63
+ const recordId = data?.[recordIdFieldName] || oldRecord?.[recordIdFieldName];
64
+
65
+ let newRecord = {};
66
+ const connector = this.adminforth.connectors[resource.dataSource];
67
+ if (action === AllowedForReviewActionsEnum.create) {
68
+ oldRecord = {};
69
+ newRecord = updates;
70
+ } else if (action === AllowedForReviewActionsEnum.edit) {
71
+ newRecord = await connector.getRecordByPrimaryKey(resource, recordId);
72
+ for (const key in updates) {
73
+ newRecord[key] = updates[key];
74
+ }
75
+ } else if (action === AllowedForReviewActionsEnum.delete) {
76
+ oldRecord = await connector.getRecordByPrimaryKey(resource, recordId);
77
+ }
78
+
79
+ const checks = await Promise.all(
80
+ resource.columns.map(async (c) => {
81
+ if (typeof c.backendOnly === "function") {
82
+ const result = await c.backendOnly({
83
+ adminUser: user,
84
+ resource,
85
+ meta: {},
86
+ source: ActionCheckSource.ShowRequest,
87
+ adminforth: this.adminforth,
88
+ });
89
+ return { col: c, result };
90
+ }
91
+ return { col: c, result: c.backendOnly ?? false };
92
+ })
93
+ );
94
+
95
+ const backendOnlyColumns = checks
96
+ .filter(({ result }) => result === true)
97
+ .map(({ col }) => col);
98
+
99
+ backendOnlyColumns.forEach((c) => {
100
+ if (JSON.stringify(oldRecord[c.name]) != JSON.stringify(newRecord[c.name])) {
101
+ if (action !== AllowedForReviewActionsEnum.delete) {
102
+ newRecord[c.name] = '<hidden value after>'
103
+ }
104
+ if (action !== AllowedForReviewActionsEnum.create) {
105
+ oldRecord[c.name] = '<hidden value before>'
106
+ }
107
+ } else {
108
+ delete oldRecord[c.name];
109
+ delete newRecord[c.name];
110
+ }
111
+ });
112
+
113
+ if (action === AllowedForReviewActionsEnum.edit) {
114
+ for (const key in oldRecord) {
115
+ if (JSON.stringify(oldRecord[key]) === JSON.stringify(newRecord[key])) {
116
+ delete oldRecord[key];
117
+ delete newRecord[key];
118
+ }
119
+ }
120
+ }
121
+
122
+ const createdAt = dayjs.utc().format();
123
+ const record = {
124
+ [this.options.resourceColumns.idColumnName]: randomUUID(),
125
+ [this.options.resourceColumns.resourceIdColumnName]: resource.resourceId,
126
+ [this.options.resourceColumns.actionColumnName]: action,
127
+ [this.options.resourceColumns.statusColumnName]: ApprovalStatusEnum.pending,
128
+ [this.options.resourceColumns.dataColumnName]: { 'oldRecord': oldRecord, 'newRecord': newRecord },
129
+ [this.options.resourceColumns.userIdColumnName]: user.pk,
130
+ [this.options.resourceColumns.recordIdColumnName]: recordId,
131
+ [this.options.resourceColumns.createdAtColumnName]: createdAt,
132
+ [this.options.resourceColumns.extraColumnName]: extra || {},
133
+ }
134
+ const diffResource = this.adminforth.config.resources.find((r) => r.resourceId === this.diffResource.resourceId);
135
+ await this.adminforth.createResourceRecord({ resource: diffResource, record, adminUser: user});
136
+ return { ok: true, error: null };
137
+ }
138
+
139
+ validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
140
+ // optional method where you can safely check field types after database discovery was performed
141
+ }
142
+
143
+ instanceUniqueRepresentation(pluginOptions: any) : string {
144
+ // optional method to return unique string representation of plugin instance.
145
+ // Needed if plugin can have multiple instances on one resource
146
+ return `single`;
147
+ }
148
+
149
+ callBeforeSaveHooks = async (
150
+ // resource: AdminForthResource, action: AllowedActionsEnum, record: any,
151
+ // adminUser: AdminUser, recordId?: any, extra?: HttpExtra
152
+ resource: AdminForthResource, action: AllowedActionsEnum, record: any,
153
+ adminUser: AdminUser, recordId: any, updates: any, oldRecord: any,
154
+ adminforth: IAdminForth, extra?: any
155
+ ) => {
156
+ let hooks = [];
157
+ if (action === AllowedActionsEnum.create) {
158
+ hooks = resource.hooks.create.beforeSave;
159
+ } else if (action === AllowedActionsEnum.edit) {
160
+ hooks = resource.hooks.edit.beforeSave;
161
+ } else if (action === AllowedActionsEnum.delete) {
162
+ hooks = resource.hooks.delete.beforeSave;
163
+ }
164
+
165
+ // mark that call is from approval plugin to avoid infinite loops
166
+ console.log('extra before save hooks:', extra);
167
+ if (extra === undefined) {
168
+ extra = {};
169
+ }
170
+ extra.adminforth_plugin_crud_approve = {
171
+ callingFromApprovalPlugin: true
172
+ }
173
+ for (const hook of hooks) {
174
+ const resp = await hook({
175
+ resource,
176
+ record,
177
+ adminUser,
178
+ recordId,
179
+ adminforth: this.adminforth,
180
+ extra,
181
+ updates,
182
+ oldRecord,
183
+ });
184
+ if (!resp || (!resp.ok && !resp.error)) {
185
+ throw new Error(`Hook beforeSave must return object with {ok: true} or { error: 'Error' } `);
186
+ }
187
+
188
+ if (resp.error) {
189
+ return { error: resp.error };
190
+ }
191
+ }
192
+ return { ok: true, error: null };
193
+ }
194
+
195
+ callAfterSaveHooks = async (
196
+ // resource: AdminForthResource, action: AllowedActionsEnum, record: any,
197
+ // adminUser: AdminUser, recordId: any, extra?: HttpExtra
198
+
199
+ resource: AdminForthResource,
200
+ action: AllowedActionsEnum,
201
+ record: any,
202
+ adminUser: AdminUser,
203
+ recordId: any,
204
+ updates: any,
205
+ oldRecord: any,
206
+ adminforth: IAdminForth,
207
+ extra?: HttpExtra
208
+ ) => {
209
+ let hooks = [];
210
+ if (action === AllowedActionsEnum.create) {
211
+ hooks = resource.hooks.create.afterSave;
212
+ } else if (action === AllowedActionsEnum.edit) {
213
+ hooks = resource.hooks.edit.afterSave;
214
+ } else if (action === AllowedActionsEnum.delete) {
215
+ hooks = resource.hooks.delete.afterSave;
216
+ }
217
+
218
+ for (const hook of hooks) {
219
+ const resp = await hook({
220
+ resource,
221
+ record,
222
+ adminUser,
223
+ recordId,
224
+ adminforth: this.adminforth,
225
+ extra,
226
+ updates,
227
+ oldRecord,
228
+ });
229
+ if (!resp || (!resp.ok && !resp.error)) {
230
+ throw new Error(`Hook afterSave must return object with {ok: true} or { error: 'Error' } `);
231
+ }
232
+
233
+ if (resp.error) {
234
+ return { error: resp.error };
235
+ }
236
+ }
237
+ }
238
+
239
+ verifyAuth = async (cookies: Array<{key: string, value: string}>) => {
240
+ let authCookie;
241
+ for (const i in cookies) {
242
+ if (cookies[i].key === `adminforth_${this.adminforth.config.customization.brandNameSlug}_jwt`) {
243
+ authCookie = cookies[i].value;
244
+ }
245
+ }
246
+ const authRes = await this.adminforth.auth.verify(authCookie, 'auth', true);
247
+ if ('error' in authRes) {
248
+ return { error: authRes.error, authRes: null };
249
+ }
250
+ return { error: null, authRes: authRes };
251
+ }
252
+
253
+ createRecord = async (resource: AdminForthResource, diffData: any, adminUser: AdminUser) => {
254
+ const connector = this.adminforth.connectors[resource.dataSource];
255
+ const err = this.adminforth.validateRecordValues(resource, diffData['newRecord'], 'create');
256
+ if (err) {
257
+ return { ok: false, error: err, createdRecord: null };
258
+ }
259
+
260
+ // remove virtual columns from record
261
+ for (const column of resource.columns.filter((col) => col.virtual)) {
262
+ if (diffData['newRecord'][column.name]) {
263
+ delete diffData['newRecord'][column.name];
264
+ }
265
+ }
266
+ return await connector.createRecord({ resource, record: diffData['newRecord'], adminUser });
267
+ }
268
+
269
+ editRecord = async (resource: AdminForthResource, diffData: any, targetRecordId: any, connector: IAdminForthDataSourceConnectorBase) => {
270
+ let oldRecord;
271
+ const dataToUse = diffData['newRecord'];
272
+ const err = this.adminforth.validateRecordValues(resource, dataToUse, 'edit', targetRecordId);
273
+ if (err) {
274
+ return { error: err };
275
+ }
276
+ // remove editReadonly columns from record
277
+ oldRecord = await connector.getRecordByPrimaryKey(resource, targetRecordId);
278
+ for (const column of resource.columns.filter((col) => col.editReadonly)) {
279
+ if (column.name in dataToUse)
280
+ delete dataToUse[column.name];
281
+ }
282
+ const newValues = {};
283
+ for (const recordField in dataToUse) {
284
+ if (dataToUse[recordField] !== oldRecord[recordField]) {
285
+ // leave only changed fields to reduce data transfer/modifications in db
286
+ const column = resource.columns.find((col) => col.name === recordField);
287
+ if (!column || !column.virtual) {
288
+ // exclude virtual columns
289
+ newValues[recordField] = dataToUse[recordField];
290
+ }
291
+ }
292
+ }
293
+
294
+ if (Object.keys(newValues).length > 0) {
295
+ const { error } = await connector.updateRecord({ resource, recordId: targetRecordId, newValues });
296
+ if ( error ) {
297
+ return { ok: false, error };
298
+ }
299
+ }
300
+ return { ok: true, error: null };
301
+ }
302
+
303
+ deleteRecord = async (resource: AdminForthResource, targetRecordId: any, connector: IAdminForthDataSourceConnectorBase) => {
304
+ return await connector.deleteRecord({ resource, recordId: targetRecordId });
305
+ }
306
+
307
+ setupEndpoints(server: IHttpServer): void {
308
+ server.endpoint({
309
+ method: 'POST',
310
+ path: `/plugin/crud-approve/update-status`,
311
+ noAuth: true,
312
+ handler: async ({ body, response, cookies }) => {
313
+ const authRes = await this.verifyAuth(cookies);
314
+ if (authRes.error) {
315
+ response.status = 403;
316
+ return { error: authRes.error };
317
+ }
318
+ const adminUser = authRes.authRes;
319
+
320
+ const { resourceId, diffId, recordId, action, approved, code } = body;
321
+ const diffRecord = await this.adminforth.resource(this.diffResource.resourceId).get(
322
+ Filters.EQ(this.options.resourceColumns.idColumnName, diffId),
323
+ )
324
+ if (!diffRecord) {
325
+ response.status = 404;
326
+ return { error: 'Diff record not found' };
327
+ }
328
+
329
+ if (diffRecord[this.options.resourceColumns.statusColumnName] !== ApprovalStatusEnum.pending) {
330
+ response.status = 400;
331
+ return { error: 'Diff record is not pending' };
332
+ }
333
+
334
+ if (approved === true) {
335
+ const resource = this.adminforth.config.resources.find(
336
+ (res) => res.resourceId == diffRecord[this.options.resourceColumns.resourceIdColumnName]
337
+ );
338
+ const diffData = diffRecord[this.options.resourceColumns.dataColumnName];
339
+ const extra = diffRecord[this.options.resourceColumns.extraColumnName] || {};
340
+ extra.body = body;
341
+ const beforeSaveResp = await this.callBeforeSaveHooks(
342
+ resource, action as AllowedActionsEnum, diffData['newRecord'],
343
+ adminUser, diffRecord[this.options.resourceColumns.recordIdColumnName],
344
+ undefined, diffData['oldRecord'], this.adminforth, extra
345
+ );
346
+ if (beforeSaveResp.error) {
347
+ response.status = 500;
348
+ return { error: `Failed to apply approved changes: ${beforeSaveResp.error}` };
349
+ }
350
+
351
+ let recordUpdateResult;
352
+ const connector = this.adminforth.connectors[resource.dataSource];
353
+ if (action === AllowedActionsEnum.create) {
354
+ recordUpdateResult = await this.createRecord(resource, diffData, adminUser);
355
+ } else if (action === AllowedActionsEnum.edit) {
356
+ recordUpdateResult = await this.editRecord(
357
+ resource, diffData, diffRecord[this.options.resourceColumns.recordIdColumnName], connector
358
+ );
359
+ } else if (action === AllowedActionsEnum.delete) {
360
+ recordUpdateResult = await this.deleteRecord(
361
+ resource, diffRecord[this.options.resourceColumns.recordIdColumnName], connector
362
+ );
363
+ }
364
+ if (recordUpdateResult?.error) {
365
+ response.status = 500;
366
+ console.error('Error applying approved changes:', recordUpdateResult);
367
+ return { error: `Failed to apply approved changes: ${recordUpdateResult.error}` };
368
+ }
369
+
370
+ let afterSaveResp;
371
+ if (action === AllowedActionsEnum.create) {
372
+ const newRecord = recordUpdateResult.createdRecord;
373
+ afterSaveResp = await this.callAfterSaveHooks(
374
+ resource, action as AllowedActionsEnum, newRecord, adminUser,
375
+ diffRecord[this.options.resourceColumns.recordIdColumnName],
376
+ newRecord, {}, this.adminforth, { body }
377
+ );
378
+ } else if (action === AllowedActionsEnum.edit) {
379
+ const newRecord = diffData['newRecord'];
380
+ const oldRecord = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(
381
+ resource, diffRecord[this.options.resourceColumns.recordIdColumnName]
382
+ );
383
+ afterSaveResp = await this.callAfterSaveHooks(
384
+ resource, action as AllowedActionsEnum, newRecord, adminUser,
385
+ recordId, newRecord, oldRecord, this.adminforth, { body }
386
+ );
387
+ } else if (action === AllowedActionsEnum.delete) {
388
+ const newRecord = diffData['newRecord'];
389
+ afterSaveResp = await this.callAfterSaveHooks(
390
+ resource, action as AllowedActionsEnum, newRecord, adminUser,
391
+ diffRecord[this.options.resourceColumns.recordIdColumnName],
392
+ {}, diffData['oldRecord'], this.adminforth, { body }
393
+ );
394
+ }
395
+
396
+ if (afterSaveResp?.error) {
397
+ response.status = 500;
398
+ return { error: `Failed to apply approved changes: ${afterSaveResp.error}` };
399
+ }
400
+ }
401
+ const r = await this.adminforth.updateResourceRecord({
402
+ resource: this.diffResource, recordId: diffId,
403
+ adminUser: adminUser, oldRecord: diffRecord,
404
+ updates: {
405
+ [this.options.resourceColumns.statusColumnName]: approved ? ApprovalStatusEnum.approved : ApprovalStatusEnum.rejected,
406
+ [this.options.resourceColumns.responserIdColumnName]: authRes.authRes.pk,
407
+ },
408
+ extra: {
409
+ adminforth_plugin_crud_approve: {
410
+ callingFromApprovalPlugin: true
411
+ }
412
+ }
413
+ });
414
+ if (r.error) {
415
+ response.status = 500;
416
+ return { error: `Failed to update diff record status: ${r.error}` };
417
+ }
418
+ return { ok: true };
419
+ }
420
+ })
421
+ }
422
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@adminforth/crud-approve-plugin",
3
+ "version": "1.0.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Adds manual approval for editing actions. Make your admins blame each other for their mistakes 😉",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "type": "module",
11
+ "homepage": "https://adminforth.dev/docs/tutorial/Plugins/CRUDApprove/",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/devforth/af-crud-approve-plugin.git"
15
+ },
16
+ "scripts": {
17
+ "build": "tsc && rsync -av --exclude 'node_modules' custom dist/",
18
+ "prepare": "npm link adminforth"
19
+ },
20
+ "author": "devforth",
21
+ "license": "ISC",
22
+ "dependencies": {
23
+ "@adminforth/two-factors-auth": "^2.9.16",
24
+ "adminforth": "latest"
25
+ },
26
+ "peerDependencies": {
27
+ "adminforth": "^2.13.0-next.51"
28
+ },
29
+ "release": {
30
+ "plugins": [
31
+ "@semantic-release/commit-analyzer",
32
+ "@semantic-release/release-notes-generator",
33
+ "@semantic-release/npm",
34
+ "@semantic-release/github",
35
+ [
36
+ "semantic-release-slack-bot",
37
+ {
38
+ "notifyOnSuccess": true,
39
+ "notifyOnFail": true,
40
+ "slackIcon": ":package:",
41
+ "markdownReleaseNotes": true
42
+ }
43
+ ]
44
+ ],
45
+ "branches": [
46
+ "main",
47
+ {
48
+ "name": "next",
49
+ "prerelease": true
50
+ }
51
+ ]
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^22.10.7",
55
+ "semantic-release": "^24.2.1",
56
+ "semantic-release-slack-bot": "^4.0.2",
57
+ "typescript": "^5.7.3"
58
+ }
59
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include*/
4
+ "module": "node16", /* Specify what module code is generated. */
5
+ "outDir": "./dist", /* Specify an output folder for all emitted files. */
6
+ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */
7
+ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
8
+ "strict": false, /* Enable all strict type-checking options. */
9
+ "skipLibCheck": true, /* Skip type checking all .d.ts files. */
10
+ },
11
+ "exclude": ["node_modules", "dist", "custom"], /* Exclude files from compilation. */
12
+ }
13
+
package/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { AdminForthResource, AdminUser, AllowedActionsEnum, HttpExtra } from "adminforth";
2
+
3
+ export interface PluginOptions {
4
+ /**
5
+ * Column names mapping in the diff table.
6
+ */
7
+ resourceColumns: {
8
+ idColumnName: string;
9
+ recordIdColumnName: string;
10
+ resourceIdColumnName: string;
11
+ actionColumnName: string;
12
+ dataColumnName: string;
13
+ userIdColumnName: string;
14
+ responserIdColumnName: string;
15
+ statusColumnName: string;
16
+ createdAtColumnName: string;
17
+ extraColumnName: string;
18
+ }
19
+ }
20
+
21
+ export enum AllowedForReviewActionsEnum {
22
+ create = AllowedActionsEnum.create,
23
+ edit = AllowedActionsEnum.edit,
24
+ delete = AllowedActionsEnum.delete,
25
+ custom = 'custom',
26
+ }
27
+
28
+ export enum ApprovalStatusEnum {
29
+ pending = 1,
30
+ approved = 2,
31
+ rejected = 3
32
+ }