@plank-cms/plank 0.16.0 → 0.17.1

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.
@@ -45,12 +45,26 @@ var DEFAULT_ROLE_PERMISSIONS = {
45
45
  "settings:webhooks:write",
46
46
  "settings:webhooks:delete"
47
47
  ],
48
- "User": [
48
+ "Contributor": [
49
49
  "content-types:read",
50
50
  "entries:read",
51
51
  "entries:write",
52
+ "entries:delete",
53
+ "media:read",
54
+ "media:write"
55
+ ],
56
+ "Editor": [
57
+ "content-types:read",
58
+ "entries:read",
59
+ "entries:write",
60
+ "entries:delete",
52
61
  "media:read",
53
62
  "media:write"
63
+ ],
64
+ "Viewer": [
65
+ "content-types:read",
66
+ "entries:read",
67
+ "media:read"
54
68
  ]
55
69
  };
56
70
 
@@ -70,13 +84,17 @@ async function appliedMigrations(client) {
70
84
  return new Set(rows.map((r2) => r2.filename));
71
85
  }
72
86
  async function seedDefaultRoles(client) {
73
- const { rows } = await client.query("SELECT COUNT(*) as count FROM plank_roles");
74
- if (parseInt(rows[0].count) > 0)
75
- return;
87
+ let created = 0;
76
88
  for (const [name, permissions] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
77
- await client.query("INSERT INTO plank_roles (id, name, permissions) VALUES ($1, $2, $3)", [createId(), name, JSON.stringify(permissions)]);
89
+ const result = await client.query(`
90
+ INSERT INTO plank_roles (id, name, permissions)
91
+ VALUES ($1, $2, $3)
92
+ ON CONFLICT (name) DO NOTHING
93
+ `, [createId(), name, JSON.stringify(permissions)]);
94
+ created += result.rowCount ?? 0;
78
95
  }
79
- console.log("[plank/db] Seeded default roles.");
96
+ if (created > 0)
97
+ console.log(`[plank/db] Seeded missing default roles: ${created}.`);
80
98
  }
81
99
  async function migrate() {
82
100
  const client = await pool_default.connect();
@@ -2469,7 +2487,7 @@ async function login(req, res) {
2469
2487
  res.status(429).json({ error: "Too many login attempts. Try again in 15 minutes." });
2470
2488
  return;
2471
2489
  }
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
2490
+ 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
2491
  FROM plank_users
2474
2492
  WHERE email = $1`, [email]);
2475
2493
  const user = rows[0];
@@ -2477,6 +2495,10 @@ async function login(req, res) {
2477
2495
  res.status(401).json({ error: "Invalid credentials" });
2478
2496
  return;
2479
2497
  }
2498
+ if (!user.enabled) {
2499
+ res.status(403).json({ error: "User is disabled" });
2500
+ return;
2501
+ }
2480
2502
  await clearRateLimit("login", rateKey);
2481
2503
  if (user.two_factor_enabled && user.two_factor_secret) {
2482
2504
  const challengeToken = buildChallengeToken({
@@ -2520,9 +2542,13 @@ async function loginWithTwoFactor(req, res) {
2520
2542
  res.status(429).json({ error: "Too many 2FA attempts. Try again in 15 minutes." });
2521
2543
  return;
2522
2544
  }
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
2545
+ 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
2546
  FROM plank_users WHERE id = $1`, [payload.sub]);
2525
2547
  const user = rows[0];
