@plank-cms/plank 0.16.0 → 0.17.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/dist/admin/assets/index-BUxIRJB1.css +2 -0
- package/dist/admin/assets/index-D7pl1GEz.js +223 -0
- package/dist/admin/index.html +2 -2
- package/dist/index.js +2 -2
- package/dist/migrations/027_roles_contributor_editor.sql +23 -0
- package/dist/migrations/028_editorial_mode_base.sql +49 -0
- package/dist/{server-PGIFNGM5.js → server-P4JVP3GT.js} +245 -59
- package/package.json +4 -4
- package/dist/admin/assets/index-BpfwJogs.css +0 -2
- package/dist/admin/assets/index-Cdi-vHcE.js +0 -223
|
@@ -45,12 +45,26 @@ var DEFAULT_ROLE_PERMISSIONS = {
|
|
|
45
45
|
"settings:webhooks:write",
|
|
46
46
|
"settings:webhooks:delete"
|
|
47
47
|
],
|
|
48
|
-
"
|
|
48
|
+
"Contributor": [
|
|
49
49
|
"content-types:read",
|
|
50
50
|
"entries:read",
|
|
51
51
|
"entries:write",
|
|
52
|
+
"entries:delete",
|
|
52
53
|
"media:read",
|
|
53
54
|
"media:write"
|
|
55
|
+
],
|
|
56
|
+
"Editor": [
|
|
57
|
+
"content-types:read",
|
|
58
|
+
"entries:read",
|
|
59
|
+
"entries:write",
|
|
60
|
+
"entries:delete",
|
|
61
|
+
"media:read",
|
|
62
|
+
"media:write"
|
|
63
|
+
],
|
|
64
|
+
"Viewer": [
|
|
65
|
+
"content-types:read",
|
|
66
|
+
"entries:read",
|
|
67
|
+
"media:read"
|
|
54
68
|
]
|
|
55
69
|
};
|
|
56
70
|
|
|
@@ -2469,7 +2483,7 @@ async function login(req, res) {
|
|
|
2469
2483
|
res.status(429).json({ error: "Too many login attempts. Try again in 15 minutes." });
|
|
2470
2484
|
return;
|
|
2471
2485
|
}
|
|
2472
|
-
const { rows } = await pool_default.query(`SELECT id, email, password, role_id, first_name, last_name, avatar_url, job_title, organization, country, two_factor_enabled, two_factor_secret, session_version
|
|
2486
|
+
const { rows } = await pool_default.query(`SELECT id, email, password, role_id, first_name, last_name, avatar_url, job_title, organization, country, two_factor_enabled, two_factor_secret, enabled, session_version
|
|
2473
2487
|
FROM plank_users
|
|
2474
2488
|
WHERE email = $1`, [email]);
|
|
2475
2489
|
const user = rows[0];
|
|
@@ -2477,6 +2491,10 @@ async function login(req, res) {
|
|
|
2477
2491
|
res.status(401).json({ error: "Invalid credentials" });
|
|
2478
2492
|
return;
|
|
2479
2493
|
}
|
|
2494
|
+
if (!user.enabled) {
|
|
2495
|
+
res.status(403).json({ error: "User is disabled" });
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2480
2498
|
await clearRateLimit("login", rateKey);
|
|
2481
2499
|
if (user.two_factor_enabled && user.two_factor_secret) {
|
|
2482
2500
|
const challengeToken = buildChallengeToken({
|
|
@@ -2520,9 +2538,13 @@ async function loginWithTwoFactor(req, res) {
|
|
|
2520
2538
|
res.status(429).json({ error: "Too many 2FA attempts. Try again in 15 minutes." });
|
|
2521
2539
|
return;
|
|
2522
2540
|
}
|
|
2523
|
-
const { rows } = await pool_default.query(`SELECT id, email, role_id, first_name, last_name, avatar_url, job_title, organization, country, two_factor_enabled, two_factor_secret, password, session_version
|
|
2541
|
+
const { rows } = await pool_default.query(`SELECT id, email, role_id, first_name, last_name, avatar_url, job_title, organization, country, two_factor_enabled, two_factor_secret, password, enabled, session_version
|
|
2524
2542
|
FROM plank_users WHERE id = $1`, [payload.sub]);
|
|
2525
2543
|
const user = rows[0];
|
|
2544
|
+
if (user && !user.enabled) {
|
|
2545
|
+
res.status(403).json({ error: "User is disabled" });
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2526
2548
|
if (!user || !user.two_factor_enabled || !user.two_factor_secret) {
|
|
2527
2549
|
res.status(401).json({ error: "2FA is not enabled for this account" });
|
|
2528
2550
|
return;
|
|
@@ -2664,8 +2686,8 @@ async function authenticate(req, res, next) {
|
|
|
2664
2686
|
if (typeof payload.sv !== "number") {
|
|
2665
2687
|
return { ok: false };
|
|
2666
2688
|
}
|
|
2667
|
-
const { rows } = await pool_default.query("SELECT session_version FROM plank_users WHERE id = $1", [payload.sub]);
|
|
2668
|
-
if (!rows[0] || rows[0].session_version !== payload.sv) {
|
|
2689
|
+
const { rows } = await pool_default.query("SELECT session_version, enabled FROM plank_users WHERE id = $1", [payload.sub]);
|
|
2690
|
+
if (!rows[0] || !rows[0].enabled || rows[0].session_version !== payload.sv) {
|
|
2669
2691
|
return { ok: false };
|
|
2670
2692
|
}
|
|
2671
2693
|
return { ok: true, sessionVersion: rows[0].session_version };
|
|
@@ -2711,6 +2733,25 @@ async function authenticate(req, res, next) {
|
|
|
2711
2733
|
}
|
|
2712
2734
|
}
|
|
2713
2735
|
|
|
2736
|
+
// ../core/dist/lib/editorialMode.js
|
|
2737
|
+
async function isEditorialModeEnabled() {
|
|
2738
|
+
const raw = await getSetting("general", "editorial_mode");
|
|
2739
|
+
return String(raw ?? "false").toLowerCase() === "true";
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// ../core/dist/lib/appModes.js
|
|
2743
|
+
async function resolveAppModes() {
|
|
2744
|
+
return {
|
|
2745
|
+
editorial: await isEditorialModeEnabled()
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// ../core/dist/middlewares/appModes.js
|
|
2750
|
+
async function attachAppModes(req, _res, next) {
|
|
2751
|
+
req.appModes = await resolveAppModes();
|
|
2752
|
+
next();
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2714
2755
|
// ../core/dist/middlewares/authorize.js
|
|
2715
2756
|
function authorize(permission) {
|
|
2716
2757
|
return async (req, res, next) => {
|
|
@@ -3152,11 +3193,17 @@ async function syncManyToMany(entryId, tableName, field, targetIds) {
|
|
|
3152
3193
|
const placeholders = targetIds.map((_3, i2) => `($1, $${i2 + 2})`).join(", ");
|
|
3153
3194
|
await pool_default.query(`INSERT INTO ${quoteIdentifier(jt)} (source_id, target_id) VALUES ${placeholders} ON CONFLICT DO NOTHING`, [entryId, ...targetIds]);
|
|
3154
3195
|
}
|
|
3155
|
-
async function
|
|
3196
|
+
async function isContributorRole(roleId) {
|
|
3156
3197
|
if (!roleId)
|
|
3157
3198
|
return false;
|
|
3158
3199
|
const { rows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [roleId]);
|
|
3159
|
-
return rows[0]?.name?.toLowerCase() === "
|
|
3200
|
+
return rows[0]?.name?.toLowerCase() === "contributor";
|
|
3201
|
+
}
|
|
3202
|
+
async function roleName(roleId) {
|
|
3203
|
+
if (!roleId)
|
|
3204
|
+
return "";
|
|
3205
|
+
const { rows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [roleId]);
|
|
3206
|
+
return rows[0]?.name?.toLowerCase() ?? "";
|
|
3160
3207
|
}
|
|
3161
3208
|
var listEntries = async (req, res) => {
|
|
3162
3209
|
const ct = await findContentTypeBySlug(req.params.slug);
|
|
@@ -3166,7 +3213,6 @@ var listEntries = async (req, res) => {
|
|
|
3166
3213
|
}
|
|
3167
3214
|
assertSafeIdentifier(ct.tableName);
|
|
3168
3215
|
const quotedTableName = quoteIdentifier(ct.tableName);
|
|
3169
|
-
const isUser = await isUserRole(req.user?.roleId);
|
|
3170
3216
|
const page = Math.max(1, parseInt(String(req.query.page ?? 1)));
|
|
3171
3217
|
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? 20))));
|
|
3172
3218
|
const offset = (page - 1) * limit;
|
|
@@ -3177,19 +3223,15 @@ var listEntries = async (req, res) => {
|
|
|
3177
3223
|
const quotedSortField = quoteIdentifier(sortField);
|
|
3178
3224
|
const locale = req.query.locale ? String(req.query.locale) : void 0;
|
|
3179
3225
|
const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
|
|
3180
|
-
const ownCollectionOnly = isUser && ct.kind === "collection";
|
|
3181
|
-
const whereClause = ownCollectionOnly ? "WHERE e.created_by = $3" : "";
|
|
3182
|
-
const countWhereClause = ownCollectionOnly ? "WHERE created_by = $1" : "";
|
|
3183
|
-
const listValues = ownCollectionOnly ? [limit, offset, req.user?.id ?? null] : [limit, offset];
|
|
3184
|
-
const countValues = ownCollectionOnly ? [req.user?.id ?? null] : [];
|
|
3185
3226
|
const [{ rows }, { rows: countRows }] = await Promise.all([
|
|
3186
|
-
pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url
|
|
3227
|
+
pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url,
|
|
3228
|
+
ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
|
|
3187
3229
|
FROM ${quotedTableName} e
|
|
3188
3230
|
LEFT JOIN plank_users u ON u.id = e.created_by
|
|
3189
|
-
|
|
3231
|
+
LEFT JOIN plank_users ed ON ed.id = e.editor_id
|
|
3190
3232
|
ORDER BY e.${quotedSortField} ${sortDir}
|
|
3191
|
-
LIMIT $1 OFFSET $2`,
|
|
3192
|
-
pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName}
|
|
3233
|
+
LIMIT $1 OFFSET $2`, [limit, offset]),
|
|
3234
|
+
pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName}`)
|
|
3193
3235
|
]);
|
|
3194
3236
|
const provider = await getProvider();
|
|
3195
3237
|
function entryMatchesLocale(row, locale2) {
|
|
@@ -3213,6 +3255,10 @@ var listEntries = async (req, res) => {
|
|
|
3213
3255
|
if (key && !key.startsWith("http")) {
|
|
3214
3256
|
resolved._author_avatar_url = await provider.getUrl(key);
|
|
3215
3257
|
}
|
|
3258
|
+
const editorKey = resolved._editor_avatar_url;
|
|
3259
|
+
if (editorKey && !editorKey.startsWith("http")) {
|
|
3260
|
+
resolved._editor_avatar_url = await provider.getUrl(editorKey);
|
|
3261
|
+
}
|
|
3216
3262
|
return normalizeNavigationFields({ ...resolved, ...mmIds }, ct.fields);
|
|
3217
3263
|
}));
|
|
3218
3264
|
let total = parseInt(countRows[0].count);
|
|
@@ -3246,16 +3292,16 @@ var getEntry = async (req, res) => {
|
|
|
3246
3292
|
}
|
|
3247
3293
|
assertSafeIdentifier(ct.tableName);
|
|
3248
3294
|
const quotedTableName = quoteIdentifier(ct.tableName);
|
|
3249
|
-
const
|
|
3250
|
-
|
|
3295
|
+
const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url,
|
|
3296
|
+
ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
|
|
3297
|
+
FROM ${quotedTableName} e
|
|
3298
|
+
LEFT JOIN plank_users u ON u.id = e.created_by
|
|
3299
|
+
LEFT JOIN plank_users ed ON ed.id = e.editor_id
|
|
3300
|
+
WHERE e.id = $1`, [req.params.id]);
|
|
3251
3301
|
if (!rows[0]) {
|
|
3252
3302
|
res.status(404).json({ error: "Entry not found" });
|
|
3253
3303
|
return;
|
|
3254
3304
|
}
|
|
3255
|
-
if (isUser && ct.kind === "collection" && rows[0].created_by !== req.user?.id) {
|
|
3256
|
-
res.status(403).json({ error: "Forbidden" });
|
|
3257
|
-
return;
|
|
3258
|
-
}
|
|
3259
3305
|
const locale = req.query.locale ? String(req.query.locale) : void 0;
|
|
3260
3306
|
const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
|
|
3261
3307
|
const mmIds = await loadManyToManyIds(req.params.id, ct.tableName, ct.fields);
|
|
@@ -3264,6 +3310,9 @@ var getEntry = async (req, res) => {
|
|
|
3264
3310
|
const key = resolved._author_avatar_url;
|
|
3265
3311
|
if (key && !key.startsWith("http"))
|
|
3266
3312
|
resolved._author_avatar_url = await provider.getUrl(key);
|
|
3313
|
+
const editorKey = resolved._editor_avatar_url;
|
|
3314
|
+
if (editorKey && !editorKey.startsWith("http"))
|
|
3315
|
+
resolved._editor_avatar_url = await provider.getUrl(editorKey);
|
|
3267
3316
|
res.json(normalizeNavigationFields({ ...resolved, ...mmIds }, ct.fields));
|
|
3268
3317
|
};
|
|
3269
3318
|
var createEntry = async (req, res) => {
|
|
@@ -3275,9 +3324,9 @@ var createEntry = async (req, res) => {
|
|
|
3275
3324
|
validate(ct, req.body);
|
|
3276
3325
|
assertSafeIdentifier(ct.tableName);
|
|
3277
3326
|
const quotedTableName = quoteIdentifier(ct.tableName);
|
|
3278
|
-
const
|
|
3279
|
-
if (
|
|
3280
|
-
res.status(403).json({ error: "Single types are read-only for
|
|
3327
|
+
const isContributor = await isContributorRole(req.user?.roleId);
|
|
3328
|
+
if (isContributor && ct.kind === "single") {
|
|
3329
|
+
res.status(403).json({ error: "Single types are read-only for Contributor role" });
|
|
3281
3330
|
return;
|
|
3282
3331
|
}
|
|
3283
3332
|
const mmFields = ct.fields.filter((f2) => f2.type === "relation" && (f2.relationType ?? "many-to-one") === "many-to-many" && req.body[f2.name] !== void 0);
|
|
@@ -3385,13 +3434,16 @@ var updateEntry = async (req, res) => {
|
|
|
3385
3434
|
validate(ct, req.body);
|
|
3386
3435
|
assertSafeIdentifier(ct.tableName);
|
|
3387
3436
|
const quotedTableName = quoteIdentifier(ct.tableName);
|
|
3388
|
-
const
|
|
3389
|
-
|
|
3390
|
-
|
|
3437
|
+
const editorialMode = req.appModes?.editorial ?? false;
|
|
3438
|
+
const currentRole = await roleName(req.user?.roleId);
|
|
3439
|
+
const isContributor = currentRole === "contributor";
|
|
3440
|
+
const isEditor = currentRole === "editor";
|
|
3441
|
+
if (isContributor && ct.kind === "single") {
|
|
3442
|
+
res.status(403).json({ error: "Single types are read-only for Contributor role" });
|
|
3391
3443
|
return;
|
|
3392
3444
|
}
|
|
3393
|
-
if (
|
|
3394
|
-
const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
|
|
3445
|
+
if ((isContributor || isEditor) && ct.kind === "collection") {
|
|
3446
|
+
const { rows: authorRows } = await pool_default.query(`SELECT created_by, status FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
|
|
3395
3447
|
if (!authorRows[0]) {
|
|
3396
3448
|
res.status(404).json({ error: "Entry not found" });
|
|
3397
3449
|
return;
|
|
@@ -3400,6 +3452,10 @@ var updateEntry = async (req, res) => {
|
|
|
3400
3452
|
res.status(403).json({ error: "Forbidden" });
|
|
3401
3453
|
return;
|
|
3402
3454
|
}
|
|
3455
|
+
if (editorialMode && isContributor && authorRows[0].status === "in_review") {
|
|
3456
|
+
res.status(403).json({ error: "Entry is currently in review and locked for contributor edits" });
|
|
3457
|
+
return;
|
|
3458
|
+
}
|
|
3403
3459
|
}
|
|
3404
3460
|
const mmFields = ct.fields.filter((f2) => f2.type === "relation" && (f2.relationType ?? "many-to-one") === "many-to-many" && req.body[f2.name] !== void 0);
|
|
3405
3461
|
const fields = ct.fields.filter((f2) => req.body[f2.name] !== void 0 && !isVirtualRelation(f2));
|
|
@@ -3449,9 +3505,9 @@ function buildSnapshotExpr(tableName) {
|
|
|
3449
3505
|
return `(SELECT ${strip} FROM ${quoteIdentifier(tableName)} t WHERE t.id = $1)`;
|
|
3450
3506
|
}
|
|
3451
3507
|
var patchEntryStatus = async (req, res) => {
|
|
3452
|
-
const { status, scheduled_for } = req.body;
|
|
3453
|
-
if (status !== "draft" && status !== "published" && status !== "scheduled") {
|
|
3454
|
-
res.status(400).json({ error: "status must be draft, published, or
|
|
3508
|
+
const { status, scheduled_for, editor_id, review_locked_by_editor, review_rejected } = req.body;
|
|
3509
|
+
if (status !== "draft" && status !== "published" && status !== "scheduled" && status !== "pending" && status !== "in_review") {
|
|
3510
|
+
res.status(400).json({ error: "status must be draft, published, scheduled, pending, or in_review" });
|
|
3455
3511
|
return;
|
|
3456
3512
|
}
|
|
3457
3513
|
if (status === "scheduled") {
|
|
@@ -3471,13 +3527,17 @@ var patchEntryStatus = async (req, res) => {
|
|
|
3471
3527
|
}
|
|
3472
3528
|
assertSafeIdentifier(ct.tableName);
|
|
3473
3529
|
const quotedTableName = quoteIdentifier(ct.tableName);
|
|
3474
|
-
const
|
|
3475
|
-
|
|
3476
|
-
|
|
3530
|
+
const editorialMode = req.appModes?.editorial ?? false;
|
|
3531
|
+
const currentRole = await roleName(req.user?.roleId);
|
|
3532
|
+
const isContributor = currentRole === "contributor";
|
|
3533
|
+
const isAdminRole = currentRole === "admin" || currentRole === "super admin";
|
|
3534
|
+
const isEditorRole = currentRole === "editor";
|
|
3535
|
+
if (isContributor && ct.kind === "single") {
|
|
3536
|
+
res.status(403).json({ error: "Single types are read-only for Contributor role" });
|
|
3477
3537
|
return;
|
|
3478
3538
|
}
|
|
3479
|
-
if (
|
|
3480
|
-
const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
|
|
3539
|
+
if (isContributor && ct.kind === "collection") {
|
|
3540
|
+
const { rows: authorRows } = await pool_default.query(`SELECT created_by, review_locked_by_editor FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
|
|
3481
3541
|
if (!authorRows[0]) {
|
|
3482
3542
|
res.status(404).json({ error: "Entry not found" });
|
|
3483
3543
|
return;
|
|
@@ -3486,6 +3546,18 @@ var patchEntryStatus = async (req, res) => {
|
|
|
3486
3546
|
res.status(403).json({ error: "Forbidden" });
|
|
3487
3547
|
return;
|
|
3488
3548
|
}
|
|
3549
|
+
if (editorialMode && authorRows[0].review_locked_by_editor && status !== "pending") {
|
|
3550
|
+
res.status(403).json({ error: "Entry is currently locked for contributor edits" });
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
if (editorialMode && isContributor && status === "published") {
|
|
3555
|
+
res.status(403).json({ error: "Contributors cannot publish in editorial mode" });
|
|
3556
|
+
return;
|
|
3557
|
+
}
|
|
3558
|
+
if (editorialMode && isContributor && status === "scheduled") {
|
|
3559
|
+
res.status(403).json({ error: "Contributors cannot schedule in editorial mode" });
|
|
3560
|
+
return;
|
|
3489
3561
|
}
|
|
3490
3562
|
let sql;
|
|
3491
3563
|
let values;
|
|
@@ -3496,6 +3568,7 @@ var patchEntryStatus = async (req, res) => {
|
|
|
3496
3568
|
published_data = ${buildSnapshotExpr(ct.tableName)},
|
|
3497
3569
|
published_at = NOW(),
|
|
3498
3570
|
scheduled_for = NULL,
|
|
3571
|
+
review_rejected = FALSE,
|
|
3499
3572
|
updated_at = NOW()
|
|
3500
3573
|
WHERE id = $1
|
|
3501
3574
|
RETURNING *
|
|
@@ -3506,11 +3579,66 @@ var patchEntryStatus = async (req, res) => {
|
|
|
3506
3579
|
UPDATE ${quotedTableName} SET
|
|
3507
3580
|
status = 'scheduled',
|
|
3508
3581
|
scheduled_for = $2,
|
|
3582
|
+
review_rejected = FALSE,
|
|
3509
3583
|
updated_at = NOW()
|
|
3510
3584
|
WHERE id = $1
|
|
3511
3585
|
RETURNING *
|
|
3512
3586
|
`;
|
|
3513
3587
|
values = [req.params.id, scheduled_for];
|
|
3588
|
+
} else if (status === "pending") {
|
|
3589
|
+
sql = `
|
|
3590
|
+
UPDATE ${quotedTableName} SET
|
|
3591
|
+
status = 'pending',
|
|
3592
|
+
review_rejected = COALESCE($2, FALSE),
|
|
3593
|
+
review_locked_by_editor = FALSE,
|
|
3594
|
+
updated_at = NOW()
|
|
3595
|
+
WHERE id = $1
|
|
3596
|
+
RETURNING *
|
|
3597
|
+
`;
|
|
3598
|
+
values = [req.params.id, typeof review_rejected === "boolean" ? review_rejected : false];
|
|
3599
|
+
} else if (status === "in_review") {
|
|
3600
|
+
if (!editorialMode) {
|
|
3601
|
+
res.status(403).json({ error: "In review status requires editorial mode" });
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
if (!isAdminRole && !isEditorRole) {
|
|
3605
|
+
res.status(403).json({ error: "Forbidden" });
|
|
3606
|
+
return;
|
|
3607
|
+
}
|
|
3608
|
+
const requestedEditorId = typeof editor_id === "string" && editor_id.trim().length > 0 ? editor_id : isEditorRole ? req.user?.id ?? null : null;
|
|
3609
|
+
if (isEditorRole && requestedEditorId && requestedEditorId !== req.user?.id) {
|
|
3610
|
+
res.status(403).json({ error: "Editors can only assign themselves" });
|
|
3611
|
+
return;
|
|
3612
|
+
}
|
|
3613
|
+
if (requestedEditorId) {
|
|
3614
|
+
const { rows: editorRows } = await pool_default.query(`SELECT r.name as role_name
|
|
3615
|
+
FROM plank_users u
|
|
3616
|
+
JOIN plank_roles r ON r.id = u.role_id
|
|
3617
|
+
WHERE u.id = $1`, [requestedEditorId]);
|
|
3618
|
+
const targetRole = editorRows[0]?.role_name?.toLowerCase();
|
|
3619
|
+
if (isAdminRole) {
|
|
3620
|
+
if (requestedEditorId !== req.user?.id && targetRole !== "editor") {
|
|
3621
|
+
res.status(403).json({ error: "Admins can assign only themselves or Editors" });
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
} else if (targetRole !== "editor") {
|
|
3625
|
+
res.status(403).json({ error: "Invalid editor assignee" });
|
|
3626
|
+
return;
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
const nextEditorId = requestedEditorId ?? (isEditorRole ? req.user?.id ?? null : null);
|
|
3630
|
+
const lock = typeof review_locked_by_editor === "boolean" ? review_locked_by_editor : false;
|
|
3631
|
+
sql = `
|
|
3632
|
+
UPDATE ${quotedTableName} SET
|
|
3633
|
+
status = 'in_review',
|
|
3634
|
+
editor_id = COALESCE($2, editor_id),
|
|
3635
|
+
review_locked_by_editor = $3,
|
|
3636
|
+
review_rejected = FALSE,
|
|
3637
|
+
updated_at = NOW()
|
|
3638
|
+
WHERE id = $1
|
|
3639
|
+
RETURNING *
|
|
3640
|
+
`;
|
|
3641
|
+
values = [req.params.id, nextEditorId, lock];
|
|
3514
3642
|
} else {
|
|
3515
3643
|
sql = `
|
|
3516
3644
|
UPDATE ${quotedTableName} SET
|
|
@@ -3518,6 +3646,8 @@ var patchEntryStatus = async (req, res) => {
|
|
|
3518
3646
|
published_data = NULL,
|
|
3519
3647
|
published_at = NULL,
|
|
3520
3648
|
scheduled_for = NULL,
|
|
3649
|
+
review_rejected = FALSE,
|
|
3650
|
+
review_locked_by_editor = FALSE,
|
|
3521
3651
|
updated_at = NOW()
|
|
3522
3652
|
WHERE id = $1
|
|
3523
3653
|
RETURNING *
|
|
@@ -3542,12 +3672,14 @@ var deleteEntry = async (req, res) => {
|
|
|
3542
3672
|
}
|
|
3543
3673
|
assertSafeIdentifier(ct.tableName);
|
|
3544
3674
|
const quotedTableName = quoteIdentifier(ct.tableName);
|
|
3545
|
-
const
|
|
3546
|
-
|
|
3547
|
-
|
|
3675
|
+
const currentRole = await roleName(req.user?.roleId);
|
|
3676
|
+
const isContributor = currentRole === "contributor";
|
|
3677
|
+
const isEditor = currentRole === "editor";
|
|
3678
|
+
if (isContributor && ct.kind === "single") {
|
|
3679
|
+
res.status(403).json({ error: "Single types are read-only for Contributor role" });
|
|
3548
3680
|
return;
|
|
3549
3681
|
}
|
|
3550
|
-
if (
|
|
3682
|
+
if ((isContributor || isEditor) && ct.kind === "collection") {
|
|
3551
3683
|
const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
|
|
3552
3684
|
if (!authorRows[0]) {
|
|
3553
3685
|
res.status(404).json({ error: "Entry not found" });
|
|
@@ -3576,13 +3708,15 @@ import { z as z4, flattenError as flattenError4 } from "zod";
|
|
|
3576
3708
|
var CreateUserSchema = z4.object({
|
|
3577
3709
|
email: z4.email(),
|
|
3578
3710
|
password: z4.string().min(8),
|
|
3579
|
-
roleId: z4.string().min(1)
|
|
3711
|
+
roleId: z4.string().min(1),
|
|
3712
|
+
enabled: z4.boolean().optional()
|
|
3580
3713
|
});
|
|
3581
3714
|
var UpdateUserSchema = z4.object({
|
|
3582
3715
|
email: z4.email().optional(),
|
|
3583
3716
|
roleId: z4.string().min(1).optional(),
|
|
3584
3717
|
firstName: z4.string().max(100).nullable().optional(),
|
|
3585
|
-
lastName: z4.string().max(100).nullable().optional()
|
|
3718
|
+
lastName: z4.string().max(100).nullable().optional(),
|
|
3719
|
+
enabled: z4.boolean().optional()
|
|
3586
3720
|
});
|
|
3587
3721
|
var ChangePasswordSchema = z4.object({
|
|
3588
3722
|
currentPassword: z4.string().min(1),
|
|
@@ -3636,7 +3770,7 @@ async function roleNameById(roleId) {
|
|
|
3636
3770
|
return rows[0]?.name ?? null;
|
|
3637
3771
|
}
|
|
3638
3772
|
async function listUsers(_req, res) {
|
|
3639
|
-
const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, r.name as role_name, u.first_name, u.last_name, u.created_at
|
|
3773
|
+
const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, r.name as role_name, u.first_name, u.last_name, u.enabled, u.created_at
|
|
3640
3774
|
FROM plank_users u
|
|
3641
3775
|
JOIN plank_roles r ON r.id = u.role_id
|
|
3642
3776
|
ORDER BY u.created_at DESC`);
|
|
@@ -3644,7 +3778,7 @@ async function listUsers(_req, res) {
|
|
|
3644
3778
|
}
|
|
3645
3779
|
async function getMe(req, res) {
|
|
3646
3780
|
const { rows } = await pool_default.query(`SELECT u.id, u.email, u.role_id, u.first_name, u.last_name, u.avatar_url,
|
|
3647
|
-
u.job_title, u.organization, u.country, u.two_factor_enabled, u.created_at,
|
|
3781
|
+
u.job_title, u.organization, u.country, u.two_factor_enabled, u.enabled, u.created_at,
|
|
3648
3782
|
r.name AS role_name, r.permissions
|
|
3649
3783
|
FROM plank_users u
|
|
3650
3784
|
JOIN plank_roles r ON r.id = u.role_id
|
|
@@ -3654,11 +3788,14 @@ async function getMe(req, res) {
|
|
|
3654
3788
|
return;
|
|
3655
3789
|
}
|
|
3656
3790
|
const resolved = await resolveAvatarUrl(rows[0]);
|
|
3791
|
+
const modes = req.appModes ?? await resolveAppModes();
|
|
3657
3792
|
res.json({
|
|
3658
3793
|
...resolved,
|
|
3659
3794
|
role: rows[0].role_name,
|
|
3660
3795
|
permissions: rows[0].permissions,
|
|
3661
|
-
|
|
3796
|
+
enabled: rows[0].enabled ?? true,
|
|
3797
|
+
two_factor_enabled: rows[0].two_factor_enabled,
|
|
3798
|
+
modes
|
|
3662
3799
|
});
|
|
3663
3800
|
}
|
|
3664
3801
|
async function getTwoFactorStatus(req, res) {
|
|
@@ -3873,17 +4010,20 @@ async function createUser(req, res) {
|
|
|
3873
4010
|
res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
|
|
3874
4011
|
return;
|
|
3875
4012
|
}
|
|
3876
|
-
const { email, password, roleId } = parsed.data;
|
|
4013
|
+
const { email, password, roleId, enabled } = parsed.data;
|
|
3877
4014
|
const requesterRoleName = await roleNameById(req.user.roleId);
|
|
3878
4015
|
const targetRoleName = await roleNameById(roleId);
|
|
3879
4016
|
if (targetRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
|
|
3880
4017
|
res.status(403).json({ error: "Only Super Admin can assign Super Admin role" });
|
|
3881
4018
|
return;
|
|
3882
4019
|
}
|
|
4020
|
+
const editorialMode = req.appModes?.editorial ?? false;
|
|
4021
|
+
const isEditorialExclusiveRole = ["Editor", "Viewer"].includes(targetRoleName ?? "");
|
|
4022
|
+
const nextEnabled = editorialMode || !isEditorialExclusiveRole ? enabled ?? true : false;
|
|
3883
4023
|
const hashed = await bcrypt2.hash(password, 12);
|
|
3884
4024
|
const id = createId();
|
|
3885
|
-
await pool_default.query("INSERT INTO plank_users (id, email, password, role_id) VALUES ($1, $2, $3, $4)", [id, email, hashed, roleId]);
|
|
3886
|
-
res.status(201).json({ id, email, roleId });
|
|
4025
|
+
await pool_default.query("INSERT INTO plank_users (id, email, password, role_id, enabled) VALUES ($1, $2, $3, $4, $5)", [id, email, hashed, roleId, nextEnabled]);
|
|
4026
|
+
res.status(201).json({ id, email, roleId, enabled: nextEnabled });
|
|
3887
4027
|
}
|
|
3888
4028
|
async function updateUser(req, res) {
|
|
3889
4029
|
const parsed = UpdateUserSchema.safeParse(req.body);
|
|
@@ -3891,7 +4031,7 @@ async function updateUser(req, res) {
|
|
|
3891
4031
|
res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
|
|
3892
4032
|
return;
|
|
3893
4033
|
}
|
|
3894
|
-
const { email, roleId, firstName, lastName } = parsed.data;
|
|
4034
|
+
const { email, roleId, firstName, lastName, enabled } = parsed.data;
|
|
3895
4035
|
const requesterRoleName = await roleNameById(req.user.roleId);
|
|
3896
4036
|
const { rows: targetRows } = await pool_default.query("SELECT role_id FROM plank_users WHERE id = $1", [req.params.id]);
|
|
3897
4037
|
if (!targetRows[0]) {
|
|
@@ -3903,20 +4043,30 @@ async function updateUser(req, res) {
|
|
|
3903
4043
|
res.status(403).json({ error: "Only Super Admin can edit Super Admin users" });
|
|
3904
4044
|
return;
|
|
3905
4045
|
}
|
|
4046
|
+
const editorialMode = req.appModes?.editorial ?? false;
|
|
4047
|
+
let resolvedEnabled = enabled;
|
|
3906
4048
|
if (roleId) {
|
|
3907
4049
|
const nextRoleName = await roleNameById(roleId);
|
|
3908
4050
|
if (nextRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
|
|
3909
4051
|
res.status(403).json({ error: "Only Super Admin can assign Super Admin role" });
|
|
3910
4052
|
return;
|
|
3911
4053
|
}
|
|
4054
|
+
if (!editorialMode && ["Editor", "Viewer"].includes(nextRoleName ?? "")) {
|
|
4055
|
+
resolvedEnabled = false;
|
|
4056
|
+
}
|
|
3912
4057
|
}
|
|
3913
4058
|
const { rows } = await pool_default.query(`UPDATE plank_users
|
|
3914
4059
|
SET email = COALESCE($1, email),
|
|
3915
4060
|
role_id = COALESCE($2, role_id),
|
|
3916
4061
|
first_name = COALESCE($3, first_name),
|
|
3917
|
-
last_name = COALESCE($4, last_name)
|
|
3918
|
-
|
|
3919
|
-
|
|
4062
|
+
last_name = COALESCE($4, last_name),
|
|
4063
|
+
enabled = COALESCE($5, enabled),
|
|
4064
|
+
session_version = CASE
|
|
4065
|
+
WHEN $5 IS NOT NULL AND $5 = FALSE AND enabled = TRUE THEN session_version + 1
|
|
4066
|
+
ELSE session_version
|
|
4067
|
+
END
|
|
4068
|
+
WHERE id = $6
|
|
4069
|
+
RETURNING id, email, role_id, first_name, last_name, enabled, created_at`, [email ?? null, roleId ?? null, firstName ?? null, lastName ?? null, resolvedEnabled ?? null, req.params.id]);
|
|
3920
4070
|
if (!rows[0]) {
|
|
3921
4071
|
res.status(404).json({ error: "User not found" });
|
|
3922
4072
|
return;
|
|
@@ -4264,6 +4414,14 @@ async function getNamespaceSettings(req, res) {
|
|
|
4264
4414
|
const settings = await getSettings(namespace);
|
|
4265
4415
|
res.json(maskSettings(namespace, settings));
|
|
4266
4416
|
}
|
|
4417
|
+
async function getAppModes(req, res) {
|
|
4418
|
+
const modes = req.appModes ?? await resolveAppModes();
|
|
4419
|
+
res.json(modes);
|
|
4420
|
+
}
|
|
4421
|
+
async function getEditorialMode(_req, res) {
|
|
4422
|
+
const { editorial: enabled } = await resolveAppModes();
|
|
4423
|
+
res.json({ enabled });
|
|
4424
|
+
}
|
|
4267
4425
|
async function updateNamespaceSettings(req, res) {
|
|
4268
4426
|
const { namespace } = req.params;
|
|
4269
4427
|
const incoming = req.body;
|
|
@@ -4279,6 +4437,13 @@ async function updateNamespaceSettings(req, res) {
|
|
|
4279
4437
|
toSave[key] = value;
|
|
4280
4438
|
}
|
|
4281
4439
|
await setSettings(namespace, toSave);
|
|
4440
|
+
if (namespace === "general" && Object.prototype.hasOwnProperty.call(toSave, "editorial_mode") && String(toSave.editorial_mode).toLowerCase() === "false") {
|
|
4441
|
+
await pool_default.query(`UPDATE plank_users
|
|
4442
|
+
SET enabled = FALSE, session_version = session_version + 1
|
|
4443
|
+
WHERE role_id IN (
|
|
4444
|
+
SELECT id FROM plank_roles WHERE LOWER(name) IN ('editor', 'viewer')
|
|
4445
|
+
)`);
|
|
4446
|
+
}
|
|
4282
4447
|
const updated = await getSettings(namespace);
|
|
4283
4448
|
res.json(maskSettings(namespace, updated));
|
|
4284
4449
|
}
|
|
@@ -4286,6 +4451,7 @@ async function updateNamespaceSettings(req, res) {
|
|
|
4286
4451
|
// ../core/dist/routes/admin.js
|
|
4287
4452
|
var router2 = Router2();
|
|
4288
4453
|
router2.use(authenticate);
|
|
4454
|
+
router2.use(attachAppModes);
|
|
4289
4455
|
router2.get("/content-types", authorize("content-types:read"), listContentTypes);
|
|
4290
4456
|
router2.post("/content-types", authorize("content-types:write"), createContentType);
|
|
4291
4457
|
router2.get("/content-types/:slug", authorize("content-types:read"), getContentType);
|
|
@@ -4334,6 +4500,8 @@ router2.post("/media", authorize("media:write"), upload.array("files", 500), upl
|
|
|
4334
4500
|
router2.get("/media/:id/url", authorize("media:read"), getMediaUrl);
|
|
4335
4501
|
router2.patch("/media/:id", authorize("media:write"), updateMedia);
|
|
4336
4502
|
router2.delete("/media/:id", authorize("media:delete"), deleteMedia);
|
|
4503
|
+
router2.get("/modes", getAppModes);
|
|
4504
|
+
router2.get("/editorial-mode", getEditorialMode);
|
|
4337
4505
|
router2.get("/settings/:namespace", authorize("settings:overview:read"), getNamespaceSettings);
|
|
4338
4506
|
router2.put("/settings/:namespace", authorize("settings:overview:write"), updateNamespaceSettings);
|
|
4339
4507
|
router2.get("/webhooks", authorize("settings:webhooks:read"), listWebhooks);
|
|
@@ -4530,10 +4698,14 @@ async function resolveAuthorAvatars(entries) {
|
|
|
4530
4698
|
if (author?.avatar_url && !author.avatar_url.startsWith("http")) {
|
|
4531
4699
|
author.avatar_url = await provider.getUrl(author.avatar_url);
|
|
4532
4700
|
}
|
|
4701
|
+
const editor = entry.editor;
|
|
4702
|
+
if (editor?.avatar_url && !editor.avatar_url.startsWith("http")) {
|
|
4703
|
+
editor.avatar_url = await provider.getUrl(editor.avatar_url);
|
|
4704
|
+
}
|
|
4533
4705
|
}));
|
|
4534
4706
|
}
|
|
4535
4707
|
function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
|
|
4536
|
-
const { published_data, _author_first_name, _author_last_name, _author_avatar_url, _author_job_title, _author_organization, _author_country, ...rest } = row;
|
|
4708
|
+
const { published_data, _author_first_name, _author_last_name, _author_avatar_url, _author_job_title, _author_organization, _author_country, _editor_first_name, _editor_last_name, _editor_avatar_url, _editor_job_title, _editor_organization, _editor_country, ...rest } = row;
|
|
4537
4709
|
const source = statusParam === "published" && published_data ? published_data : rest;
|
|
4538
4710
|
const effective = { ...source };
|
|
4539
4711
|
if (locale) {
|
|
@@ -4584,6 +4756,14 @@ function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
|
|
|
4584
4756
|
organization: _author_organization ?? null,
|
|
4585
4757
|
country: _author_country ?? null
|
|
4586
4758
|
} : null;
|
|
4759
|
+
out.editor = _editor_first_name || _editor_last_name ? {
|
|
4760
|
+
first_name: _editor_first_name ?? null,
|
|
4761
|
+
last_name: _editor_last_name ?? null,
|
|
4762
|
+
avatar_url: _editor_avatar_url ?? null,
|
|
4763
|
+
job_title: _editor_job_title ?? null,
|
|
4764
|
+
organization: _editor_organization ?? null,
|
|
4765
|
+
country: _editor_country ?? null
|
|
4766
|
+
} : null;
|
|
4587
4767
|
return out;
|
|
4588
4768
|
}
|
|
4589
4769
|
var listPublicEntries = async (req, res) => {
|
|
@@ -4599,9 +4779,11 @@ var listPublicEntries = async (req, res) => {
|
|
|
4599
4779
|
const fallbacks2 = req.query.fallback ? String(req.query.fallback).split(",") : [];
|
|
4600
4780
|
const statusClause = statusParam2 === "published" || statusParam2 === "draft" ? `WHERE e.status = $1` : "";
|
|
4601
4781
|
const values = statusClause ? [statusParam2] : [];
|
|
4602
|
-
const { rows: rows2 } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country
|
|
4782
|
+
const { rows: rows2 } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
|
|
4783
|
+
ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
|
|
4603
4784
|
FROM ${ct.tableName} e
|
|
4604
4785
|
LEFT JOIN plank_users u ON u.id = e.created_by
|
|
4786
|
+
LEFT JOIN plank_users ed ON ed.id = e.editor_id
|
|
4605
4787
|
${statusClause} LIMIT 1`, values);
|
|
4606
4788
|
if (!rows2[0]) {
|
|
4607
4789
|
res.status(404).json({ error: "Not found" });
|
|
@@ -4647,9 +4829,11 @@ var listPublicEntries = async (req, res) => {
|
|
|
4647
4829
|
const limitParam = filterValues.length + 1;
|
|
4648
4830
|
const offsetParam = filterValues.length + 2;
|
|
4649
4831
|
const [{ rows }, { rows: countRows }] = await Promise.all([
|
|
4650
|
-
pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country
|
|
4832
|
+
pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
|
|
4833
|
+
ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
|
|
4651
4834
|
FROM ${ct.tableName} e
|
|
4652
4835
|
LEFT JOIN plank_users u ON u.id = e.created_by
|
|
4836
|
+
LEFT JOIN plank_users ed ON ed.id = e.editor_id
|
|
4653
4837
|
${where} ORDER BY e.${sortField} ${sortDir} LIMIT $${limitParam} OFFSET $${offsetParam}`, [...filterValues, limit, offset]),
|
|
4654
4838
|
pool_default.query(`SELECT COUNT(*) as count FROM ${ct.tableName} e ${where}`, filterValues)
|
|
4655
4839
|
]);
|
|
@@ -4671,9 +4855,11 @@ var getPublicEntry = async (req, res) => {
|
|
|
4671
4855
|
const statusParam = String(req.query.status ?? "published");
|
|
4672
4856
|
const statusClause = statusParam === "published" || statusParam === "draft" ? ` AND e.status = $2` : "";
|
|
4673
4857
|
const values = statusClause ? [req.params.id, statusParam] : [req.params.id];
|
|
4674
|
-
const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country
|
|
4858
|
+
const { rows } = await pool_default.query(`SELECT e.*, u.first_name AS _author_first_name, u.last_name AS _author_last_name, u.avatar_url AS _author_avatar_url, u.job_title AS _author_job_title, u.organization AS _author_organization, u.country AS _author_country,
|
|
4859
|
+
ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url, ed.job_title AS _editor_job_title, ed.organization AS _editor_organization, ed.country AS _editor_country
|
|
4675
4860
|
FROM ${ct.tableName} e
|
|
4676
4861
|
LEFT JOIN plank_users u ON u.id = e.created_by
|
|
4862
|
+
LEFT JOIN plank_users ed ON ed.id = e.editor_id
|
|
4677
4863
|
WHERE e.id = $1${statusClause}`, values);
|
|
4678
4864
|
if (!rows[0]) {
|
|
4679
4865
|
res.status(404).json({ error: "Not found" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plank-cms/plank",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Self-hosted headless CMS. Deploy in minutes on your own infrastructure.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/fs-extra": "^11.0.4",
|
|
57
57
|
"tsup": "^8.5.0",
|
|
58
|
-
"@plank-cms/
|
|
59
|
-
"@plank-cms/
|
|
60
|
-
"@plank-cms/
|
|
58
|
+
"@plank-cms/db": "0.17.0",
|
|
59
|
+
"@plank-cms/core": "0.17.0",
|
|
60
|
+
"@plank-cms/schema": "0.17.0"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
63
63
|
"build": "tsup",
|