@easy-cms/core 0.32.0 → 0.33.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +161 -5
  2. package/dist/index.js +652 -195
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -326,6 +326,7 @@ var SCHEDULED_JOBS = "scheduled-jobs";
326
326
  var WEBHOOK_DELIVERIES = "webhook-deliveries";
327
327
  var EMAIL_DELIVERIES = "email-deliveries";
328
328
  var DATABASE_BACKUPS = "database-backups";
329
+ var ROLES = "user-roles";
329
330
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
330
331
  SESSIONS,
331
332
  LOGIN_ATTEMPTS,
@@ -333,7 +334,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
333
334
  SCHEDULED_JOBS,
334
335
  WEBHOOK_DELIVERIES,
335
336
  EMAIL_DELIVERIES,
336
- DATABASE_BACKUPS
337
+ DATABASE_BACKUPS,
338
+ ROLES
337
339
  ]);
338
340
  var DEFAULT_ROLES = ["admin", "editor"];
339
341
  var adminOrSelf = ({ user }) => {
@@ -344,7 +346,8 @@ var adminOrSelf = ({ user }) => {
344
346
  var adminOnly = {
345
347
  update: ({ user }) => user?.role === "admin"
346
348
  };
347
- function userFields(roles) {
349
+ function userFields(roles, rbac) {
350
+ const defaultRole = roles.includes("editor") ? "editor" : roles[roles.length - 1];
348
351
  return [
349
352
  {
350
353
  name: "email",
@@ -354,12 +357,20 @@ function userFields(roles) {
354
357
  label: { en: "Email", th: "\u0E2D\u0E35\u0E40\u0E21\u0E25" }
355
358
  },
356
359
  { name: "name", type: "text", label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" } },
357
- {
360
+ // With roles from the admin (`auth.rbac`), any role in Settings → Roles: checked on save.
361
+ rbac ? {
362
+ name: "role",
363
+ type: "text",
364
+ required: true,
365
+ defaultValue: defaultRole,
366
+ access: adminOnly,
367
+ label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
368
+ } : {
358
369
  name: "role",
359
370
  type: "select",
360
371
  options: roles,
361
372
  required: true,
362
- defaultValue: roles.includes("editor") ? "editor" : roles[roles.length - 1],
373
+ defaultValue: defaultRole,
363
374
  access: adminOnly,
364
375
  label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
365
376
  },
@@ -382,7 +393,7 @@ function withUsers(config) {
382
393
  useAsTitle: "email",
383
394
  icon: "users",
384
395
  ...custom,
385
- fields: [...userFields(roles), ...custom?.fields ?? []],
396
+ fields: [...userFields(roles, config.auth?.rbac === true), ...custom?.fields ?? []],
386
397
  access: {
387
398
  read: isLoggedIn,
388
399
  create: isAdmin,
@@ -1690,7 +1701,7 @@ function parseId(value) {
1690
1701
  async function validateFields(fields, input, options, prefix = "") {
1691
1702
  const data = {};
1692
1703
  const errors = [];
1693
- const references = [];
1704
+ const references2 = [];
1694
1705
  for (const field of fields) {
1695
1706
  const path = prefix + field.name;
1696
1707
  const raw = input[field.name];
@@ -1708,7 +1719,7 @@ async function validateFields(fields, input, options, prefix = "") {
1708
1719
  );
1709
1720
  data[field.name] = sub.data;
1710
1721
  errors.push(...sub.errors);
1711
- references.push(...sub.references);
1722
+ references2.push(...sub.references);
1712
1723
  continue;
1713
1724
  }
1714
1725
  const localization = options.localization;
@@ -1738,7 +1749,7 @@ async function validateFields(fields, input, options, prefix = "") {
1738
1749
  options,
1739
1750
  failLocale,
1740
1751
  errors,
1741
- references
1752
+ references2
1742
1753
  );
1743
1754
  if (normalized === void 0) continue;
1744
1755
  out[locale] = normalized;
@@ -1755,12 +1766,12 @@ async function validateFields(fields, input, options, prefix = "") {
1755
1766
  if (!field.required) await runCustom(field, null, options, fail);
1756
1767
  continue;
1757
1768
  }
1758
- const value = await normalizeValue(field, raw, path, options, fail, errors, references);
1769
+ const value = await normalizeValue(field, raw, path, options, fail, errors, references2);
1759
1770
  if (value === void 0) continue;
1760
1771
  data[field.name] = value;
1761
1772
  await runCustom(field, value, options, fail);
1762
1773
  }
1763
- return { data, errors, references };
1774
+ return { data, errors, references: references2 };
1764
1775
  }
1765
1776
  async function runCustom(field, value, options, fail) {
1766
1777
  if (!field.validate) return;
@@ -1768,7 +1779,7 @@ async function runCustom(field, value, options, fail) {
1768
1779
  const result = await validate(value, { data: options.root, operation: options.operation });
1769
1780
  if (result !== true) fail(typeof result === "string" ? result : "is invalid");
1770
1781
  }
1771
- async function normalizeValue(field, raw, path, options, fail, errors, references) {
1782
+ async function normalizeValue(field, raw, path, options, fail, errors, references2) {
1772
1783
  switch (field.type) {
1773
1784
  case "text":
1774
1785
  case "textarea":
@@ -1830,7 +1841,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
1830
1841
  if (id === void 0)
1831
1842
  return fail(`must be ${many ? "ids" : "an id"} of "${target}" documents`);
1832
1843
  ids.push(id);
1833
- references.push({
1844
+ references2.push({
1834
1845
  field: path,
1835
1846
  collection: target,
1836
1847
  id,
@@ -1854,19 +1865,19 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
1854
1865
  }
1855
1866
  const sub = await validateFields(field.fields, row, options, `${path}.${i}.`);
1856
1867
  errors.push(...sub.errors);
1857
- references.push(...sub.references);
1868
+ references2.push(...sub.references);
1858
1869
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
1859
1870
  rows.push({ id, ...sub.data });
1860
1871
  }
1861
1872
  return rows;
1862
1873
  }
1863
1874
  case "blocks":
1864
- return normalizeBlocks(field, raw, path, options, fail, errors, references);
1875
+ return normalizeBlocks(field, raw, path, options, fail, errors, references2);
1865
1876
  case "group":
1866
1877
  return raw;
1867
1878
  }
1868
1879
  }
1869
- async function normalizeBlocks(field, raw, path, options, fail, errors, references) {
1880
+ async function normalizeBlocks(field, raw, path, options, fail, errors, references2) {
1870
1881
  if (!Array.isArray(raw)) return fail("must be an array of blocks");
1871
1882
  if (field.minRows !== void 0 && raw.length < field.minRows)
1872
1883
  return fail(`must have at least ${field.minRows} blocks`);
@@ -1884,7 +1895,7 @@ async function normalizeBlocks(field, raw, path, options, fail, errors, referenc
1884
1895
  }
1885
1896
  const sub = await validateFields(fields, row, options, `${path}.${i}.`);
1886
1897
  errors.push(...sub.errors);
1887
- references.push(...sub.references);
1898
+ references2.push(...sub.references);
1888
1899
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
1889
1900
  rows.push({ id, blockType: row.blockType, ...sub.data });
1890
1901
  }
@@ -2168,6 +2179,487 @@ import { existsSync } from "fs";
2168
2179
  import { resolve as resolve2 } from "path";
2169
2180
  import { createJiti } from "jiti";
2170
2181
 
2182
+ // src/versions.ts
2183
+ var DEFAULT_MAX_VERSIONS = 50;
2184
+ function versionLimit(config) {
2185
+ const versions = config.versions;
2186
+ if (!versions) return void 0;
2187
+ return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
2188
+ }
2189
+ var collectionParent = (slug2) => slug2;
2190
+ var globalParent = (slug2) => `global:${slug2}`;
2191
+ function snapshotOf(fields, doc) {
2192
+ const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
2193
+ const snapshot = {};
2194
+ for (const [key2, value] of Object.entries(doc)) {
2195
+ if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
2196
+ snapshot[key2] = value;
2197
+ }
2198
+ return snapshot;
2199
+ }
2200
+ var toSummary = (row) => ({
2201
+ id: row.id,
2202
+ status: row.status ?? null,
2203
+ latest: row.latest === true,
2204
+ author: row.author ?? null,
2205
+ createdAt: String(row.createdAt)
2206
+ });
2207
+ var VersionStore = class {
2208
+ constructor(db) {
2209
+ this.db = db;
2210
+ }
2211
+ db;
2212
+ /** Saves a new latest version and deletes the oldest beyond `max`. */
2213
+ async save(args) {
2214
+ const { parent, doc } = args;
2215
+ const previous = await this.db.find({
2216
+ collection: VERSIONS,
2217
+ where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
2218
+ sort: [],
2219
+ limit: 0,
2220
+ page: 1
2221
+ });
2222
+ for (const row of previous.docs) {
2223
+ await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
2224
+ }
2225
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2226
+ const created = await this.db.create({
2227
+ collection: VERSIONS,
2228
+ data: {
2229
+ parent,
2230
+ doc,
2231
+ status: args.status,
2232
+ latest: true,
2233
+ author: args.author,
2234
+ snapshot: args.snapshot,
2235
+ createdAt: now,
2236
+ updatedAt: now
2237
+ }
2238
+ });
2239
+ await this.prune(parent, doc, args.max);
2240
+ return toSummary(created);
2241
+ }
2242
+ /** The latest version of each document, keyed by document id. */
2243
+ async latest(parent, docs) {
2244
+ const result = /* @__PURE__ */ new Map();
2245
+ if (docs.length === 0) return result;
2246
+ const rows = await this.db.find({
2247
+ collection: VERSIONS,
2248
+ where: {
2249
+ and: [
2250
+ { parent: { equals: parent } },
2251
+ { doc: { in: [...docs] } },
2252
+ { latest: { equals: true } }
2253
+ ]
2254
+ },
2255
+ sort: [],
2256
+ limit: 0,
2257
+ page: 1
2258
+ });
2259
+ for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
2260
+ return result;
2261
+ }
2262
+ async list(parent, doc, limit, page) {
2263
+ const found = await this.db.find({
2264
+ collection: VERSIONS,
2265
+ where: { and: this.of(parent, doc) },
2266
+ sort: ["-id"],
2267
+ limit,
2268
+ page
2269
+ });
2270
+ return { ...found, docs: found.docs.map(toSummary) };
2271
+ }
2272
+ async get(parent, doc, id) {
2273
+ const found = await this.db.find({
2274
+ collection: VERSIONS,
2275
+ where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
2276
+ sort: [],
2277
+ limit: 1,
2278
+ page: 1
2279
+ });
2280
+ const row = found.docs[0];
2281
+ return row ? this.toVersion(row) : null;
2282
+ }
2283
+ async deleteAll(parent, doc) {
2284
+ const rows = await this.db.find({
2285
+ collection: VERSIONS,
2286
+ where: { and: this.of(parent, doc) },
2287
+ sort: [],
2288
+ limit: 0,
2289
+ page: 1
2290
+ });
2291
+ for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
2292
+ }
2293
+ async prune(parent, doc, max) {
2294
+ const rows = await this.db.find({
2295
+ collection: VERSIONS,
2296
+ where: { and: this.of(parent, doc) },
2297
+ sort: ["-id"],
2298
+ limit: 0,
2299
+ page: 1
2300
+ });
2301
+ for (const row of rows.docs.slice(max)) {
2302
+ await this.db.delete({ collection: VERSIONS, id: row.id });
2303
+ }
2304
+ }
2305
+ of(parent, doc) {
2306
+ return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
2307
+ }
2308
+ toVersion(row) {
2309
+ return { ...toSummary(row), data: row.snapshot ?? {} };
2310
+ }
2311
+ };
2312
+
2313
+ // src/roles.ts
2314
+ var ROLE_OPERATIONS = ["read", "create", "update", "delete", "publish"];
2315
+ var GLOBAL_OPERATIONS = ["read", "update", "publish"];
2316
+ var KEY = /^[a-z][a-z0-9-]{0,31}$/;
2317
+ var HISTORY = 50;
2318
+ var TTL = 3e4;
2319
+ var rolesCollection = {
2320
+ slug: ROLES,
2321
+ access: { read: () => false, create: () => false, update: () => false, delete: () => false },
2322
+ fields: [
2323
+ { name: "key", type: "text", required: true, unique: true },
2324
+ { name: "name", type: "text" },
2325
+ { name: "permissions", type: "json" }
2326
+ ]
2327
+ };
2328
+ function governed(slug2) {
2329
+ return !INTERNAL_COLLECTIONS.has(slug2) && slug2 !== API_KEYS;
2330
+ }
2331
+ function references(fields, out = /* @__PURE__ */ new Set()) {
2332
+ for (const field of fields) {
2333
+ if (field.type === "relationship") out.add(field.to);
2334
+ else if (field.type === "upload") out.add("media");
2335
+ else if (field.type === "group" || field.type === "array") references(field.fields, out);
2336
+ else if (field.type === "blocks") for (const b of field.blocks) references(b.fields, out);
2337
+ }
2338
+ return out;
2339
+ }
2340
+ var viewId = {
2341
+ page: (path) => `page:${path}`,
2342
+ widget: (tag) => `widget:${tag}`
2343
+ };
2344
+ var tagOf = (component) => typeof component === "string" ? component : component.tag;
2345
+ var Roles = class {
2346
+ constructor(config, db) {
2347
+ this.config = config;
2348
+ this.db = db;
2349
+ this.enabled = config.auth.rbac;
2350
+ this.versions = new VersionStore(db);
2351
+ }
2352
+ config;
2353
+ db;
2354
+ enabled;
2355
+ versions;
2356
+ cache;
2357
+ synced;
2358
+ /**
2359
+ * Whether the user's role allows an operation: `true`, `false`, or for their own account
2360
+ * (users without read or update on `users`) a constraint to it.
2361
+ */
2362
+ async allows(user, target, operation) {
2363
+ if (!this.enabled || !user || user.role === "admin") return true;
2364
+ if ("collection" in target && !governed(target.collection)) return true;
2365
+ const permissions = await this.permissionsOf(user.role);
2366
+ const ops = "collection" in target ? permissions.collections?.[target.collection] : permissions.globals?.[target.global];
2367
+ if (ops?.includes(operation)) return true;
2368
+ if ("collection" in target && target.collection === USERS) {
2369
+ if (operation === "read" || operation === "update") return { id: { equals: user.id } };
2370
+ }
2371
+ return false;
2372
+ }
2373
+ /**
2374
+ * Whether the user may open an admin page: admins always; with roles, those whose role has
2375
+ * it; without roles, nobody else (`status`, `deliveries`) or everybody (plugin pages).
2376
+ */
2377
+ async canView(user, id) {
2378
+ if (user.role === "admin" && !user.apiKey) return true;
2379
+ if (user.apiKey) return false;
2380
+ if (!this.enabled) return id.startsWith("page:") || id.startsWith("widget:");
2381
+ return (await this.permissionsOf(user.role)).admin?.includes(id) ?? false;
2382
+ }
2383
+ /** Forgets cached permissions, after a role changed. */
2384
+ invalidate() {
2385
+ this.cache = void 0;
2386
+ }
2387
+ /**
2388
+ * Adds the roles of `auth.roles` that are missing; on the first start, gives them everything.
2389
+ * Runs once, on first use (not on startup: `easy-cms migrate` opens the CMS before the table exists).
2390
+ */
2391
+ sync() {
2392
+ this.synced ??= this.addMissing().catch((error) => {
2393
+ this.synced = void 0;
2394
+ throw error;
2395
+ });
2396
+ return this.synced;
2397
+ }
2398
+ async addMissing() {
2399
+ if (!this.enabled) return;
2400
+ const rows = await this.rows();
2401
+ const first = rows.length === 0;
2402
+ const known = new Set(rows.map((r) => String(r.key)));
2403
+ for (const key2 of this.config.auth.roles) {
2404
+ if (known.has(key2)) continue;
2405
+ try {
2406
+ await this.insert(key2, "", first && key2 !== "admin" ? this.everything() : {}, null);
2407
+ } catch (error) {
2408
+ if (!(await this.rows()).some((r) => r.key === key2)) throw error;
2409
+ }
2410
+ }
2411
+ this.invalidate();
2412
+ }
2413
+ /** Whether a role exists (users can only be given existing roles). */
2414
+ async exists(key2) {
2415
+ if (!this.enabled) return this.config.auth.roles.includes(key2);
2416
+ return (await this.load()).has(key2);
2417
+ }
2418
+ /** Roles a user can be given, those of `auth.roles` first; `name` may be empty. */
2419
+ async options() {
2420
+ if (!this.enabled) return this.config.auth.roles.map((key2) => ({ key: key2, name: "" }));
2421
+ await this.sync();
2422
+ return (await this.rows()).map((row) => ({ key: String(row.key), name: typeof row.name === "string" ? row.name : "" })).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
2423
+ }
2424
+ // --- Settings → Roles ----------------------------------------------------
2425
+ async list() {
2426
+ await this.sync();
2427
+ const rows = await this.rows();
2428
+ const counts = /* @__PURE__ */ new Map();
2429
+ for (const row of rows) {
2430
+ counts.set(
2431
+ String(row.key),
2432
+ await this.db.count({ collection: USERS, where: { role: { equals: row.key } } })
2433
+ );
2434
+ }
2435
+ const roles = rows.map((row) => this.toRole(row, counts.get(String(row.key)) ?? 0)).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
2436
+ const decided = roles.filter((r) => r.key !== "admin").map((r) => r.permissions);
2437
+ const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
2438
+ return {
2439
+ roles,
2440
+ collections: this.config.collections.filter((c) => governed(c.slug)).map((c) => ({
2441
+ slug: c.slug,
2442
+ ops: ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true),
2443
+ new: !seen("collections", c.slug),
2444
+ references: [...references(c.fields)].filter((s) => s !== c.slug && governed(s))
2445
+ })),
2446
+ globals: this.config.globals.map((g) => ({
2447
+ slug: g.slug,
2448
+ ops: GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true),
2449
+ new: !seen("globals", g.slug)
2450
+ })),
2451
+ views: this.views()
2452
+ };
2453
+ }
2454
+ async create(input, author) {
2455
+ await this.sync();
2456
+ const key2 = typeof input.key === "string" ? input.key.trim() : "";
2457
+ if (!KEY.test(key2)) {
2458
+ throw new ValidationError(ROLES, [
2459
+ { field: "key", message: 'lowercase letters, digits and "-", starting with a letter' }
2460
+ ]);
2461
+ }
2462
+ if ((await this.rows()).some((r) => r.key === key2))
2463
+ throw new ValidationError(ROLES, [{ field: "key", message: "is already used" }]);
2464
+ const row = await this.insert(
2465
+ key2,
2466
+ this.cleanName(input.name),
2467
+ this.clean(input.permissions),
2468
+ author.id
2469
+ );
2470
+ return this.toRole(row, 0);
2471
+ }
2472
+ async update(id, input, author) {
2473
+ const row = await this.row(id);
2474
+ const data = { ...row, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
2475
+ if (input.name !== void 0) data.name = this.cleanName(input.name);
2476
+ if (input.permissions !== void 0) {
2477
+ if (row.key === "admin")
2478
+ throw new EasyCMSError("Admins may always do everything; their role cannot be changed", 400);
2479
+ data.permissions = this.clean(input.permissions);
2480
+ }
2481
+ const { id: _id, ...rest } = data;
2482
+ const saved = await this.db.update({ collection: ROLES, id: row.id, data: rest });
2483
+ await this.record(saved, author.id);
2484
+ this.invalidate();
2485
+ return this.toRole(saved, await this.users(String(row.key)));
2486
+ }
2487
+ async delete(id) {
2488
+ const row = await this.row(id);
2489
+ const key2 = String(row.key);
2490
+ if (this.config.auth.roles.includes(key2))
2491
+ throw new EasyCMSError(`"${key2}" is in auth.roles and cannot be deleted`, 400);
2492
+ const users = await this.users(key2);
2493
+ if (users > 0)
2494
+ throw new EasyCMSError(`${users} user(s) have this role; give them another role first`, 409);
2495
+ await this.db.delete({ collection: ROLES, id: row.id });
2496
+ await this.versions.deleteAll(ROLES, row.id);
2497
+ this.invalidate();
2498
+ }
2499
+ async history(id) {
2500
+ const row = await this.row(id);
2501
+ const list = await this.versions.list(ROLES, row.id, HISTORY, 1);
2502
+ const emails = /* @__PURE__ */ new Map();
2503
+ const out = [];
2504
+ for (const summary of list.docs) {
2505
+ const version = await this.versions.get(ROLES, row.id, summary.id);
2506
+ if (!version) continue;
2507
+ let author = null;
2508
+ if (summary.author !== null) {
2509
+ const k = String(summary.author);
2510
+ if (!emails.has(k)) {
2511
+ const user = await this.db.findById({ collection: USERS, id: summary.author });
2512
+ emails.set(k, typeof user?.email === "string" ? user.email : null);
2513
+ }
2514
+ author = emails.get(k) ?? null;
2515
+ }
2516
+ out.push({
2517
+ id: summary.id,
2518
+ author,
2519
+ createdAt: summary.createdAt,
2520
+ name: String(version.data.name ?? ""),
2521
+ permissions: version.data.permissions ?? {}
2522
+ });
2523
+ }
2524
+ return out;
2525
+ }
2526
+ // -------------------------------------------------------------------------
2527
+ /** Pages and panels roles can be given. */
2528
+ views() {
2529
+ return [
2530
+ { id: "status" },
2531
+ ...this.config.collections.some(
2532
+ (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
2533
+ ) ? [{ id: "deliveries" }] : [],
2534
+ ...this.config.admin.pages.map((page) => ({ id: viewId.page(page.path), label: page.label })),
2535
+ ...this.config.admin.dashboard.map((widget) => ({
2536
+ id: viewId.widget(tagOf(widget.component)),
2537
+ ...widget.label !== void 0 ? { label: widget.label } : {}
2538
+ }))
2539
+ ];
2540
+ }
2541
+ /** Every collection and global, and the plugin pages and panels: what roles had before roles. */
2542
+ everything() {
2543
+ const collections = {};
2544
+ for (const c of this.config.collections) {
2545
+ if (governed(c.slug))
2546
+ collections[c.slug] = ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true);
2547
+ }
2548
+ const globals = {};
2549
+ for (const g of this.config.globals)
2550
+ globals[g.slug] = GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true);
2551
+ const admin = this.views().map((v) => v.id).filter((id) => id.startsWith("page:") || id.startsWith("widget:"));
2552
+ return { collections, globals, admin };
2553
+ }
2554
+ /** Keeps only known collections, globals, operations and pages; `read` comes with the others. */
2555
+ clean(value) {
2556
+ const input = typeof value === "object" && value !== null ? value : {};
2557
+ const pick = (group, allowed2) => {
2558
+ const out = {};
2559
+ if (typeof group !== "object" || group === null) return out;
2560
+ for (const [slug2, ops] of Object.entries(group)) {
2561
+ const valid = allowed2.get(slug2);
2562
+ if (!valid || !Array.isArray(ops)) continue;
2563
+ const picked = valid.filter((op) => ops.includes(op));
2564
+ if (picked.length > 0 && !picked.includes("read")) picked.unshift("read");
2565
+ out[slug2] = picked;
2566
+ }
2567
+ return out;
2568
+ };
2569
+ const collections = new Map(
2570
+ this.config.collections.filter((c) => governed(c.slug)).map((c) => [
2571
+ c.slug,
2572
+ ROLE_OPERATIONS.filter((op) => op !== "publish" || c.drafts === true)
2573
+ ])
2574
+ );
2575
+ const globals = new Map(
2576
+ this.config.globals.map((g) => [
2577
+ g.slug,
2578
+ GLOBAL_OPERATIONS.filter((op) => op !== "publish" || g.drafts === true)
2579
+ ])
2580
+ );
2581
+ const views = new Set(this.views().map((v) => v.id));
2582
+ const admin = Array.isArray(input.admin) ? [
2583
+ ...new Set(
2584
+ input.admin.filter((id) => typeof id === "string" && views.has(id))
2585
+ )
2586
+ ] : [];
2587
+ return {
2588
+ collections: pick(input.collections, collections),
2589
+ globals: pick(input.globals, globals),
2590
+ admin
2591
+ };
2592
+ }
2593
+ cleanName(value) {
2594
+ if (value === void 0 || value === null) return "";
2595
+ if (typeof value !== "string" || value.trim().length > 60)
2596
+ throw new ValidationError(ROLES, [
2597
+ { field: "name", message: "must be at most 60 characters" }
2598
+ ]);
2599
+ return value.trim();
2600
+ }
2601
+ async permissionsOf(key2) {
2602
+ return (await this.load()).get(key2) ?? {};
2603
+ }
2604
+ async load() {
2605
+ if (this.cache && Date.now() - this.cache.at < TTL) return this.cache.byKey;
2606
+ await this.sync();
2607
+ const byKey = /* @__PURE__ */ new Map();
2608
+ for (const row of await this.rows())
2609
+ byKey.set(String(row.key), row.permissions ?? {});
2610
+ this.cache = { at: Date.now(), byKey };
2611
+ return byKey;
2612
+ }
2613
+ async rows() {
2614
+ return (await this.db.find({ collection: ROLES, sort: ["id"], limit: 0, page: 1 })).docs;
2615
+ }
2616
+ async row(id) {
2617
+ const row = await this.db.findById({ collection: ROLES, id });
2618
+ if (!row) throw new NotFoundError(ROLES, id);
2619
+ return row;
2620
+ }
2621
+ users(key2) {
2622
+ return this.db.count({ collection: USERS, where: { role: { equals: key2 } } });
2623
+ }
2624
+ async insert(key2, name, permissions, author) {
2625
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2626
+ const row = await this.db.create({
2627
+ collection: ROLES,
2628
+ data: { key: key2, name, permissions, createdAt: now, updatedAt: now }
2629
+ });
2630
+ await this.record(row, author);
2631
+ this.invalidate();
2632
+ return row;
2633
+ }
2634
+ /** Keeps the role as saved, for its history. */
2635
+ async record(row, author) {
2636
+ await this.versions.save({
2637
+ parent: ROLES,
2638
+ doc: row.id,
2639
+ status: null,
2640
+ snapshot: { name: row.name ?? "", permissions: row.permissions ?? {} },
2641
+ author,
2642
+ max: HISTORY
2643
+ });
2644
+ }
2645
+ toRole(row, users) {
2646
+ const key2 = String(row.key);
2647
+ return {
2648
+ id: row.id,
2649
+ key: key2,
2650
+ name: typeof row.name === "string" ? row.name : "",
2651
+ system: this.config.auth.roles.includes(key2),
2652
+ users,
2653
+ permissions: row.permissions ?? {},
2654
+ updatedAt: String(row.updatedAt)
2655
+ };
2656
+ }
2657
+ };
2658
+ function rank(role, config) {
2659
+ const index = config.auth.roles.indexOf(role.key);
2660
+ return index === -1 ? config.auth.roles.length : index;
2661
+ }
2662
+
2171
2663
  // src/webhooks.ts
2172
2664
  import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
2173
2665
  var WEBHOOK_EVENTS = [
@@ -2359,6 +2851,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
2359
2851
  "webhook-deliveries",
2360
2852
  "email-deliveries",
2361
2853
  "database-backups",
2854
+ "user-roles",
2362
2855
  "api-keys",
2363
2856
  "jobs",
2364
2857
  "migrations",
@@ -2563,6 +3056,7 @@ function validateAdminViews(config, add) {
2563
3056
  "e.g. dashboard: [{ component: 'ecms-stats-widget' }]"
2564
3057
  );
2565
3058
  } else {
3059
+ const tags = /* @__PURE__ */ new Set();
2566
3060
  widgets2.forEach((widget, i) => {
2567
3061
  const path = `admin.dashboard[${i}]`;
2568
3062
  if (typeof widget !== "object" || widget === null) {
@@ -2573,6 +3067,19 @@ function validateAdminViews(config, add) {
2573
3067
  validateComponent(w.component, `${path}.component`, add);
2574
3068
  if (w.width !== void 0 && w.width !== "half" && w.width !== "full")
2575
3069
  add(`${path}.width`, 'must be "half" or "full"');
3070
+ if (w.label !== void 0 && !(typeof w.label === "string" && w.label.trim() !== "") && !(typeof w.label === "object" && w.label !== null && !Array.isArray(w.label)))
3071
+ add(`${path}.label`, "must be a string or { en, th }");
3072
+ const component = w.component;
3073
+ const tag = typeof component === "string" ? component : component?.tag;
3074
+ if (config.auth?.rbac === true && typeof tag === "string") {
3075
+ if (tags.has(tag))
3076
+ add(
3077
+ `${path}.component`,
3078
+ `another panel uses "${tag}"`,
3079
+ "with auth.rbac, roles are given panels by their tag: use one tag per panel"
3080
+ );
3081
+ tags.add(tag);
3082
+ }
2576
3083
  access(w.access, path);
2577
3084
  });
2578
3085
  }
@@ -2586,6 +3093,8 @@ function validateIcon(container, path, add) {
2586
3093
  function validateAuth(config, add) {
2587
3094
  const auth = config.auth;
2588
3095
  if (auth === void 0) return;
3096
+ if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
3097
+ add("auth.rbac", "must be true or false");
2589
3098
  if (auth.roles !== void 0) {
2590
3099
  const roles = auth.roles;
2591
3100
  if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
@@ -3280,6 +3789,7 @@ async function resolveConfig(input) {
3280
3789
  },
3281
3790
  auth: {
3282
3791
  roles: config.auth?.roles ?? DEFAULT_ROLES,
3792
+ rbac: config.auth?.rbac === true,
3283
3793
  tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
3284
3794
  maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
3285
3795
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
@@ -3291,12 +3801,14 @@ async function resolveConfig(input) {
3291
3801
  collections: [
3292
3802
  ...config.collections ?? [],
3293
3803
  ...internalCollections,
3294
- ...usesVersions(config) ? [versionsCollection] : [],
3804
+ // Roles keep their history as versions.
3805
+ ...usesVersions(config) || config.auth?.rbac ? [versionsCollection] : [],
3295
3806
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
3296
3807
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : [],
3297
3808
  ...config.apiKeys ? [apiKeysCollection] : [],
3298
3809
  ...config.email ? [emailDeliveriesCollection] : [],
3299
- databaseBackupsCollection
3810
+ databaseBackupsCollection,
3811
+ ...config.auth?.rbac ? [rolesCollection] : []
3300
3812
  ],
3301
3813
  globals: config.globals ?? [],
3302
3814
  endpoints: config.endpoints ?? [],
@@ -3841,137 +4353,6 @@ function read(response, maxBytes, deadline) {
3841
4353
  });
3842
4354
  }
3843
4355
 
3844
- // src/versions.ts
3845
- var DEFAULT_MAX_VERSIONS = 50;
3846
- function versionLimit(config) {
3847
- const versions = config.versions;
3848
- if (!versions) return void 0;
3849
- return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
3850
- }
3851
- var collectionParent = (slug2) => slug2;
3852
- var globalParent = (slug2) => `global:${slug2}`;
3853
- function snapshotOf(fields, doc) {
3854
- const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
3855
- const snapshot = {};
3856
- for (const [key2, value] of Object.entries(doc)) {
3857
- if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
3858
- snapshot[key2] = value;
3859
- }
3860
- return snapshot;
3861
- }
3862
- var toSummary = (row) => ({
3863
- id: row.id,
3864
- status: row.status ?? null,
3865
- latest: row.latest === true,
3866
- author: row.author ?? null,
3867
- createdAt: String(row.createdAt)
3868
- });
3869
- var VersionStore = class {
3870
- constructor(db) {
3871
- this.db = db;
3872
- }
3873
- db;
3874
- /** Saves a new latest version and deletes the oldest beyond `max`. */
3875
- async save(args) {
3876
- const { parent, doc } = args;
3877
- const previous = await this.db.find({
3878
- collection: VERSIONS,
3879
- where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
3880
- sort: [],
3881
- limit: 0,
3882
- page: 1
3883
- });
3884
- for (const row of previous.docs) {
3885
- await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
3886
- }
3887
- const now = (/* @__PURE__ */ new Date()).toISOString();
3888
- const created = await this.db.create({
3889
- collection: VERSIONS,
3890
- data: {
3891
- parent,
3892
- doc,
3893
- status: args.status,
3894
- latest: true,
3895
- author: args.author,
3896
- snapshot: args.snapshot,
3897
- createdAt: now,
3898
- updatedAt: now
3899
- }
3900
- });
3901
- await this.prune(parent, doc, args.max);
3902
- return toSummary(created);
3903
- }
3904
- /** The latest version of each document, keyed by document id. */
3905
- async latest(parent, docs) {
3906
- const result = /* @__PURE__ */ new Map();
3907
- if (docs.length === 0) return result;
3908
- const rows = await this.db.find({
3909
- collection: VERSIONS,
3910
- where: {
3911
- and: [
3912
- { parent: { equals: parent } },
3913
- { doc: { in: [...docs] } },
3914
- { latest: { equals: true } }
3915
- ]
3916
- },
3917
- sort: [],
3918
- limit: 0,
3919
- page: 1
3920
- });
3921
- for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
3922
- return result;
3923
- }
3924
- async list(parent, doc, limit, page) {
3925
- const found = await this.db.find({
3926
- collection: VERSIONS,
3927
- where: { and: this.of(parent, doc) },
3928
- sort: ["-id"],
3929
- limit,
3930
- page
3931
- });
3932
- return { ...found, docs: found.docs.map(toSummary) };
3933
- }
3934
- async get(parent, doc, id) {
3935
- const found = await this.db.find({
3936
- collection: VERSIONS,
3937
- where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
3938
- sort: [],
3939
- limit: 1,
3940
- page: 1
3941
- });
3942
- const row = found.docs[0];
3943
- return row ? this.toVersion(row) : null;
3944
- }
3945
- async deleteAll(parent, doc) {
3946
- const rows = await this.db.find({
3947
- collection: VERSIONS,
3948
- where: { and: this.of(parent, doc) },
3949
- sort: [],
3950
- limit: 0,
3951
- page: 1
3952
- });
3953
- for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
3954
- }
3955
- async prune(parent, doc, max) {
3956
- const rows = await this.db.find({
3957
- collection: VERSIONS,
3958
- where: { and: this.of(parent, doc) },
3959
- sort: ["-id"],
3960
- limit: 0,
3961
- page: 1
3962
- });
3963
- for (const row of rows.docs.slice(max)) {
3964
- await this.db.delete({ collection: VERSIONS, id: row.id });
3965
- }
3966
- }
3967
- of(parent, doc) {
3968
- return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
3969
- }
3970
- toVersion(row) {
3971
- return { ...toSummary(row), data: row.snapshot ?? {} };
3972
- }
3973
- };
3974
-
3975
4356
  // src/local-api.ts
3976
4357
  async function createEasyCMS(config, options = {}) {
3977
4358
  const resolved3 = await resolveConfig(config);
@@ -4009,6 +4390,8 @@ var EasyCMS = class {
4009
4390
  storage;
4010
4391
  /** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
4011
4392
  cwd;
4393
+ /** Roles and their permissions (`auth.rbac`, Settings → Roles). */
4394
+ roles;
4012
4395
  versions;
4013
4396
  webhooks;
4014
4397
  mailer;
@@ -4019,6 +4402,7 @@ var EasyCMS = class {
4019
4402
  this.storage = storage;
4020
4403
  this.cwd = cwd;
4021
4404
  this.versions = new VersionStore(db);
4405
+ this.roles = new Roles(config, db);
4022
4406
  this.webhooks = new Webhooks(
4023
4407
  config.webhooks ?? [],
4024
4408
  logger,
@@ -4109,7 +4493,7 @@ var EasyCMS = class {
4109
4493
  const config = this.collection(MEDIA);
4110
4494
  const guard = guardOf(options);
4111
4495
  if (guard.enforce) {
4112
- this.checkKey(guard, { collection: MEDIA }, "create");
4496
+ await this.checkGrant(guard, { collection: MEDIA }, "create");
4113
4497
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
4114
4498
  if (allowed2 !== true) throw deny(guard.user);
4115
4499
  }
@@ -4169,7 +4553,7 @@ var EasyCMS = class {
4169
4553
  const guard = guardOf(options);
4170
4554
  const fromURL = this.config.upload.fromURL;
4171
4555
  if (guard.enforce) {
4172
- this.checkKey(guard, { collection: MEDIA }, "create");
4556
+ await this.checkGrant(guard, { collection: MEDIA }, "create");
4173
4557
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data });
4174
4558
  if (allowed2 !== true) throw deny(guard.user);
4175
4559
  if (!fromURL)
@@ -4324,7 +4708,7 @@ var EasyCMS = class {
4324
4708
  let parsed;
4325
4709
  if (id === null) {
4326
4710
  if (guard.enforce) {
4327
- this.checkKey(guard, { collection: collection2 }, "create");
4711
+ await this.checkGrant(guard, { collection: collection2 }, "create");
4328
4712
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
4329
4713
  if (allowed2 !== true) throw deny(guard.user);
4330
4714
  }
@@ -4444,7 +4828,7 @@ var EasyCMS = class {
4444
4828
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
4445
4829
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
4446
4830
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4447
- this.checkKey(guard, { collection: collection2 }, "publish");
4831
+ await this.checkGrant(guard, { collection: collection2 }, "publish");
4448
4832
  const [drafted] = await this.withDrafts(config, [existing]);
4449
4833
  const live = options.live === true && mode === "save";
4450
4834
  const current = live ? existing : drafted;
@@ -4639,7 +5023,7 @@ var EasyCMS = class {
4639
5023
  const guard = guardOf(options);
4640
5024
  await this.checkGlobalAccess(config, "update", guard);
4641
5025
  if (config.drafts && (mode === "unpublish" || raw.status === "published"))
4642
- this.checkKey(guard, { global: slug2 }, "publish");
5026
+ await this.checkGrant(guard, { global: slug2 }, "publish");
4643
5027
  const input = await filterInput(
4644
5028
  config.fields,
4645
5029
  raw,
@@ -4928,14 +5312,14 @@ var EasyCMS = class {
4928
5312
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
4929
5313
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
4930
5314
  await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
4931
- this.checkKey(guardOf(options), { collection: collection2 }, "publish");
5315
+ await this.checkGrant(guardOf(options), { collection: collection2 }, "publish");
4932
5316
  return { config, parsed };
4933
5317
  }
4934
5318
  async globalScheduleTarget(slug2, options) {
4935
5319
  const config = this.global(slug2);
4936
5320
  if (!config.schedule) throw new QueryError(`"${slug2}" has no schedule`);
4937
5321
  await this.checkGlobalAccess(config, "update", guardOf(options));
4938
- this.checkKey(guardOf(options), { global: slug2 }, "publish");
5322
+ await this.checkGrant(guardOf(options), { global: slug2 }, "publish");
4939
5323
  }
4940
5324
  async addJob(parent, doc, job, options) {
4941
5325
  const runAt = new Date(job.at);
@@ -4981,20 +5365,21 @@ var EasyCMS = class {
4981
5365
  page: 1
4982
5366
  });
4983
5367
  const allowed2 = /* @__PURE__ */ new Map();
4984
- const may = async (parent) => {
5368
+ const may2 = async (parent) => {
4985
5369
  if (!guard.enforce) return true;
4986
5370
  const cached = allowed2.get(parent);
4987
5371
  if (cached !== void 0) return cached;
4988
5372
  const config = parent.startsWith("global:") ? this.config.globals.find((g) => g.slug === parent.slice("global:".length)) : this.config.collections.find((c) => c.slug === parent);
4989
5373
  const access = config ? await evaluateAccess(config.access?.update, { user: guard.user }) : false;
4990
- const result = access !== false;
5374
+ const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
5375
+ const result = access !== false && await this.roles.allows(guard.user, target, "update") !== false;
4991
5376
  allowed2.set(parent, result);
4992
5377
  return result;
4993
5378
  };
4994
5379
  const jobs = [];
4995
5380
  for (const row of rows.docs) {
4996
5381
  const parent = String(row.parent);
4997
- if (!await may(parent)) continue;
5382
+ if (!await may2(parent)) continue;
4998
5383
  const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
4999
5384
  jobs.push({ ...toJob(row), ...target, doc: row.doc });
5000
5385
  if (jobs.length === limit) break;
@@ -5079,28 +5464,36 @@ var EasyCMS = class {
5079
5464
  if (!config) throw new QueryError(`Unknown global "${slug2}"`);
5080
5465
  return config;
5081
5466
  }
5082
- /** Refuses what a request's API key does not allow (see `ApiKeyPermissions`). */
5083
- checkKey(guard, target, operation) {
5084
- if (!guard.enforce || keyAllows(guard.user, target, operation)) return;
5467
+ /**
5468
+ * Refuses what a request's API key (see `ApiKeyPermissions`) or the user's role (`auth.rbac`)
5469
+ * does not allow. Returns a constraint when the role allows only the user's own account.
5470
+ */
5471
+ async checkGrant(guard, target, operation) {
5472
+ if (!guard.enforce) return true;
5085
5473
  const name = "collection" in target ? target.collection : target.global;
5086
- throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
5474
+ if (!keyAllows(guard.user, target, operation))
5475
+ throw new ForbiddenError(`This API key may not ${operation} "${name}"`);
5476
+ const role = await this.roles.allows(guard.user, target, operation);
5477
+ if (role === false) throw deny(guard.user);
5478
+ return role;
5087
5479
  }
5088
5480
  /** The query constraint read access adds, or `undefined` when there is none. Throws when denied. */
5089
5481
  async readWhere(config, guard, where) {
5090
5482
  if (!guard.enforce) return where;
5091
- this.checkKey(guard, { collection: config.slug }, "read");
5483
+ const own = await this.checkGrant(guard, { collection: config.slug }, "read");
5092
5484
  const access = await evaluateAccess(config.access?.read, { user: guard.user });
5093
5485
  if (access === false) throw deny(guard.user);
5094
- return andWhere(where, access);
5486
+ return andWhere(andWhere(where, access), own);
5095
5487
  }
5096
5488
  async checkDocumentAccess(config, operation, guard, id, data) {
5097
5489
  if (!guard.enforce) return;
5098
- this.checkKey(guard, { collection: config.slug }, operation);
5099
- const access = await evaluateAccess(config.access?.[operation], {
5490
+ const own = await this.checkGrant(guard, { collection: config.slug }, operation);
5491
+ const allowed2 = await evaluateAccess(config.access?.[operation], {
5100
5492
  user: guard.user,
5101
5493
  id,
5102
5494
  ...data ? { data } : {}
5103
5495
  });
5496
+ const access = allowed2 === false ? false : andWhere(own === true ? void 0 : own, allowed2) ?? true;
5104
5497
  if (access === true) return;
5105
5498
  if (access !== false) {
5106
5499
  const matches2 = await this.db.count({
@@ -5113,7 +5506,7 @@ var EasyCMS = class {
5113
5506
  }
5114
5507
  async checkGlobalAccess(config, operation, guard) {
5115
5508
  if (!guard.enforce) return;
5116
- this.checkKey(guard, { global: config.slug }, operation);
5509
+ await this.checkGrant(guard, { global: config.slug }, operation);
5117
5510
  const access = await evaluateAccess(config.access?.[operation], { user: guard.user });
5118
5511
  if (typeof access === "object")
5119
5512
  throw new QueryError(`${operation} access of global "${config.slug}" must return a boolean`);
@@ -5167,9 +5560,9 @@ var EasyCMS = class {
5167
5560
  const guard = guardOf(options);
5168
5561
  const collection2 = config.slug;
5169
5562
  if (guard.enforce) {
5170
- this.checkKey(guard, { collection: collection2 }, "create");
5563
+ await this.checkGrant(guard, { collection: collection2 }, "create");
5171
5564
  if (config.drafts && raw.status === "published")
5172
- this.checkKey(guard, { collection: collection2 }, "publish");
5565
+ await this.checkGrant(guard, { collection: collection2 }, "publish");
5173
5566
  const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
5174
5567
  if (typeof allowed2 === "object")
5175
5568
  throw new QueryError(`create access of "${collection2}" must return a boolean`);
@@ -5388,6 +5781,8 @@ var EasyCMS = class {
5388
5781
  }
5389
5782
  clean2.status = status;
5390
5783
  }
5784
+ if (config.slug === USERS && this.roles.enabled && typeof clean2.role === "string" && !await this.roles.exists(clean2.role))
5785
+ errors.push({ field: "role", message: `there is no role "${clean2.role}"` });
5391
5786
  const isCollection = this.config.collections.includes(config);
5392
5787
  if (isCollection && errors.length === 0) {
5393
5788
  await this.makeSlugsUnique(config, clean2, selfId);
@@ -5474,11 +5869,11 @@ var EasyCMS = class {
5474
5869
  return await this.db.count({ collection: collection2, where }) > 0;
5475
5870
  }
5476
5871
  /** Each reference is one its relationship's `filterOptions` allow. */
5477
- async checkFilterOptions(references, selfId, user) {
5872
+ async checkFilterOptions(references2, selfId, user) {
5478
5873
  const errors = [];
5479
5874
  const cms = this;
5480
5875
  const allowed2 = /* @__PURE__ */ new Map();
5481
- for (const ref of references) {
5876
+ for (const ref of references2) {
5482
5877
  const filter = ref.filterOptions;
5483
5878
  if (!filter) continue;
5484
5879
  if (!allowed2.has(filter)) allowed2.set(filter, await filter({ id: selfId, user, cms }));
@@ -5496,9 +5891,9 @@ var EasyCMS = class {
5496
5891
  }
5497
5892
  return errors;
5498
5893
  }
5499
- async checkReferences(references) {
5894
+ async checkReferences(references2) {
5500
5895
  const byCollection = /* @__PURE__ */ new Map();
5501
- for (const ref of references) {
5896
+ for (const ref of references2) {
5502
5897
  if (!this.config.collections.some((c) => c.slug === ref.collection)) continue;
5503
5898
  byCollection.set(ref.collection, [...byCollection.get(ref.collection) ?? [], ref]);
5504
5899
  }
@@ -5897,23 +6292,36 @@ async function serializeFields(fields, update2, localized) {
5897
6292
  }
5898
6293
  return out;
5899
6294
  }
5900
- async function collection(config, user, localized) {
6295
+ async function may(cms, access, user, target, operation) {
6296
+ return await allowed(access, user) && await cms.roles.allows(user, target, operation) !== false;
6297
+ }
6298
+ async function collection(cms, config, user, localized, roles) {
6299
+ const target = { collection: config.slug };
6300
+ const fields = await serializeFields(
6301
+ config.fields,
6302
+ new FieldAccessChecker("update", { user }),
6303
+ localized
6304
+ );
6305
+ if (config.slug === USERS && cms.roles.enabled) {
6306
+ const role = fields.find((f) => f.name === "role");
6307
+ if (role) {
6308
+ role.type = "select";
6309
+ role.options = roles.map((r) => ({ label: r.name || r.key, value: r.key }));
6310
+ }
6311
+ }
5901
6312
  const result = {
5902
6313
  slug: config.slug,
5903
6314
  drafts: config.drafts === true,
5904
6315
  versions: Boolean(config.versions),
5905
6316
  preview: typeof config.preview === "function",
5906
6317
  schedule: config.schedule === true,
5907
- fields: await serializeFields(
5908
- config.fields,
5909
- new FieldAccessChecker("update", { user }),
5910
- localized
5911
- ),
6318
+ fields,
5912
6319
  permissions: {
5913
- read: await allowed(config.access?.read, user),
5914
- create: await allowed(config.access?.create, user),
5915
- update: await allowed(config.access?.update, user),
5916
- delete: await allowed(config.access?.delete, user)
6320
+ read: await may(cms, config.access?.read, user, target, "read"),
6321
+ create: await may(cms, config.access?.create, user, target, "create"),
6322
+ update: await may(cms, config.access?.update, user, target, "update"),
6323
+ delete: await may(cms, config.access?.delete, user, target, "delete"),
6324
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
5917
6325
  }
5918
6326
  };
5919
6327
  if (config.labels) result.labels = config.labels;
@@ -5927,7 +6335,8 @@ async function collection(config, user, localized) {
5927
6335
  result.editIn = "drawer";
5928
6336
  return result;
5929
6337
  }
5930
- async function global(config, user, localized) {
6338
+ async function global(cms, config, user, localized) {
6339
+ const target = { global: config.slug };
5931
6340
  const result = {
5932
6341
  slug: config.slug,
5933
6342
  drafts: config.drafts === true,
@@ -5940,8 +6349,9 @@ async function global(config, user, localized) {
5940
6349
  localized
5941
6350
  ),
5942
6351
  permissions: {
5943
- read: await allowed(config.access?.read, user),
5944
- update: await allowed(config.access?.update, user)
6352
+ read: await may(cms, config.access?.read, user, target, "read"),
6353
+ update: await may(cms, config.access?.update, user, target, "update"),
6354
+ publish: config.drafts === true && await may(cms, config.access?.update, user, target, "publish")
5945
6355
  }
5946
6356
  };
5947
6357
  if (config.label !== void 0) result.label = config.label;
@@ -5953,16 +6363,31 @@ async function adminSchema(cms, user, origin) {
5953
6363
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
5954
6364
  const localization = cms.config.localization;
5955
6365
  const localized = localization !== null;
6366
+ const admin = user.role === "admin" && !user.apiKey;
6367
+ const deliveries = cms.config.collections.some(
6368
+ (c) => c.slug === WEBHOOK_DELIVERIES || c.slug === EMAIL_DELIVERIES
6369
+ );
6370
+ const views = {
6371
+ status: await cms.roles.canView(user, "status"),
6372
+ deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
6373
+ backups: admin,
6374
+ email: admin,
6375
+ roles: admin && cms.roles.enabled
6376
+ };
6377
+ const roles = await cms.roles.options();
5956
6378
  return {
5957
6379
  locale: cms.config.admin.locale,
5958
6380
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
5959
6381
  menu: [...cms.config.admin.menu],
5960
6382
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
5961
- collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
5962
- globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized))),
6383
+ collections: await Promise.all(
6384
+ collections.map((c) => collection(cms, c, user, localized, roles))
6385
+ ),
6386
+ globals: await Promise.all(cms.config.globals.map((g) => global(cms, g, user, localized))),
5963
6387
  modules: adminModuleUrls(cms),
5964
6388
  uploadFromURL: cms.config.upload.fromURL !== void 0,
5965
- ...user.role === "admin" && !user.apiKey ? {
6389
+ views,
6390
+ ...views.deliveries ? {
5966
6391
  deliveries: {
5967
6392
  webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
5968
6393
  email: cms.config.collections.some((c) => c.slug === EMAIL_DELIVERIES)
@@ -5983,7 +6408,8 @@ async function shown(access, user) {
5983
6408
  async function pages(cms, user) {
5984
6409
  const out = [];
5985
6410
  for (const page of cms.config.admin.pages) {
5986
- if (!await shown(page.access, user)) continue;
6411
+ if (!await shown(page.access, user) || !await cms.roles.canView(user, `page:${page.path}`))
6412
+ continue;
5987
6413
  out.push({
5988
6414
  path: page.path,
5989
6415
  label: page.label,
@@ -5997,14 +6423,16 @@ async function pages(cms, user) {
5997
6423
  async function widgets(cms, user) {
5998
6424
  const out = [];
5999
6425
  for (const widget of cms.config.admin.dashboard) {
6000
- if (!await shown(widget.access, user)) continue;
6426
+ const tag = typeof widget.component === "string" ? widget.component : widget.component.tag;
6427
+ if (!await shown(widget.access, user) || !await cms.roles.canView(user, `widget:${tag}`))
6428
+ continue;
6001
6429
  out.push({ component: componentRef(widget.component), width: widget.width ?? "half" });
6002
6430
  }
6003
6431
  return out;
6004
6432
  }
6005
6433
 
6006
6434
  // src/version.ts
6007
- var VERSION = true ? "0.32.0" : "0.0.0-dev";
6435
+ var VERSION = true ? "0.33.0" : "0.0.0-dev";
6008
6436
 
6009
6437
  // src/rest/admin-status.ts
6010
6438
  var DAY = 864e5;
@@ -6495,9 +6923,36 @@ async function route(cms, ctx, method, segments, options) {
6495
6923
  }
6496
6924
  throw new HttpError("Not found", 404);
6497
6925
  }
6498
- if (first === "admin" && second === "deliveries") {
6926
+ if (first === "admin" && second === "roles") {
6499
6927
  if (!ctx.user) throw new UnauthorizedError();
6500
6928
  if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
6929
+ if (!cms.roles.enabled) throw new HttpError("Not found", 404);
6930
+ const [, , id2, action, extra] = segments;
6931
+ if (extra !== void 0) throw new HttpError("Not found", 404);
6932
+ if (id2 === void 0) {
6933
+ if (method === "GET") return { body: await cms.roles.list() };
6934
+ if (method === "POST")
6935
+ return { status: 201, body: await cms.roles.create(await readJson(ctx.request), ctx.user) };
6936
+ throw methodNotAllowed(ctx, "GET, POST");
6937
+ }
6938
+ const parsed = parseId(id2);
6939
+ if (parsed === void 0) throw new HttpError("Not found", 404);
6940
+ if (action === "history") {
6941
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
6942
+ return { body: await cms.roles.history(parsed) };
6943
+ }
6944
+ if (action !== void 0) throw new HttpError("Not found", 404);
6945
+ if (method === "PATCH")
6946
+ return { body: await cms.roles.update(parsed, await readJson(ctx.request), ctx.user) };
6947
+ if (method === "DELETE") {
6948
+ await cms.roles.delete(parsed);
6949
+ return { body: { deleted: 1 } };
6950
+ }
6951
+ throw methodNotAllowed(ctx, "PATCH, DELETE");
6952
+ }
6953
+ if (first === "admin" && second === "deliveries") {
6954
+ if (!ctx.user) throw new UnauthorizedError();
6955
+ if (!await cms.roles.canView(ctx.user, "deliveries")) throw new ForbiddenError();
6501
6956
  const [, , kind, id2, action, extra] = segments;
6502
6957
  if (kind === void 0) {
6503
6958
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
@@ -6547,7 +7002,7 @@ async function route(cms, ctx, method, segments, options) {
6547
7002
  return { body: new Response(file.body, { headers }) };
6548
7003
  }
6549
7004
  if (second === "status" && third === void 0) {
6550
- if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
7005
+ if (!await cms.roles.canView(ctx.user, "status")) throw new ForbiddenError();
6551
7006
  return { body: await adminStatus(cms) };
6552
7007
  }
6553
7008
  if (second === "scheduled" && third === void 0)
@@ -7157,6 +7612,8 @@ export {
7157
7612
  NotFoundError,
7158
7613
  PayloadTooLargeError,
7159
7614
  QueryError,
7615
+ ROLE_OPERATIONS,
7616
+ Roles,
7160
7617
  SESSION_COOKIE,
7161
7618
  SchemaError,
7162
7619
  TooManyRequestsError,