2548
+ if (user && !user.enabled) {
2549
+ res.status(403).json({ error: "User is disabled" });
2550
+ return;
2551
+ }
2526
2552
  if (!user || !user.two_factor_enabled || !user.two_factor_secret) {
2527
2553
  res.status(401).json({ error: "2FA is not enabled for this account" });
2528
2554
  return;
@@ -2602,8 +2628,13 @@ async function register(req, res) {
2602
2628
  const { email, password } = parsed.data;
2603
2629
  const hashed = await bcrypt.hash(password, 12);
2604
2630
  const { rows: roleRows } = await pool_default.query("SELECT id, name FROM plank_roles WHERE name = $1", ["Super Admin"]);
2631
+ const superAdminRole = roleRows[0];
2632
+ if (!superAdminRole) {
2633
+ res.status(500).json({ error: "Super Admin role is not configured." });
2634
+ return;
2635
+ }
2605
2636
  const id = createId();
2606
- await pool_default.query("INSERT INTO plank_users (id, email, password, role_id) VALUES ($1, $2, $3, $4)", [id, email, hashed, roleRows[0].id]);
2637
+ await pool_default.query("INSERT INTO plank_users (id, email, password, role_id) VALUES ($1, $2, $3, $4)", [id, email, hashed, superAdminRole.id]);
2607
2638
  res.status(201).json({ id, email });
2608
2639
  }
2609
2640
 
@@ -2664,8 +2695,8 @@ async function authenticate(req, res, next) {
2664
2695
  if (typeof payload.sv !== "number") {
2665
2696
  return { ok: false };
2666
2697
  }
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) {
2698
+ const { rows } = await pool_default.query("SELECT session_version, enabled FROM plank_users WHERE id = $1", [payload.sub]);
2699
+ if (!rows[0] || !rows[0].enabled || rows[0].session_version !== payload.sv) {
2669
2700
  return { ok: false };
2670
2701
  }
2671
2702
  return { ok: true, sessionVersion: rows[0].session_version };
@@ -2711,6 +2742,25 @@ async function authenticate(req, res, next) {
2711
2742
  }
2712
2743
  }
2713
2744
 
2745
+ // ../core/dist/lib/editorialMode.js
2746
+ async function isEditorialModeEnabled() {
2747
+ const raw = await getSetting("general", "editorial_mode");
2748
+ return String(raw ?? "false").toLowerCase() === "true";
2749
+ }
2750
+
2751
+ // ../core/dist/lib/appModes.js
2752
+ async function resolveAppModes() {
2753
+ return {
2754
+ editorial: await isEditorialModeEnabled()
2755
+ };
2756
+ }
2757
+
2758
+ // ../core/dist/middlewares/appModes.js
2759
+ async function attachAppModes(req, _res, next) {
2760
+ req.appModes = await resolveAppModes();
2761
+ next();
2762
+ }
2763
+
2714
2764
  // ../core/dist/middlewares/authorize.js
2715
2765
  function authorize(permission) {
2716
2766
  return async (req, res, next) => {
@@ -3152,11 +3202,17 @@ async function syncManyToMany(entryId, tableName, field, targetIds) {
3152
3202
  const placeholders = targetIds.map((_3, i2) => `($1, $${i2 + 2})`).join(", ");
3153
3203
  await pool_default.query(`INSERT INTO ${quoteIdentifier(jt)} (source_id, target_id) VALUES ${placeholders} ON CONFLICT DO NOTHING`, [entryId, ...targetIds]);
3154
3204
  }
3155
- async function isUserRole(roleId) {
3205
+ async function isContributorRole(roleId) {
3156
3206
  if (!roleId)
3157
3207
  return false;
3158
3208
  const { rows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [roleId]);
3159
- return rows[0]?.name?.toLowerCase() === "user";
3209
+ return rows[0]?.name?.toLowerCase() === "contributor";
3210
+ }
3211
+ async function roleName(roleId) {
3212
+ if (!roleId)
3213
+ return "";
3214
+ const { rows } = await pool_default.query("SELECT name FROM plank_roles WHERE id = $1", [roleId]);
3215
+ return rows[0]?.name?.toLowerCase() ?? "";
3160
3216
  }
3161
3217
  var listEntries = async (req, res) => {
3162
3218
  const ct = await findContentTypeBySlug(req.params.slug);
@@ -3166,7 +3222,6 @@ var listEntries = async (req, res) => {
3166
3222
  }
3167
3223
  assertSafeIdentifier(ct.tableName);
3168
3224
  const quotedTableName = quoteIdentifier(ct.tableName);
3169
- const isUser = await isUserRole(req.user?.roleId);
3170
3225
  const page = Math.max(1, parseInt(String(req.query.page ?? 1)));
3171
3226
  const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? 20))));
3172
3227
  const offset = (page - 1) * limit;
@@ -3177,19 +3232,15 @@ var listEntries = async (req, res) => {
3177
3232
  const quotedSortField = quoteIdentifier(sortField);
3178
3233
  const locale = req.query.locale ? String(req.query.locale) : void 0;
3179
3234
  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
3235
  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
3236
+ 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,
3237
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
3187
3238
  FROM ${quotedTableName} e
3188
3239
  LEFT JOIN plank_users u ON u.id = e.created_by
3189
- ${whereClause}
3240
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
3190
3241
  ORDER BY e.${quotedSortField} ${sortDir}
3191
- LIMIT $1 OFFSET $2`, listValues),
3192
- pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName} ${countWhereClause}`, countValues)
3242
+ LIMIT $1 OFFSET $2`, [limit, offset]),
3243
+ pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName}`)
3193
3244
  ]);
