adminforth 1.1.107 → 1.2.2

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.
@@ -248,6 +248,7 @@ class CodeInjector implements ICodeInjector {
248
248
 
249
249
  await fsExtra.copy(src, to, {
250
250
  recursive: true,
251
+ dereference: true,
251
252
  });
252
253
  }
253
254
  }
@@ -0,0 +1,452 @@
1
+ import {
2
+ AdminForthConfig,
3
+ AdminForthResource,
4
+ IAdminForth, IConfigValidator,
5
+ AdminForthComponentDeclaration ,
6
+ AdminForthResourcePages, AllowedActionsEnum,
7
+ type AdminForthComponentDeclarationFull,
8
+ } from "../types/AdminForthConfig.js";
9
+
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { guessLabelFromName } from './utils.js';
13
+ import { v4 as uuid } from 'uuid';
14
+
15
+ export default class ConfigValidator implements IConfigValidator {
16
+
17
+ constructor(private adminforth: IAdminForth, private config: AdminForthConfig) {
18
+ this.adminforth = adminforth;
19
+ this.config = config;
20
+ }
21
+
22
+ checkCustomFileExists(filePath: string): Array<string> {
23
+ if (filePath.startsWith('@@/')) {
24
+ const checkPath = path.join(this.config.customization.customComponentsDir, filePath.replace('@@/', ''));
25
+ if (!fs.existsSync(checkPath)) {
26
+ return [`File file ${filePath} does not exist in ${this.config.customization.customComponentsDir}`];
27
+ }
28
+ }
29
+ return [];
30
+ }
31
+
32
+ validateComponent(component: AdminForthComponentDeclaration, errors: Array<string>, ignoreExistsCheck: boolean = false): AdminForthComponentDeclaration {
33
+ if (!component) {
34
+ return component;
35
+ }
36
+ let obj: AdminForthComponentDeclarationFull;
37
+ if (typeof component === 'string') {
38
+ obj = { file: component, meta: {} };
39
+ } else {
40
+ obj = component;
41
+ }
42
+ if (!ignoreExistsCheck) {
43
+ errors.push(...this.checkCustomFileExists(obj.file));
44
+ }
45
+
46
+ return obj;
47
+ }
48
+
49
+ validateConfig() {
50
+ const errors = [];
51
+
52
+ if (this.config.rootUser) {
53
+ if (!this.config.rootUser.username) {
54
+ throw new Error('rootUser.username is required');
55
+ }
56
+ if (!this.config.rootUser.password) {
57
+ throw new Error('rootUser.password is required');
58
+ }
59
+
60
+ console.log('\n ⚠️⚠️⚠️ [INSECURE ALERT] config.rootUser is set, please create a new user and remove config.rootUser from config ASAP when you are in production\n');
61
+ }
62
+
63
+ if (!this.config.customization.customComponentsDir) {
64
+ this.config.customization.customComponentsDir = './custom';
65
+ }
66
+
67
+ try {
68
+ // check customComponentsDir exists
69
+ fs.accessSync(this.config.customization.customComponentsDir, fs.constants.R_OK);
70
+ } catch (e) {
71
+ this.config.customization.customComponentsDir = undefined;
72
+ }
73
+
74
+ if (this.config.auth) {
75
+ if (!this.config.auth.resourceId) {
76
+ throw new Error('No config.auth.resourceId defined');
77
+ }
78
+ if (!this.config.auth.passwordHashField) {
79
+ throw new Error('No config.auth.passwordHashField defined');
80
+ }
81
+ if (!this.config.auth.usernameField) {
82
+ throw new Error('No config.auth.usernameField defined');
83
+ }
84
+ if (this.config.auth.loginBackgroundImage) {
85
+ errors.push(...this.checkCustomFileExists(this.config.auth.loginBackgroundImage));
86
+ }
87
+ const userResource = this.config.resources.find((res) => res.resourceId === this.config.auth.resourceId);
88
+ if (!userResource) {
89
+ throw new Error(`Resource with id "${this.config.auth.resourceId}" not found`);
90
+ }
91
+
92
+ if (!this.config.auth.beforeLoginConfirmation) {
93
+ this.config.auth.beforeLoginConfirmation = [];
94
+ }
95
+ }
96
+
97
+ if (!this.config.customization) {
98
+ this.config.customization = {};
99
+ }
100
+
101
+ if (!this.config.customization.customComponentsDir) {
102
+ this.config.customization.customComponentsDir = './custom';
103
+ }
104
+
105
+ try {
106
+ // check customComponentsDir exists
107
+ fs.accessSync(this.config.customization.customComponentsDir, fs.constants.R_OK);
108
+ } catch (e) {
109
+ this.config.customization.customComponentsDir = undefined;
110
+ }
111
+
112
+ if (this.config.customization.customPages) {
113
+ this.config.customization.customPages.forEach((page, i) => {
114
+ // validate component if it's not plugin injection
115
+ if (this.adminforth.codeInjector.allComponentNames.hasOwnProperty(page.component as PropertyKey)) {
116
+ const validatedPage = this.validateComponent(page.component, errors, true);
117
+ }
118
+ });
119
+ } else {
120
+ this.config.customization.customPages = [];
121
+ }
122
+ if (!this.config.baseUrl) {
123
+ this.config.baseUrl = '';
124
+ }
125
+ if (!this.config.baseUrl.endsWith('/')) {
126
+ this.adminforth.baseUrlSlashed = this.config.baseUrl + '/';
127
+ } else {
128
+ this.adminforth.baseUrlSlashed = this.config.baseUrl;
129
+ }
130
+ if (this.config?.customization.brandName === undefined) {
131
+ this.config.customization.brandName = 'AdminForth';
132
+ }
133
+ if (this.config.customization.brandLogo) {
134
+ errors.push(...this.checkCustomFileExists(this.config.customization.brandLogo));
135
+ }
136
+
137
+ if (this.config.customization.favicon) {
138
+ errors.push(...this.checkCustomFileExists(this.config.customization.favicon));
139
+ }
140
+
141
+ if (!this.config.customization.datesFormat) {
142
+ this.config.customization.datesFormat = 'MMM D, YYYY HH:mm:ss';
143
+ }
144
+
145
+ if (this.config.resources) {
146
+ this.config.resources.forEach((res) => {
147
+ if (!res.table) {
148
+ errors.push(`Resource "${res.dataSource}" is missing table`);
149
+ }
150
+ // if recordLabel is not callable, throw error
151
+ if (res.recordLabel && typeof res.recordLabel !== 'function') {
152
+ errors.push(`Resource "${res.dataSource}" recordLabel is not a function`);
153
+ }
154
+ if (!res.recordLabel) {
155
+ res.recordLabel = (item) => {
156
+ const pkVal = item[res.columns.find((col) => col.primaryKey).name];
157
+ return `${res.label} ${pkVal}`;
158
+ }
159
+ }
160
+
161
+
162
+ res.resourceId = res.resourceId || res.table;
163
+ res.label = res.label || res.table.charAt(0).toUpperCase() + res.table.slice(1);
164
+ if (!res.dataSource) {
165
+ errors.push(`Resource "${res.resourceId}" is missing dataSource`);
166
+ }
167
+ if (!res.columns) {
168
+ res.columns = [];
169
+ }
170
+ res.columns.forEach((col) => {
171
+ col.label = col.label || guessLabelFromName(col.name);
172
+ //define default sortable
173
+ if (!Object.keys(col).includes('sortable')) { col.sortable = true; }
174
+ if (col.showIn && !Array.isArray(col.showIn)) {
175
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" showIn must be an array`);
176
+ }
177
+
178
+ // check col.required is string or object
179
+ if (col.required && !((typeof col.required === 'boolean') || (typeof col.required === 'object'))) {
180
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" required must be a string or object`);
181
+ }
182
+
183
+ // if it is object check the keys are one of ['create', 'edit']
184
+ if (typeof col.required === 'object') {
185
+ const wrongRequiredOn = Object.keys(col.required).find((c) => !['create', 'edit'].includes(c));
186
+ if (wrongRequiredOn) {
187
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" has invalid required value "${wrongRequiredOn}", allowed keys are 'create', 'edit']`);
188
+ }
189
+ }
190
+
191
+ // same for editingNote
192
+ if (col.editingNote && !((typeof col.editingNote === 'string') || (typeof col.editingNote === 'object'))) {
193
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" editingNote must be a string or object`);
194
+ }
195
+ if (typeof col.editingNote === 'object') {
196
+ const wrongEditingNoteOn = Object.keys(col.editingNote).find((c) => !['create', 'edit'].includes(c));
197
+ if (wrongEditingNoteOn) {
198
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" has invalid editingNote value "${wrongEditingNoteOn}", allowed keys are 'create', 'edit']`);
199
+ }
200
+ }
201
+
202
+ const wrongShowIn = col.showIn && col.showIn.find((c) => AdminForthResourcePages[c] === undefined);
203
+ if (wrongShowIn) {
204
+ errors.push(`Resource "${res.resourceId}" column "${col.name}" has invalid showIn value "${wrongShowIn}", allowed values are ${Object.keys(AdminForthResourcePages).join(', ')}`);
205
+ }
206
+ col.showIn = col.showIn || Object.values(AdminForthResourcePages);
207
+
208
+ if (col.foreignResource) {
209
+ const befHook = col.foreignResource.hooks?.dropdownList?.beforeDatasourceRequest;
210
+ if (befHook) {
211
+ if (!Array.isArray(befHook)) {
212
+ col.foreignResource.hooks.dropdownList.beforeDatasourceRequest = [befHook];
213
+ }
214
+ }
215
+ const aftHook = col.foreignResource.hooks?.dropdownList?.afterDatasourceResponse;
216
+ if (aftHook) {
217
+ if (!Array.isArray(aftHook)) {
218
+ col.foreignResource.hooks.dropdownList.afterDatasourceResponse = [aftHook];
219
+ }
220
+ }
221
+ }
222
+ })
223
+
224
+ if (!res.options) {
225
+ res.options = { bulkActions: [], allowedActions: {} };
226
+ }
227
+
228
+ if (!res.options.allowedActions) {
229
+ res.options.allowedActions = {
230
+ all: true,
231
+ };
232
+ }
233
+
234
+
235
+ if (Object.keys(res.options.allowedActions).includes('all')) {
236
+ if (Object.keys(res.options.allowedActions).length > 1) {
237
+ errors.push(`Resource "${res.resourceId}" allowedActions cannot have "all" and other keys at same time: ${Object.keys(res.options.allowedActions).join(', ')}`);
238
+ }
239
+ for (const key of Object.keys(AllowedActionsEnum)) {
240
+ if (key !== 'all') {
241
+ res.options.allowedActions[key] = res.options.allowedActions.all;
242
+ }
243
+ }
244
+ delete res.options.allowedActions.all;
245
+ } else {
246
+ // by default allow all actions
247
+ for (const key of Object.keys(AllowedActionsEnum)) {
248
+ if (!Object.keys(res.options.allowedActions).includes(key)) {
249
+ res.options.allowedActions[key] = true;
250
+ }
251
+ }
252
+ }
253
+
254
+
255
+ //check if resource has bulkActions
256
+ let bulkActions = res?.options?.bulkActions || [];
257
+
258
+ if (!Array.isArray(bulkActions)) {
259
+ errors.push(`Resource "${res.resourceId}" bulkActions must be an array`);
260
+ bulkActions = [];
261
+ }
262
+
263
+ if (res.options?.allowedActions?.delete && !bulkActions.find((action) => action.label === 'Delete checked')) {
264
+ bulkActions.push({
265
+ label: `Delete checked`,
266
+ state: 'danger',
267
+ icon: 'flowbite:trash-bin-outline',
268
+ action: async ({ selectedIds }) => {
269
+ const connector = this.adminforth.connectors[res.dataSource];
270
+ await Promise.all(selectedIds.map(async (recordId) => {
271
+ await connector.deleteRecord({ resource: res, recordId });
272
+ }));
273
+ }
274
+ });
275
+ }
276
+
277
+ const newBulkActions = bulkActions.map((action) => {
278
+ return Object.assign(action, { id: uuid() });
279
+ });
280
+ res.options.bulkActions = newBulkActions;
281
+
282
+ // if pageInjection is a string, make array with one element. Also check file exists
283
+ const possibleInjections = ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom'];
284
+ if (res.options.pageInjections) {
285
+ Object.entries(res.options.pageInjections).map(([key, value]) => {
286
+ Object.entries(value).map(([injection, target]) => {
287
+ if (possibleInjections.includes(injection)) {
288
+ if (!Array.isArray(res.options.pageInjections[key][injection])) {
289
+ // not array
290
+ res.options.pageInjections[key][injection] = [target];
291
+ }
292
+ res.options.pageInjections[key][injection].forEach((target, i) => {
293
+ res.options.pageInjections[key][injection][i] = this.validateComponent(target, errors);
294
+ });
295
+ } else {
296
+ errors.push(`Resource "${res.resourceId}" has invalid pageInjection key "${injection}", Supported keys are ${possibleInjections.join(', ')}`);
297
+ }
298
+ });
299
+
300
+ })
301
+ }
302
+
303
+ // transform all hooks Functions to array of functions
304
+ if (!res.hooks) {
305
+ res.hooks = {};
306
+ }
307
+ for (const hookName of ['show', 'list']) {
308
+ if (!res.hooks[hookName]) {
309
+ res.hooks[hookName] = {};
310
+ }
311
+ if (!res.hooks[hookName].beforeDatasourceRequest) {
312
+ res.hooks[hookName].beforeDatasourceRequest = [];
313
+ }
314
+
315
+ if (!Array.isArray(res.hooks[hookName].beforeDatasourceRequest)) {
316
+ res.hooks[hookName].beforeDatasourceRequest = [res.hooks[hookName].beforeDatasourceRequest];
317
+ }
318
+
319
+ if (!res.hooks[hookName].afterDatasourceResponse) {
320
+ res.hooks[hookName].afterDatasourceResponse = [];
321
+ }
322
+
323
+ if (!Array.isArray(res.hooks[hookName].afterDatasourceResponse)) {
324
+ res.hooks[hookName].afterDatasourceResponse = [res.hooks[hookName].afterDatasourceResponse];
325
+ }
326
+ }
327
+ for (const hookName of ['create', 'edit', 'delete']) {
328
+ if (!res.hooks[hookName]) {
329
+ res.hooks[hookName] = {};
330
+ }
331
+ if (!res.hooks[hookName].beforeSave) {
332
+ res.hooks[hookName].beforeSave = [];
333
+ }
334
+ if (!Array.isArray(res.hooks[hookName].beforeSave)) {
335
+ res.hooks[hookName].beforeSave = [res.hooks[hookName].beforeSave];
336
+ }
337
+ if (!res.hooks[hookName].afterSave) {
338
+ res.hooks[hookName].afterSave = [];
339
+ }
340
+ if (!Array.isArray(res.hooks[hookName].afterSave)) {
341
+ res.hooks[hookName].afterSave = [res.hooks[hookName].afterSave];
342
+ }
343
+ }
344
+ });
345
+
346
+
347
+
348
+ if (!this.config.menu) {
349
+ errors.push('No config.menu defined');
350
+ }
351
+
352
+ // check if there is only one homepage: true in menu, recursivly
353
+ let homepages = 0;
354
+ const browseMenu = (menu) => {
355
+ menu.forEach((item) => {
356
+ if (item.component && item.resourceId) {
357
+ errors.push(`Menu item cannot have both component and resourceId: ${JSON.stringify(item)}`);
358
+ }
359
+ if (item.component && !item.path) {
360
+ errors.push(`Menu item with component must have path : ${JSON.stringify(item)}`);
361
+ }
362
+
363
+ if (item.type === 'resource' && !item.resourceId) {
364
+ errors.push(`Menu item with type 'resource' must have resourceId : ${JSON.stringify(item)}`);
365
+ }
366
+
367
+ if (item.resourceId && !this.config.resources.find((res) => res.resourceId === item.resourceId)) {
368
+ errors.push(`Menu item with type 'resourceId' has resourceId which is not in resources: ${JSON.stringify(item)}`);
369
+ }
370
+
371
+ if (item.type === 'component' && !item.component) {
372
+ errors.push(`Menu item with type 'component' must have component : ${JSON.stringify(item)}`);
373
+ }
374
+
375
+ // make sure component starts with @@
376
+ if (item.component) {
377
+ if (!item.component.startsWith('@@')) {
378
+ errors.push(`Menu item component must start with @@ : ${JSON.stringify(item)}`);
379
+ }
380
+
381
+ const path = item.component.replace('@@', this.config.customization.customComponentsDir);
382
+ if (!fs.existsSync(path)) {
383
+ errors.push(`Menu item component "${item.component.replace('@@', '')}" does not exist in "${this.config.customization.customComponentsDir}"`);
384
+ }
385
+ }
386
+
387
+ if (item.homepage) {
388
+ homepages++;
389
+ if (homepages > 1) {
390
+ errors.push('There must be only one homepage: true in menu, found second one in ' + JSON.stringify(item));
391
+ }
392
+ }
393
+ if (item.children) {
394
+ browseMenu(item.children);
395
+ }
396
+ });
397
+ };
398
+ browseMenu(this.config.menu);
399
+
400
+ }
401
+
402
+ // check for duplicate resourceIds and show which ones are duplicated
403
+ const resourceIds = this.config.resources.map((res) => res.resourceId);
404
+ const uniqueResourceIds = new Set(resourceIds);
405
+ if (uniqueResourceIds.size != resourceIds.length) {
406
+ const duplicates = resourceIds.filter((item, index) => resourceIds.indexOf(item) != index);
407
+ errors.push(`Duplicate fields "resourceId" or "table": ${duplicates.join(', ')}`);
408
+ }
409
+
410
+ //add ids for onSelectedAllActions for each resource
411
+ if (errors.length > 0) {
412
+ throw new Error(`Invalid AdminForth config: ${errors.join(', ')}`);
413
+ }
414
+
415
+ // check is all custom components files exists
416
+ for (const resource of this.config.resources) {
417
+ for (const column of resource.columns) {
418
+ if (column.components) {
419
+
420
+ for (const [key, comp] of Object.entries(column.components as Record<string, AdminForthComponentDeclarationFull>)) {
421
+ let ignoreExistsCheck = false;
422
+ if (this.adminforth.codeInjector.allComponentNames[comp.file]) {
423
+ // not obvious, but if we are in this if, it means that this is plugin component
424
+ // and there is no sense to check if it exists in users folder
425
+ ignoreExistsCheck = true;
426
+ }
427
+ column.components[key] = this.validateComponent(comp, errors, ignoreExistsCheck);
428
+ }
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ postProcessAfterDiscover(resource: AdminForthResource) {
435
+ resource.columns.forEach((column) => {
436
+ // if db/user says column is required in boolean, expand
437
+ if (typeof column.required === 'boolean') {
438
+ column.required = { create: column.required, edit: column.required };
439
+ }
440
+
441
+ if (!column.required) {
442
+ column.required = { create: false, edit: false };
443
+ }
444
+
445
+ // same for editingNote
446
+ if (typeof column.editingNote === 'string') {
447
+ column.editingNote = { create: column.editingNote, edit: column.editingNote };
448
+ }
449
+ })
450
+ resource.dataSourceColumns = resource.columns.filter((col) => !col.virtual);
451
+ }
452
+ }