@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.
@@ -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",
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 isUserRole(roleId) {
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() === "user";
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
- ${whereClause}
3231
+ LEFT JOIN plank_users ed ON ed.id = e.editor_id
3190
3232
  ORDER BY e.${quotedSortField} ${sortDir}
3191
- LIMIT $1 OFFSET $2`, listValues),
3192
- pool_default.query(`SELECT COUNT(*) as count FROM ${quotedTableName} ${countWhereClause}`, countValues)
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 isUser = await isUserRole(req.user?.roleId);
3250
- const { rows } = await pool_default.query(`SELECT * FROM ${quotedTableName} WHERE id = $1`, [req.params.id]);
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 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" });
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 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" });
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 (isUser && ct.kind === "collection") {
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 scheduled" });
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 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" });
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 (isUser && ct.kind === "collection") {
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 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" });
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 (isUser && ct.kind === "collection") {
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
- two_factor_enabled: rows[0].two_factor_enabled
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
- 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]);
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.16.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/core": "0.16.0",
59
- "@plank-cms/schema": "0.16.0",
60
- "@plank-cms/db": "0.16.0"
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",