3194
3245
  const provider = await getProvider();
3195
3246
  function entryMatchesLocale(row, locale2) {
@@ -3213,6 +3264,10 @@ var listEntries = async (req, res) => {
3213
3264
  if (key && !key.startsWith("http")) {
3214
3265
  resolved._author_avatar_url = await provider.getUrl(key);
3215
3266
  }
3267
+ const editorKey = resolved._editor_avatar_url;
3268
+ if (editorKey && !editorKey.startsWith("http")) {
3269
+ resolved._editor_avatar_url = await provider.getUrl(editorKey);
3270
+ }
3216
3271
  return normalizeNavigationFields({ ...resolved, ...mmIds }, ct.fields);
3217
3272
  }));
3218
3273
  let total = parseInt(countRows[0].count);
@@ -3246,16 +3301,16 @@ var getEntry = async (req, res) => {
3246
3301
  }
3247
3302
  assertSafeIdentifier(ct.tableName);
3248
3303
  const quotedTableName = quoteIdentifier(ct.tableName);
3249
- const isUser = await isUserRole(req.user?.roleId);
3250
- const { rows } = await pool_default.query(`SELECT * FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3304
+ 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,
3305
+ ed.first_name AS _editor_first_name, ed.last_name AS _editor_last_name, ed.avatar_url AS _editor_avatar_url
3306
+ FROM ${quotedTableName} e
3307
+ LEFT JOIN plank_users u ON u.id = e.created_by
3308
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
3309
+ WHERE e.id = $1`, [req.params.id]);
3251
3310
  if (!rows[0]) {
3252
3311
  res.status(404).json({ error: "Entry not found" });
3253
3312
  return;
3254
3313
  }
3255
- if (isUser && ct.kind === "collection" && rows[0].created_by !== req.user?.id) {
3256
- res.status(403).json({ error: "Forbidden" });
3257
- return;
3258
- }
3259
3314
  const locale = req.query.locale ? String(req.query.locale) : void 0;
3260
3315
  const fallbacks = req.query.fallback ? String(req.query.fallback).split(",") : [];
3261
3316
  const mmIds = await loadManyToManyIds(req.params.id, ct.tableName, ct.fields);
@@ -3264,6 +3319,9 @@ var getEntry = async (req, res) => {
3264
3319
  const key = resolved._author_avatar_url;
3265
3320
  if (key && !key.startsWith("http"))
3266
3321
  resolved._author_avatar_url = await provider.getUrl(key);
3322
+ const editorKey = resolved._editor_avatar_url;
3323
+ if (editorKey && !editorKey.startsWith("http"))
3324
+ resolved._editor_avatar_url = await provider.getUrl(editorKey);
3267
3325
  res.json(normalizeNavigationFields({ ...resolved, ...mmIds }, ct.fields));
3268
3326
  };
