webspresso 0.0.80 → 0.0.82
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/README.md +133 -15
- package/bin/commands/doctor.js +83 -0
- package/bin/commands/favicon-generate.js +3 -1
- package/bin/commands/skill.js +6 -4
- package/core/auth/hash.js +1 -1
- package/core/auth/index.js +30 -0
- package/core/auth/manager.js +190 -0
- package/core/auth/tokens.js +135 -0
- package/core/kernel/app.js +7 -1
- package/index.d.ts +32 -0
- package/index.js +2 -0
- package/package.json +5 -2
- package/plugins/admin-panel/app.js +43 -1
- package/plugins/admin-panel/core/admin-module.js +156 -16
- package/plugins/admin-panel/core/registry.js +3 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +48 -2
- package/plugins/admin-panel/index.js +17 -1
- package/plugins/csrf/index.js +222 -0
- package/plugins/email/admin-component.js +226 -0
- package/plugins/email/api-handlers.js +139 -0
- package/plugins/email/auth-bridge.js +209 -0
- package/plugins/email/bundled-templates.js +68 -0
- package/plugins/email/fs-env.js +101 -0
- package/plugins/email/index.js +186 -0
- package/plugins/email/manifest.js +25 -0
- package/plugins/email/migration-template.js +40 -0
- package/plugins/email/purge.js +28 -0
- package/plugins/email/render.js +154 -0
- package/plugins/email/service.js +244 -0
- package/plugins/email/template-registry.js +134 -0
- package/plugins/email/templates/auth-email-verify.mjml +13 -0
- package/plugins/email/templates/auth-password-reset.mjml +13 -0
- package/plugins/email/templates/auth-welcome.mjml +11 -0
- package/plugins/email/transport.js +71 -0
- package/plugins/index.js +4 -0
- package/src/file-router.js +75 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
- package/templates/skills/webspresso-usage/SKILL.md +2 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth token types and helpers (password reset, email verification)
|
|
3
|
+
* @module core/auth/tokens
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { generateToken, hashToken, hash } = require('./hash');
|
|
7
|
+
|
|
8
|
+
const TOKEN_TYPES = {
|
|
9
|
+
PASSWORD_RESET: 'password_reset',
|
|
10
|
+
EMAIL_VERIFY: 'email_verify',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {import('knex').Knex} knex
|
|
15
|
+
* @returns {Promise<void>}
|
|
16
|
+
*/
|
|
17
|
+
async function createAuthTokensTable(knex) {
|
|
18
|
+
const exists = await knex.schema.hasTable('auth_tokens');
|
|
19
|
+
if (exists) return;
|
|
20
|
+
|
|
21
|
+
await knex.schema.createTable('auth_tokens', (table) => {
|
|
22
|
+
table.bigIncrements('id').primary();
|
|
23
|
+
table.bigInteger('user_id').unsigned().notNullable();
|
|
24
|
+
table.string('token', 64).notNullable().unique();
|
|
25
|
+
table.string('type', 32).notNullable();
|
|
26
|
+
table.timestamp('expires_at').notNullable();
|
|
27
|
+
table.timestamp('created_at').defaultTo(knex.fn.now());
|
|
28
|
+
|
|
29
|
+
table.index(['user_id', 'type']);
|
|
30
|
+
table.index('token');
|
|
31
|
+
table.index('expires_at');
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import('knex').Knex} knex
|
|
37
|
+
* @returns {Promise<void>}
|
|
38
|
+
*/
|
|
39
|
+
async function dropAuthTokensTable(knex) {
|
|
40
|
+
await knex.schema.dropTableIfExists('auth_tokens');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create Knex auth_tokens adapter
|
|
45
|
+
* @param {Object} db - Database with knex
|
|
46
|
+
* @returns {Object}
|
|
47
|
+
*/
|
|
48
|
+
function createKnexAuthTokensAdapter(db) {
|
|
49
|
+
const knex = db.knex || db;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
create: async (userId, tokenHash, type, expiresAt) => {
|
|
53
|
+
await knex('auth_tokens').insert({
|
|
54
|
+
user_id: userId,
|
|
55
|
+
token: tokenHash,
|
|
56
|
+
type,
|
|
57
|
+
expires_at: expiresAt,
|
|
58
|
+
created_at: new Date(),
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
find: async (tokenHash, type) => {
|
|
62
|
+
return knex('auth_tokens').where({ token: tokenHash, type }).first();
|
|
63
|
+
},
|
|
64
|
+
delete: async (tokenHash) => {
|
|
65
|
+
await knex('auth_tokens').where({ token: tokenHash }).delete();
|
|
66
|
+
},
|
|
67
|
+
deleteAllForUser: async (userId, type) => {
|
|
68
|
+
const q = knex('auth_tokens').where({ user_id: userId });
|
|
69
|
+
if (type) q.where({ type });
|
|
70
|
+
await q.delete();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Object} authTokensAdapter
|
|
77
|
+
* @param {string} type
|
|
78
|
+
* @param {*} userId
|
|
79
|
+
* @param {number} ttlMs
|
|
80
|
+
* @returns {Promise<{ rawToken: string, expiresAt: Date }>}
|
|
81
|
+
*/
|
|
82
|
+
async function createAuthToken(authTokensAdapter, type, userId, ttlMs) {
|
|
83
|
+
const rawToken = generateToken(32);
|
|
84
|
+
const tokenHash = hashToken(rawToken);
|
|
85
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
86
|
+
|
|
87
|
+
await authTokensAdapter.deleteAllForUser(userId, type);
|
|
88
|
+
await authTokensAdapter.create(userId, tokenHash, type, expiresAt);
|
|
89
|
+
|
|
90
|
+
return { rawToken, expiresAt };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {Object} authTokensAdapter
|
|
95
|
+
* @param {string} type
|
|
96
|
+
* @param {string} rawToken
|
|
97
|
+
* @returns {Promise<{ userId: *, expiresAt: Date }|null>}
|
|
98
|
+
*/
|
|
99
|
+
async function verifyAuthToken(authTokensAdapter, type, rawToken) {
|
|
100
|
+
if (!rawToken) return null;
|
|
101
|
+
|
|
102
|
+
const tokenHash = hashToken(rawToken);
|
|
103
|
+
const record = await authTokensAdapter.find(tokenHash, type);
|
|
104
|
+
if (!record) return null;
|
|
105
|
+
|
|
106
|
+
if (new Date(record.expires_at) < new Date()) {
|
|
107
|
+
await authTokensAdapter.delete(tokenHash);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
userId: record.user_id,
|
|
113
|
+
expiresAt: new Date(record.expires_at),
|
|
114
|
+
tokenHash,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {Object} authTokensAdapter
|
|
120
|
+
* @param {string} tokenHash
|
|
121
|
+
*/
|
|
122
|
+
async function consumeAuthToken(authTokensAdapter, tokenHash) {
|
|
123
|
+
await authTokensAdapter.delete(tokenHash);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
TOKEN_TYPES,
|
|
128
|
+
createAuthTokensTable,
|
|
129
|
+
dropAuthTokensTable,
|
|
130
|
+
createKnexAuthTokensAdapter,
|
|
131
|
+
createAuthToken,
|
|
132
|
+
verifyAuthToken,
|
|
133
|
+
consumeAuthToken,
|
|
134
|
+
hash,
|
|
135
|
+
};
|
package/core/kernel/app.js
CHANGED
|
@@ -50,7 +50,13 @@ function createApp(options = {}) {
|
|
|
50
50
|
}
|
|
51
51
|
};
|
|
52
52
|
events.on(flowDef.trigger, handler);
|
|
53
|
-
const unregister = () =>
|
|
53
|
+
const unregister = () => {
|
|
54
|
+
events.off(flowDef.trigger, handler);
|
|
55
|
+
const idx = flowHandles.findIndex((h) => h.unregister === unregister);
|
|
56
|
+
if (idx !== -1) {
|
|
57
|
+
flowHandles.splice(idx, 1);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
54
60
|
flowHandles.push({
|
|
55
61
|
id,
|
|
56
62
|
trigger: flowDef.trigger,
|
package/index.d.ts
CHANGED
|
@@ -74,6 +74,15 @@ export interface CreateAppResult {
|
|
|
74
74
|
|
|
75
75
|
export function createApp(options?: CreateAppOptions): CreateAppResult;
|
|
76
76
|
|
|
77
|
+
export function resolveClientRuntime(options?: {
|
|
78
|
+
clientRuntime?: {
|
|
79
|
+
alpine?: boolean | Record<string, unknown>;
|
|
80
|
+
swup?: boolean | Record<string, unknown>;
|
|
81
|
+
};
|
|
82
|
+
}): { alpine: boolean; swup: boolean };
|
|
83
|
+
|
|
84
|
+
export const CLIENT_RUNTIME_BASE: string;
|
|
85
|
+
|
|
77
86
|
// --- App context ---
|
|
78
87
|
|
|
79
88
|
export function attachDbMiddleware(req: Request, res: Response, next: NextFunction): void;
|
|
@@ -595,6 +604,29 @@ export interface RateLimitPluginOptions {
|
|
|
595
604
|
|
|
596
605
|
export function rateLimitPlugin(options?: RateLimitPluginOptions): WebspressoPlugin;
|
|
597
606
|
|
|
607
|
+
export interface CsrfCookieOptions {
|
|
608
|
+
key?: string;
|
|
609
|
+
signed?: boolean;
|
|
610
|
+
path?: string;
|
|
611
|
+
httpOnly?: boolean;
|
|
612
|
+
secure?: boolean;
|
|
613
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
614
|
+
maxAge?: number;
|
|
615
|
+
[key: string]: unknown;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export interface CsrfPluginOptions {
|
|
619
|
+
cookie?: boolean | CsrfCookieOptions;
|
|
620
|
+
sessionKey?: string;
|
|
621
|
+
methods?: string[];
|
|
622
|
+
ignorePaths?: Array<string | RegExp | ((req: Request) => boolean)>;
|
|
623
|
+
global?: boolean;
|
|
624
|
+
errorMessage?: string;
|
|
625
|
+
errorStatus?: number;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function csrfPlugin(options?: CsrfPluginOptions): WebspressoPlugin;
|
|
629
|
+
|
|
598
630
|
export interface ContentPluginOptions {
|
|
599
631
|
db: import('./index').Database;
|
|
600
632
|
adminPath?: string;
|
package/index.js
CHANGED
|
@@ -63,6 +63,7 @@ const {
|
|
|
63
63
|
redirectPlugin,
|
|
64
64
|
rateLimitPlugin,
|
|
65
65
|
contentPlugin,
|
|
66
|
+
csrfPlugin,
|
|
66
67
|
} = require('./plugins');
|
|
67
68
|
|
|
68
69
|
const content = require('./core/content');
|
|
@@ -133,6 +134,7 @@ module.exports = {
|
|
|
133
134
|
redirectPlugin,
|
|
134
135
|
rateLimitPlugin,
|
|
135
136
|
contentPlugin,
|
|
137
|
+
csrfPlugin,
|
|
136
138
|
|
|
137
139
|
// Schema-driven CMS core (framework-agnostic)
|
|
138
140
|
content,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.82",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -67,7 +67,9 @@
|
|
|
67
67
|
"helmet": "^7.2.0",
|
|
68
68
|
"inquirer": "^8.2.6",
|
|
69
69
|
"knex": "^3.1.0",
|
|
70
|
+
"mjml": "^5.4.0",
|
|
70
71
|
"multer": "^2.1.1",
|
|
72
|
+
"nodemailer": "^9.0.3",
|
|
71
73
|
"nunjucks": "^3.2.4",
|
|
72
74
|
"sanitize-html": "^2.17.3",
|
|
73
75
|
"sharp": "^0.33.5",
|
|
@@ -125,6 +127,7 @@
|
|
|
125
127
|
"overrides": {
|
|
126
128
|
"basic-ftp": "^5.3.0",
|
|
127
129
|
"tar": "^7.5.11",
|
|
128
|
-
"undici": "^6.24.0"
|
|
130
|
+
"undici": "^6.24.0",
|
|
131
|
+
"uuid": "^11.1.1"
|
|
129
132
|
}
|
|
130
133
|
}
|
|
@@ -221,6 +221,47 @@ var routes = {
|
|
|
221
221
|
},
|
|
222
222
|
};
|
|
223
223
|
|
|
224
|
+
function wrapCustomPageWithLayout(page, rawComp) {
|
|
225
|
+
if (!rawComp) return createCustomPage(page);
|
|
226
|
+
if (page.layout === false) return rawComp;
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
oninit: function(vnode) {
|
|
230
|
+
if (typeof rawComp.oninit === 'function') return rawComp.oninit.call(this, vnode);
|
|
231
|
+
},
|
|
232
|
+
oncreate: function(vnode) {
|
|
233
|
+
if (typeof rawComp.oncreate === 'function') return rawComp.oncreate.call(this, vnode);
|
|
234
|
+
},
|
|
235
|
+
onupdate: function(vnode) {
|
|
236
|
+
if (typeof rawComp.onupdate === 'function') return rawComp.onupdate.call(this, vnode);
|
|
237
|
+
},
|
|
238
|
+
onbeforeremove: function(vnode) {
|
|
239
|
+
if (typeof rawComp.onbeforeremove === 'function') return rawComp.onbeforeremove.call(this, vnode);
|
|
240
|
+
},
|
|
241
|
+
onremove: function(vnode) {
|
|
242
|
+
if (typeof rawComp.onremove === 'function') return rawComp.onremove.call(this, vnode);
|
|
243
|
+
},
|
|
244
|
+
view: function(vnode) {
|
|
245
|
+
var content;
|
|
246
|
+
if (typeof rawComp.view === 'function') {
|
|
247
|
+
content = rawComp.view.call(this, vnode);
|
|
248
|
+
} else if (typeof rawComp === 'function') {
|
|
249
|
+
content = rawComp.call(this, vnode);
|
|
250
|
+
} else {
|
|
251
|
+
content = rawComp;
|
|
252
|
+
}
|
|
253
|
+
if (content && (content.tag === Layout || (content.tag && content.tag.name === 'Layout'))) {
|
|
254
|
+
return content;
|
|
255
|
+
}
|
|
256
|
+
var breadcrumbs = [{ label: page.title, href: page.path }];
|
|
257
|
+
return m(Layout, [
|
|
258
|
+
m(Breadcrumb, { items: breadcrumbs }),
|
|
259
|
+
content
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
224
265
|
// Dynamic routes for custom pages registered via plugins
|
|
225
266
|
var config = window.__ADMIN_CONFIG__;
|
|
226
267
|
if (config && config.pages) {
|
|
@@ -234,7 +275,7 @@ if (config && config.pages) {
|
|
|
234
275
|
return;
|
|
235
276
|
}
|
|
236
277
|
if (window.__customPages && window.__customPages[page.id]) {
|
|
237
|
-
return window.__customPages[page.id];
|
|
278
|
+
return wrapCustomPageWithLayout(page, window.__customPages[page.id]);
|
|
238
279
|
}
|
|
239
280
|
return createCustomPage(page);
|
|
240
281
|
}
|
|
@@ -245,3 +286,4 @@ if (config && config.pages) {
|
|
|
245
286
|
|
|
246
287
|
m.route(document.getElementById('app'), '/', routes);
|
|
247
288
|
`;
|
|
289
|
+
|
|
@@ -1,25 +1,118 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format client component code to ensure it assigns to window.__customPages[pageId]
|
|
6
|
+
*/
|
|
7
|
+
function formatClientComponent(pageId, rawCode) {
|
|
8
|
+
let code = (rawCode || '').trim();
|
|
9
|
+
if (!code) return '';
|
|
10
|
+
if (code.includes('window.__customPages')) {
|
|
11
|
+
return code;
|
|
12
|
+
}
|
|
13
|
+
if (code.startsWith('module.exports =')) {
|
|
14
|
+
code = code.replace(/^module\.exports\s*=\s*/, '');
|
|
15
|
+
}
|
|
16
|
+
if (code.endsWith(';')) {
|
|
17
|
+
code = code.slice(0, -1);
|
|
18
|
+
}
|
|
19
|
+
return `window.__customPages = window.__customPages || {};\nwindow.__customPages[${JSON.stringify(pageId)}] = ${code};`;
|
|
20
|
+
}
|
|
21
|
+
|
|
1
22
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Declarative API for registering admin pages, menu items, API routes, and widgets
|
|
4
|
-
* @module plugins/admin-panel/core/admin-module
|
|
23
|
+
* Load page definitions and components from a directory structure
|
|
5
24
|
*/
|
|
25
|
+
function loadPagesFromDir(dirPath) {
|
|
26
|
+
const resolvedDir = path.resolve(dirPath);
|
|
27
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
28
|
+
throw new Error(`pagesDir directory not found: ${dirPath}`);
|
|
29
|
+
}
|
|
30
|
+
const stat = fs.statSync(resolvedDir);
|
|
31
|
+
if (!stat.isDirectory()) {
|
|
32
|
+
throw new Error(`pagesDir path is not a directory: ${dirPath}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
|
|
36
|
+
const pages = [];
|
|
37
|
+
const menuItems = [];
|
|
38
|
+
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.name.startsWith('.')) continue;
|
|
41
|
+
|
|
42
|
+
let pageConfig = null;
|
|
43
|
+
let componentFile = null;
|
|
44
|
+
|
|
45
|
+
if (entry.isDirectory()) {
|
|
46
|
+
const folderPath = path.join(resolvedDir, entry.name);
|
|
47
|
+
const jsonCandidate = path.join(folderPath, 'page.json');
|
|
48
|
+
const configJsonCandidate = path.join(folderPath, 'config.json');
|
|
49
|
+
|
|
50
|
+
if (fs.existsSync(jsonCandidate)) {
|
|
51
|
+
pageConfig = JSON.parse(fs.readFileSync(jsonCandidate, 'utf8'));
|
|
52
|
+
} else if (fs.existsSync(configJsonCandidate)) {
|
|
53
|
+
pageConfig = JSON.parse(fs.readFileSync(configJsonCandidate, 'utf8'));
|
|
54
|
+
} else {
|
|
55
|
+
pageConfig = { id: entry.name, title: entry.name, path: '/' + entry.name };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const jsCandidates = ['component.js', 'index.js', 'page.js', `${entry.name}.js`];
|
|
59
|
+
for (const cand of jsCandidates) {
|
|
60
|
+
const fullCandPath = path.join(folderPath, cand);
|
|
61
|
+
if (fs.existsSync(fullCandPath)) {
|
|
62
|
+
componentFile = fullCandPath;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
|
|
67
|
+
const jsonPath = path.join(resolvedDir, entry.name);
|
|
68
|
+
pageConfig = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
|
69
|
+
const baseName = entry.name.slice(0, -5);
|
|
70
|
+
const jsCandidate = path.join(resolvedDir, baseName + '.js');
|
|
71
|
+
if (fs.existsSync(jsCandidate)) {
|
|
72
|
+
componentFile = jsCandidate;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (pageConfig) {
|
|
77
|
+
if (componentFile && !pageConfig.componentFile) {
|
|
78
|
+
pageConfig.componentFile = componentFile;
|
|
79
|
+
}
|
|
80
|
+
if (pageConfig.menu) {
|
|
81
|
+
if (pageConfig.menu === true) {
|
|
82
|
+
menuItems.push({
|
|
83
|
+
id: pageConfig.id,
|
|
84
|
+
label: pageConfig.title,
|
|
85
|
+
path: pageConfig.path,
|
|
86
|
+
icon: pageConfig.icon,
|
|
87
|
+
});
|
|
88
|
+
} else if (typeof pageConfig.menu === 'object') {
|
|
89
|
+
menuItems.push({
|
|
90
|
+
id: pageConfig.id,
|
|
91
|
+
label: pageConfig.title,
|
|
92
|
+
path: pageConfig.path,
|
|
93
|
+
icon: pageConfig.icon,
|
|
94
|
+
...pageConfig.menu,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
pages.push(pageConfig);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { pages, menuItems };
|
|
103
|
+
}
|
|
6
104
|
|
|
7
105
|
/**
|
|
8
106
|
* Register an admin module with all its components in a single declarative call
|
|
9
107
|
* @param {Object} config - Module configuration
|
|
10
108
|
* @param {string} config.id - Unique module identifier (required)
|
|
11
109
|
* @param {Array} [config.pages] - Custom admin pages
|
|
110
|
+
* @param {string} [config.pagesDir] - Directory path containing page folders/configs
|
|
12
111
|
* @param {Array} [config.menu] - Sidebar menu items
|
|
13
112
|
* @param {Object} [config.menuGroups] - Menu group definitions
|
|
14
113
|
* @param {Object} [config.api] - API endpoint definitions
|
|
15
114
|
* @param {Array} [config.widgets] - Dashboard widgets
|
|
16
115
|
* @param {Object} deps - Internal dependencies injected by admin-panel
|
|
17
|
-
* @param {Object} deps.registry - AdminRegistry instance
|
|
18
|
-
* @param {string} deps.adminPath - Admin panel base path
|
|
19
|
-
* @param {Object} deps.ctx - Plugin context
|
|
20
|
-
* @param {Function} deps.requireAuth - Auth middleware
|
|
21
|
-
* @param {Function} deps.optionalAuth - Optional auth middleware
|
|
22
|
-
* @param {Function} deps.serveAdminPanel - SPA HTML handler
|
|
23
116
|
*/
|
|
24
117
|
function registerModule(config, deps) {
|
|
25
118
|
if (!config || typeof config !== 'object') {
|
|
@@ -36,14 +129,36 @@ function registerModule(config, deps) {
|
|
|
36
129
|
registerMenuGroups(config.menuGroups, registry);
|
|
37
130
|
}
|
|
38
131
|
|
|
39
|
-
if (config.pages) {
|
|
40
|
-
|
|
132
|
+
if (config.pages !== undefined) {
|
|
133
|
+
if (!Array.isArray(config.pages)) {
|
|
134
|
+
throw new Error(`Module "${config.id}": pages must be an array`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (config.menu !== undefined) {
|
|
139
|
+
if (!Array.isArray(config.menu)) {
|
|
140
|
+
throw new Error('menu must be an array');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let pagesList = config.pages ? [...config.pages] : [];
|
|
145
|
+
let menuList = config.menu ? [...config.menu] : [];
|
|
146
|
+
|
|
147
|
+
if (config.pagesDir) {
|
|
148
|
+
const { pages: dirPages, menuItems: dirMenuItems } = loadPagesFromDir(config.pagesDir);
|
|
149
|
+
pagesList = pagesList.concat(dirPages);
|
|
150
|
+
menuList = menuList.concat(dirMenuItems);
|
|
41
151
|
}
|
|
42
152
|
|
|
43
|
-
if (
|
|
44
|
-
|
|
153
|
+
if (pagesList.length > 0) {
|
|
154
|
+
registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel });
|
|
45
155
|
}
|
|
46
156
|
|
|
157
|
+
if (menuList.length > 0) {
|
|
158
|
+
registerMenuItems(menuList, registry);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
47
162
|
if (config.api) {
|
|
48
163
|
registerApiRoutes(config.id, config.api, { adminPath, ctx, requireAuth, optionalAuth });
|
|
49
164
|
}
|
|
@@ -53,6 +168,20 @@ function registerModule(config, deps) {
|
|
|
53
168
|
}
|
|
54
169
|
}
|
|
55
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Register custom admin pages from a directory directly
|
|
173
|
+
* @param {string} dirPath - Directory path containing custom page folders/configs
|
|
174
|
+
* @param {Object} deps - Dependencies injected by admin-panel
|
|
175
|
+
*/
|
|
176
|
+
function registerPageDir(dirPath, deps) {
|
|
177
|
+
const { pages, menuItems } = loadPagesFromDir(dirPath);
|
|
178
|
+
const moduleId = path.basename(dirPath);
|
|
179
|
+
registerPages(moduleId, pages, deps);
|
|
180
|
+
if (menuItems.length > 0) {
|
|
181
|
+
registerMenuItems(menuItems, deps.registry);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
56
185
|
function registerPages(moduleId, pages, deps) {
|
|
57
186
|
if (!Array.isArray(pages)) {
|
|
58
187
|
throw new Error(`Module "${moduleId}": pages must be an array`);
|
|
@@ -73,10 +202,20 @@ function registerPages(moduleId, pages, deps) {
|
|
|
73
202
|
icon: page.icon,
|
|
74
203
|
description: page.description,
|
|
75
204
|
permission: page.permission,
|
|
205
|
+
layout: page.layout !== undefined ? page.layout : true,
|
|
76
206
|
});
|
|
77
207
|
|
|
78
|
-
if (page.
|
|
79
|
-
|
|
208
|
+
if (page.componentFile) {
|
|
209
|
+
const resolvedPath = path.resolve(page.componentFile);
|
|
210
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
211
|
+
throw new Error(`Module "${moduleId}", page "${pageId}": componentFile not found at ${page.componentFile}`);
|
|
212
|
+
}
|
|
213
|
+
const rawCode = fs.readFileSync(resolvedPath, 'utf8');
|
|
214
|
+
const formattedCode = formatClientComponent(pageId, rawCode);
|
|
215
|
+
registry.registerClientComponent(pageId, formattedCode);
|
|
216
|
+
} else if (page.component) {
|
|
217
|
+
const formattedCode = formatClientComponent(pageId, page.component);
|
|
218
|
+
registry.registerClientComponent(pageId, formattedCode);
|
|
80
219
|
}
|
|
81
220
|
|
|
82
221
|
ctx.addRoute('get', `${adminPath}${page.path}`, optionalAuth, serveAdminPanel);
|
|
@@ -143,4 +282,5 @@ function registerWidgets(widgets, registry) {
|
|
|
143
282
|
}
|
|
144
283
|
}
|
|
145
284
|
|
|
146
|
-
module.exports = { registerModule };
|
|
285
|
+
module.exports = { registerModule, registerPageDir, loadPagesFromDir, formatClientComponent };
|
|
286
|
+
|
|
@@ -412,9 +412,12 @@ class AdminRegistry {
|
|
|
412
412
|
title: p.title,
|
|
413
413
|
path: p.path,
|
|
414
414
|
icon: p.icon,
|
|
415
|
+
description: p.description,
|
|
415
416
|
permission: p.permission,
|
|
417
|
+
layout: p.layout !== undefined ? p.layout : true,
|
|
416
418
|
hasClientComponent: this.clientComponents.has(p.id),
|
|
417
419
|
})),
|
|
420
|
+
|
|
418
421
|
widgets: this.getWidgets().map(w => ({
|
|
419
422
|
id: w.id,
|
|
420
423
|
title: w.title,
|
|
@@ -223,6 +223,8 @@ module.exports = {
|
|
|
223
223
|
FilesUploadField: {
|
|
224
224
|
oninit: (vnode) => {
|
|
225
225
|
vnode.state.uploading = false;
|
|
226
|
+
vnode.state.draggedIndex = null;
|
|
227
|
+
vnode.state.dragOverIndex = null;
|
|
226
228
|
},
|
|
227
229
|
oncreate: (vnode) => {
|
|
228
230
|
const { name, meta = {} } = vnode.attrs;
|
|
@@ -298,19 +300,63 @@ module.exports = {
|
|
|
298
300
|
|
|
299
301
|
urls.length > 0
|
|
300
302
|
? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
|
|
301
|
-
|
|
303
|
+
const isDragOver = state.dragOverIndex === idx;
|
|
304
|
+
const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
|
|
305
|
+
const cursorClass = readonly ? '' : '.cursor-move';
|
|
306
|
+
const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
|
|
307
|
+
const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
|
|
308
|
+
|
|
309
|
+
return m(baseClasses + cursorClass + borderClasses + bgClasses, {
|
|
310
|
+
draggable: !readonly,
|
|
311
|
+
ondragstart: (e) => {
|
|
312
|
+
state.draggedIndex = idx;
|
|
313
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
314
|
+
e.dataTransfer.setData('text/plain', idx);
|
|
315
|
+
},
|
|
316
|
+
ondragover: (e) => {
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
e.dataTransfer.dropEffect = 'move';
|
|
319
|
+
},
|
|
320
|
+
ondragenter: (e) => {
|
|
321
|
+
state.dragOverIndex = idx;
|
|
322
|
+
},
|
|
323
|
+
ondragleave: (e) => {
|
|
324
|
+
if (state.dragOverIndex === idx) {
|
|
325
|
+
state.dragOverIndex = null;
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
ondrop: (e) => {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
const fromIdx = state.draggedIndex;
|
|
331
|
+
const toIdx = idx;
|
|
332
|
+
if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
|
|
333
|
+
const newUrls = [...urls];
|
|
334
|
+
const [movedItem] = newUrls.splice(fromIdx, 1);
|
|
335
|
+
newUrls.splice(toIdx, 0, movedItem);
|
|
336
|
+
if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
|
|
337
|
+
}
|
|
338
|
+
state.draggedIndex = null;
|
|
339
|
+
state.dragOverIndex = null;
|
|
340
|
+
},
|
|
341
|
+
ondragend: (e) => {
|
|
342
|
+
state.draggedIndex = null;
|
|
343
|
+
state.dragOverIndex = null;
|
|
344
|
+
}
|
|
345
|
+
}, [
|
|
302
346
|
shouldShowImagePreview(url, accept)
|
|
303
|
-
? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
|
|
347
|
+
? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
|
|
304
348
|
m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
|
|
305
349
|
src: url,
|
|
306
350
|
alt: `${label} ${idx + 1}`,
|
|
307
351
|
loading: 'lazy',
|
|
352
|
+
draggable: false,
|
|
308
353
|
})
|
|
309
354
|
)
|
|
310
355
|
: m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
|
|
311
356
|
href: url,
|
|
312
357
|
target: '_blank',
|
|
313
358
|
rel: 'noopener noreferrer',
|
|
359
|
+
draggable: false,
|
|
314
360
|
}, url.substring(url.lastIndexOf('/') + 1) || url),
|
|
315
361
|
!readonly
|
|
316
362
|
? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
|
|
@@ -15,7 +15,8 @@ const { registerDashboardWidgets, generateDashboardComponent } = require('./modu
|
|
|
15
15
|
const { registerDefaultBulkActions, generateBulkActionsComponent } = require('./modules/bulk-actions');
|
|
16
16
|
const { registerDefaultPages, createCustomPageApiHandlers, generateCustomPageComponent } = require('./modules/custom-pages');
|
|
17
17
|
const { registerModelMenuItems, registerSystemMenuItems, generateMenuComponent } = require('./modules/menu');
|
|
18
|
-
const { registerModule } = require('./core/admin-module');
|
|
18
|
+
const { registerModule, registerPageDir } = require('./core/admin-module');
|
|
19
|
+
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Admin Panel Plugin Factory
|
|
@@ -105,6 +106,21 @@ function adminPanelPlugin(options = {}) {
|
|
|
105
106
|
serveAdminPanel,
|
|
106
107
|
});
|
|
107
108
|
},
|
|
109
|
+
registerPageDir(dirPath) {
|
|
110
|
+
const ctx = this.api?._ctx ?? this._ctx;
|
|
111
|
+
if (!ctx) {
|
|
112
|
+
throw new Error('registerPageDir can only be called during or after onRoutesReady');
|
|
113
|
+
}
|
|
114
|
+
return registerPageDir(dirPath, {
|
|
115
|
+
registry,
|
|
116
|
+
adminPath,
|
|
117
|
+
ctx,
|
|
118
|
+
requireAuth,
|
|
119
|
+
optionalAuth,
|
|
120
|
+
serveAdminPanel,
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
|
|
108
124
|
_ctx: null,
|
|
109
125
|
},
|
|
110
126
|
|