3269
3327
  var createEntry = async (req, res) => {
@@ -3275,9 +3333,9 @@ var createEntry = async (req, res) => {
3275
3333
  validate(ct, req.body);
3276
3334
  assertSafeIdentifier(ct.tableName);
3277
3335
  const quotedTableName = quoteIdentifier(ct.tableName);
3278
- const isUser = await isUserRole(req.user?.roleId);
3279
- if (isUser && ct.kind === "single") {
3280
- res.status(403).json({ error: "Single types are read-only for User role" });
3336
+ const isContributor = await isContributorRole(req.user?.roleId);
3337
+ if (isContributor && ct.kind === "single") {
3338
+ res.status(403).json({ error: "Single types are read-only for Contributor role" });
3281
3339
  return;
3282
3340
  }
3283
3341
  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 +3443,16 @@ var updateEntry = async (req, res) => {
3385
3443
  validate(ct, req.body);
3386
3444
  assertSafeIdentifier(ct.tableName);
3387
3445
  const quotedTableName = quoteIdentifier(ct.tableName);
3388
- const isUser = await isUserRole(req.user?.roleId);
3389
- if (isUser && ct.kind === "single") {
3390
- res.status(403).json({ error: "Single types are read-only for User role" });
3446
+ const editorialMode = req.appModes?.editorial ?? false;
3447
+ const currentRole = await roleName(req.user?.roleId);
3448
+ const isContributor = currentRole === "contributor";
3449
+ const isEditor = currentRole === "editor";
3450
+ if (isContributor && ct.kind === "single") {
3451
+ res.status(403).json({ error: "Single types are read-only for Contributor role" });
3391
3452
  return;
3392
3453
  }
3393
- if (isUser && ct.kind === "collection") {
3394
- const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3454
+ if ((isContributor || isEditor) && ct.kind === "collection") {
3455
+ const { rows: authorRows } = await pool_default.query(`SELECT created_by, status FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3395
3456
  if (!authorRows[0]) {
3396
3457
  res.status(404).json({ error: "Entry not found" });
3397
3458
  return;
@@ -3400,6 +3461,10 @@ var updateEntry = async (req, res) => {
3400
3461
  res.status(403).json({ error: "Forbidden" });
3401
3462
  return;
3402
3463
  }
3464
+ if (editorialMode && isContributor && authorRows[0].status === "in_review") {
3465
+ res.status(403).json({ error: "Entry is currently in review and locked for contributor edits" });
3466
+ return;
3467
+ }
3403
3468
  }
3404
3469
  const mmFields = ct.fields.filter((f2) => f2.type === "relation" && (f2.relationType ?? "many-to-one") === "many-to-many" && req.body[f2.name] !== void 0);
3405
3470
  const fields = ct.fields.filter((f2) => req.body[f2.name] !== void 0 && !isVirtualRelation(f2));
@@ -3449,9 +3514,9 @@ function buildSnapshotExpr(tableName) {
3449
3514
  return `(SELECT ${strip} FROM ${quoteIdentifier(tableName)} t WHERE t.id = $1)`;
3450
3515
  }
3451
3516
  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 scheduled" });
3517
+ const { status, scheduled_for, editor_id, review_locked_by_editor, review_rejected } = req.body;
3518
+ if (status !== "draft" && status !== "published" && status !== "scheduled" && status !== "pending" && status !== "in_review") {
3519
+ res.status(400).json({ error: "status must be draft, published, scheduled, pending, or in_review" });
3455
3520
  return;
3456
3521
  }
3457
3522
  if (status === "scheduled") {
@@ -3471,13 +3536,17 @@ var patchEntryStatus = async (req, res) => {
3471
3536
  }
3472
3537
  assertSafeIdentifier(ct.tableName);
3473
3538
  const quotedTableName = quoteIdentifier(ct.tableName);
3474
- const isUser = await isUserRole(req.user?.roleId);
3475
- if (isUser && ct.kind === "single") {
3476
- res.status(403).json({ error: "Single types are read-only for User role" });
3539
+ const editorialMode = req.appModes?.editorial ?? false;
3540
+ const currentRole = await roleName(req.user?.roleId);
3541
+ const isContributor = currentRole === "contributor";
3542
+ const isAdminRole = currentRole === "admin" || currentRole === "super admin";
3543
+ const isEditorRole = currentRole === "editor";
3544
+ if (isContributor && ct.kind === "single") {
3545
+ res.status(403).json({ error: "Single types are read-only for Contributor role" });
3477
3546
  return;
3478
3547
  }
3479
- if (isUser && ct.kind === "collection") {
3480
- const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3548
+ if (isContributor && ct.kind === "collection") {
3549
+ const { rows: authorRows } = await pool_default.query(`SELECT created_by, review_locked_by_editor FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3481
3550
  if (!authorRows[0]) {
3482
3551
  res.status(404).json({ error: "Entry not found" });
3483
3552
  return;
@@ -3486,6 +3555,18 @@ var patchEntryStatus = async (req, res) => {
3486
3555
  res.status(403).json({ error: "Forbidden" });
3487
3556
  return;
3488
3557
  }
3558
+ if (editorialMode && authorRows[0].review_locked_by_editor && status !== "pending") {
3559
+ res.status(403).json({ error: "Entry is currently locked for contributor edits" });
3560
+ return;
3561
+ }
3562
+ }
3563
+ if (editorialMode && isContributor && status === "published") {
3564
+ res.status(403).json({ error: "Contributors cannot publish in editorial mode" });
3565
+ return;
3566
+ }
3567
+ if (editorialMode && isContributor && status === "scheduled") {
3568
+ res.status(403).json({ error: "Contributors cannot schedule in editorial mode" });
3569
+ return;
3489
3570
  }
3490
3571
  let sql;
3491
3572
  let values;
@@ -3496,6 +3577,7 @@ var patchEntryStatus = async (req, res) => {
3496
3577
  published_data = ${buildSnapshotExpr(ct.tableName)},
3497
3578
  published_at = NOW(),
3498
3579
  scheduled_for = NULL,
3580
+ review_rejected = FALSE,
3499
3581
  updated_at = NOW()
3500
3582
  WHERE id = $1
3501
3583
  RETURNING *
@@ -3506,11 +3588,66 @@ var patchEntryStatus = async (req, res) => {
3506
3588
  UPDATE ${quotedTableName} SET
3507
3589
  status = 'scheduled',
3508
3590
  scheduled_for = $2,
3591
+ review_rejected = FALSE,
3509
3592
  updated_at = NOW()
3510
3593
  WHERE id = $1
3511
3594
  RETURNING *
3512
3595
  `;
3513
3596
  values = [req.params.id, scheduled_for];
3597
+ } else if (status === "pending") {
3598
+ sql = `
3599
+ UPDATE ${quotedTableName} SET
3600
+ status = 'pending',
3601
+ review_rejected = COALESCE($2, FALSE),
3602
+ review_locked_by_editor = FALSE,
3603
+ updated_at = NOW()
3604
+ WHERE id = $1
3605
+ RETURNING *
3606
+ `;
3607
+ values = [req.params.id, typeof review_rejected === "boolean" ? review_rejected : false];
3608
+ } else if (status === "in_review") {
3609
+ if (!editorialMode) {
3610
+ res.status(403).json({ error: "In review status requires editorial mode" });
3611
+ return;
3612
+ }
3613
+ if (!isAdminRole && !isEditorRole) {
3614
+ res.status(403).json({ error: "Forbidden" });
3615
+ return;
3616
+ }
3617
+ const requestedEditorId = typeof editor_id === "string" && editor_id.trim().length > 0 ? editor_id : isEditorRole ? req.user?.id ?? null : null;
3618
+ if (isEditorRole && requestedEditorId && requestedEditorId !== req.user?.id) {
3619
+ res.status(403).json({ error: "Editors can only assign themselves" });
3620
+ return;
3621
+ }
3622
+ if (requestedEditorId) {
3623
+ const { rows: editorRows } = await pool_default.query(`SELECT r.name as role_name
3624
+ FROM plank_users u
3625
+ JOIN plank_roles r ON r.id = u.role_id
3626
+ WHERE u.id = $1`, [requestedEditorId]);
3627
+ const targetRole = editorRows[0]?.role_name?.toLowerCase();
3628
+ if (isAdminRole) {
3629
+ if (requestedEditorId !== req.user?.id && targetRole !== "editor") {
3630
+ res.status(403).json({ error: "Admins can assign only themselves or Editors" });
3631
+ return;
3632
+ }
3633
+ } else if (targetRole !== "editor") {
3634
+ res.status(403).json({ error: "Invalid editor assignee" });
3635
+ return;
3636
+ }
3637
+ }
3638
+ const nextEditorId = requestedEditorId ?? (isEditorRole ? req.user?.id ?? null : null);
3639
+ const lock = typeof review_locked_by_editor === "boolean" ? review_locked_by_editor : false;
3640
+ sql = `
3641
+ UPDATE ${quotedTableName} SET
3642
+ status = 'in_review',
3643
+ editor_id = COALESCE($2, editor_id),
3644
+ review_locked_by_editor = $3,
3645
+ review_rejected = FALSE,
3646
+ updated_at = NOW()
3647
+ WHERE id = $1
3648
+ RETURNING *
3649
+ `;
3650
+ values = [req.params.id, nextEditorId, lock];
3514
3651
  } else {
3515
3652
  sql = `
3516
3653
  UPDATE ${quotedTableName} SET
@@ -3518,6 +3655,8 @@ var patchEntryStatus = async (req, res) => {
3518
3655
  published_data = NULL,
3519
3656
  published_at = NULL,
3520
3657
  scheduled_for = NULL,
3658
+ review_rejected = FALSE,
3659
+ review_locked_by_editor = FALSE,
3521
3660
  updated_at = NOW()
3522
3661
  WHERE id = $1
3523
3662
  RETURNING *
@@ -3542,12 +3681,14 @@ var deleteEntry = async (req, res) => {
3542
3681
  }
3543
3682
  assertSafeIdentifier(ct.tableName);
3544
3683
  const quotedTableName = quoteIdentifier(ct.tableName);
3545
- const isUser = await isUserRole(req.user?.roleId);
3546
- if (isUser && ct.kind === "single") {
3547
- res.status(403).json({ error: "Single types are read-only for User role" });
3684
+ const currentRole = await roleName(req.user?.roleId);
3685
+ const isContributor = currentRole === "contributor";
3686
+ const isEditor = currentRole === "editor";
3687
+ if (isContributor && ct.kind === "single") {
3688
+ res.status(403).json({ error: "Single types are read-only for Contributor role" });
3548
3689
  return;
3549
3690
  }
3550
- if (isUser && ct.kind === "collection") {
3691
+ if ((isContributor || isEditor) && ct.kind === "collection") {
3551
3692
  const { rows: authorRows } = await pool_default.query(`SELECT created_by FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
3552
3693
  if (!authorRows[0]) {
3553
3694
  res.status(404).json({ error: "Entry not found" });
@@ -3576,13 +3717,15 @@ import { z as z4, flattenError as flattenError4 } from "zod";
3576
3717
  var CreateUserSchema = z4.object({
3577
3718
  email: z4.email(),
3578
3719
  password: z4.string().min(8),
3579
- roleId: z4.string().min(1)
3720
+ roleId: z4.string().min(1),
3721
+ enabled: z4.boolean().optional()
3580
3722
  });
3581
3723
  var UpdateUserSchema = z4.object({
3582
3724
  email: z4.email().optional(),
3583
3725
  roleId: z4.string().min(1).optional(),
3584
3726
  firstName: z4.string().max(100).nullable().optional(),
3585
- lastName: z4.string().max(100).nullable().optional()
3727
+ lastName: z4.string().max(100).nullable().optional(),
3728
+ enabled: z4.boolean().optional()
3586
3729
  });
3587
3730
  var ChangePasswordSchema = z4.object({
3588
3731
  currentPassword: z4.string().min(1),
@@ -3636,7 +3779,7 @@ async function roleNameById(roleId) {
3636
3779
  return rows[0]?.name ?? null;
3637
3780
  }
3638
3781
  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
3782
+ 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
3783
  FROM plank_users u
3641
3784
  JOIN plank_roles r ON r.id = u.role_id
3642
3785
  ORDER BY u.created_at DESC`);
@@ -3644,7 +3787,7 @@ async function listUsers(_req, res) {
3644
3787
  }
3645
3788
  async function getMe(req, res) {
3646
3789
  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,
3790
+ u.job_title, u.organization, u.country, u.two_factor_enabled, u.enabled, u.created_at,
3648
3791
  r.name AS role_name, r.permissions
3649
3792
  FROM plank_users u
3650
3793
  JOIN plank_roles r ON r.id = u.role_id
@@ -3654,11 +3797,14 @@ async function getMe(req, res) {
3654
3797
  return;
3655
3798
  }
3656
3799
  const resolved = await resolveAvatarUrl(rows[0]);
3800
+ const modes = req.appModes ?? await resolveAppModes();
3657
3801
  res.json({
3658
3802
  ...resolved,
3659
3803
  role: rows[0].role_name,
3660
3804
  permissions: rows[0].permissions,
3661
- two_factor_enabled: rows[0].two_factor_enabled
3805
+ enabled: rows[0].enabled ?? true,
3806
+ two_factor_enabled: rows[0].two_factor_enabled,
3807
+ modes
3662
3808
  });
3663
3809
  }
3664
3810
  async function getTwoFactorStatus(req, res) {
@@ -3873,17 +4019,20 @@ async function createUser(req, res) {
3873
4019
  res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
3874
4020
  return;
3875
4021
  }
3876
- const { email, password, roleId } = parsed.data;
4022
+ const { email, password, roleId, enabled } = parsed.data;
3877
4023
  const requesterRoleName = await roleNameById(req.user.roleId);
3878
4024
  const targetRoleName = await roleNameById(roleId);
3879
4025
  if (targetRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
3880
4026
  res.status(403).json({ error: "Only Super Admin can assign Super Admin role" });
3881
4027
  return;
3882
4028
  }
4029
+ const editorialMode = req.appModes?.editorial ?? false;
4030
+ const isEditorialExclusiveRole = ["Editor", "Viewer"].includes(targetRoleName ?? "");
4031
+ const nextEnabled = editorialMode || !isEditorialExclusiveRole ? enabled ?? true : false;
3883
4032
  const hashed = await bcrypt2.hash(password, 12);
3884
4033
  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 });
4034
+ 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]);
4035
+ res.status(201).json({ id, email, roleId, enabled: nextEnabled });
3887
4036
  }
3888
4037
  async function updateUser(req, res) {
3889
4038
  const parsed = UpdateUserSchema.safeParse(req.body);
@@ -3891,7 +4040,7 @@ async function updateUser(req, res) {
3891
4040
  res.status(400).json({ errors: flattenError4(parsed.error, (i2) => i2.message) });
3892
4041
  return;
3893
4042
  }
3894
- const { email, roleId, firstName, lastName } = parsed.data;
4043
+ const { email, roleId, firstName, lastName, enabled } = parsed.data;
3895
4044
  const requesterRoleName = await roleNameById(req.user.roleId);
3896
4045
  const { rows: targetRows } = await pool_default.query("SELECT role_id FROM plank_users WHERE id = $1", [req.params.id]);
3897
4046
  if (!targetRows[0]) {
@@ -3903,20 +4052,30 @@ async function updateUser(req, res) {
3903
4052
  res.status(403).json({ error: "Only Super Admin can edit Super Admin users" });
3904
4053
  return;
3905
4054
  }
4055
+ const editorialMode = req.appModes?.editorial ?? false;
4056
+ let resolvedEnabled = enabled;
3906
4057
  if (roleId) {
3907
4058
  const nextRoleName = await roleNameById(roleId);
3908
4059
  if (nextRoleName === "Super Admin" && requesterRoleName !== "Super Admin") {
3909
4060
  res.status(403).json({ error: "Only Super Admin can assign Super Admin role" });
3910
4061
  return;
3911
4062
  }
4063
+ if (!editorialMode && ["Editor", "Viewer"].includes(nextRoleName ?? "")) {
4064
+ resolvedEnabled = false;
4065
+ }
3912
4066
  }
3913
4067
  const { rows } = await pool_default.query(`UPDATE plank_users
3914
4068
  SET email = COALESCE($1, email),
3915
4069
  role_id = COALESCE($2, role_id),
3916
4070
  first_name = COALESCE($3, first_name),
3917
- last_name = COALESCE($4, last_name)
3918
- WHERE id = $5
3919
- RETURNING id, email, role_id, first_name, last_name, created_at`, [email ?? null, roleId ?? null, firstName ?? null, lastName ?? null, req.params.id]);
4071
+ last_name = COALESCE($4, last_name),
4072
+ enabled = COALESCE($5, enabled),
4073
+ session_version = CASE
4074
+ WHEN $5 IS NOT NULL AND $5 = FALSE AND enabled = TRUE THEN session_version + 1
4075
+ ELSE session_version
4076
+ END
4077
+ WHERE id = $6
4078
+ 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
4079
  if (!rows[0]) {
3921
4080
  res.status(404).json({ error: "User not found" });
3922
4081
  return;
@@ -4264,6 +4423,14 @@ async function getNamespaceSettings(req, res) {
4264
4423
  const settings = await getSettings(namespace);
4265
4424
  res.json(maskSettings(namespace, settings));
4266
4425
  }
4426
+ async function getAppModes(req, res) {
4427
+ const modes = req.appModes ?? await resolveAppModes();
4428
+ res.json(modes);
4429
+ }
4430
+ async function getEditorialMode(_req, res) {
4431
+ const { editorial: enabled } = await resolveAppModes();
4432
+ res.json({ enabled });
4433
+ }
4267
4434
  async function updateNamespaceSettings(req, res) {
4268
4435
  const { namespace } = req.params;
4269
4436
  const incoming = req.body;
@@ -4279,6 +4446,13 @@ async function updateNamespaceSettings(req, res) {
4279
4446
  toSave[key] = value;
4280
4447
  }
4281
4448
  await setSettings(namespace, toSave);
4449
+ if (namespace === "general" && Object.prototype.hasOwnProperty.call(toSave, "editorial_mode") && String(toSave.editorial_mode).toLowerCase() === "false") {
4450
+ await pool_default.query(`UPDATE plank_users
4451
+ SET enabled = FALSE, session_version = session_version + 1
4452
+ WHERE role_id IN (
4453
+ SELECT id FROM plank_roles WHERE LOWER(name) IN ('editor', 'viewer')
4454
+ )`);
4455
+ }
4282
4456
  const updated = await getSettings(namespace);
4283
4457
  res.json(maskSettings(namespace, updated));
4284
4458
  }
@@ -4286,6 +4460,7 @@ async function updateNamespaceSettings(req, res) {
4286
4460
  // ../core/dist/routes/admin.js
4287
4461
  var router2 = Router2();
4288
4462
  router2.use(authenticate);
4463
+ router2.use(attachAppModes);
4289
4464
  router2.get("/content-types", authorize("content-types:read"), listContentTypes);
4290
4465
  router2.post("/content-types", authorize("content-types:write"), createContentType);
4291
4466
  router2.get("/content-types/:slug", authorize("content-types:read"), getContentType);
@@ -4334,6 +4509,8 @@ router2.post("/media", authorize("media:write"), upload.array("files", 500), upl
4334
4509
  router2.get("/media/:id/url", authorize("media:read"), getMediaUrl);
4335
4510
  router2.patch("/media/:id", authorize("media:write"), updateMedia);
4336
4511
  router2.delete("/media/:id", authorize("media:delete"), deleteMedia);
4512
+ router2.get("/modes", getAppModes);
4513
+ router2.get("/editorial-mode", getEditorialMode);
4337
4514
  router2.get("/settings/:namespace", authorize("settings:overview:read"), getNamespaceSettings);
4338
4515
  router2.put("/settings/:namespace", authorize("settings:overview:write"), updateNamespaceSettings);
4339
4516
  router2.get("/webhooks", authorize("settings:webhooks:read"), listWebhooks);
@@ -4530,10 +4707,14 @@ async function resolveAuthorAvatars(entries) {
4530
4707
  if (author?.avatar_url && !author.avatar_url.startsWith("http")) {
4531
4708
  author.avatar_url = await provider.getUrl(author.avatar_url);
4532
4709
  }
4710
+ const editor = entry.editor;
4711
+ if (editor?.avatar_url && !editor.avatar_url.startsWith("http")) {
4712
+ editor.avatar_url = await provider.getUrl(editor.avatar_url);
4713
+ }
4533
4714
  }));
4534
4715
  }
4535
4716
  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;
4717
+ 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
4718
  const source = statusParam === "published" && published_data ? published_data : rest;
4538
4719
  const effective = { ...source };
4539
4720
  if (locale) {
@@ -4584,6 +4765,14 @@ function serializeEntry(row, ct, statusParam, locale, fallbacks = []) {
4584
4765
  organization: _author_organization ?? null,
4585
4766
  country: _author_country ?? null
4586
4767
  } : null;
4768
+ out.editor = _editor_first_name || _editor_last_name ? {
4769
+ first_name: _editor_first_name ?? null,
4770
+ last_name: _editor_last_name ?? null,
4771
+ avatar_url: _editor_avatar_url ?? null,
4772
+ job_title: _editor_job_title ?? null,
4773
+ organization: _editor_organization ?? null,
4774
+ country: _editor_country ?? null
4775
+ } : null;
4587
4776
  return out;
4588
4777
  }
4589
4778
  var listPublicEntries = async (req, res) => {
@@ -4599,9 +4788,11 @@ var listPublicEntries = async (req, res) => {
4599
4788
  const fallbacks2 = req.query.fallback ? String(req.query.fallback).split(",") : [];
4600
4789
  const statusClause = statusParam2 === "published" || statusParam2 === "draft" ? `WHERE e.status = $1` : "";
4601
4790
  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
4791
+ 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,
4792
+ 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
4793
  FROM ${ct.tableName} e
4604
4794
  LEFT JOIN plank_users u ON u.id = e.created_by
4795
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
4605
4796
  ${statusClause} LIMIT 1`, values);
4606
4797
  if (!rows2[0]) {
4607
4798
  res.status(404).json({ error: "Not found" });
@@ -4647,9 +4838,11 @@ var listPublicEntries = async (req, res) => {
4647
4838
  const limitParam = filterValues.length + 1;
4648
4839
  const offsetParam = filterValues.length + 2;
4649
4840
  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
4841
+ 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,
4842
+ 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
4843
  FROM ${ct.tableName} e
4652
4844
  LEFT JOIN plank_users u ON u.id = e.created_by
4845
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
4653
4846
  ${where} ORDER BY e.${sortField} ${sortDir} LIMIT $${limitParam} OFFSET $${offsetParam}`, [...filterValues, limit, offset]),
4654
4847
  pool_default.query(`SELECT COUNT(*) as count FROM ${ct.tableName} e ${where}`, filterValues)
4655
4848
  ]);
@@ -4671,9 +4864,11 @@ var getPublicEntry = async (req, res) => {
4671
4864
  const statusParam = String(req.query.status ?? "published");
4672
4865
  const statusClause = statusParam === "published" || statusParam === "draft" ? ` AND e.status = $2` : "";
4673
4866
  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
4867
+ 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,
4868
+ 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
4869
  FROM ${ct.tableName} e
4676
4870
  LEFT JOIN plank_users u ON u.id = e.created_by
4871
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
4677
4872
  WHERE e.id = $1${statusClause}`, values);
4678
4873
  if (!rows[0]) {
4679
4874
  res.status(404).json({ error: "Not found" });