@easy-cms/plugin-multi-tenant 0.44.0 → 0.45.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 (2) hide show
  1. package/dist/index.js +112 -16
  2. package/package.json +7 -4
package/dist/index.js CHANGED
@@ -61,7 +61,7 @@ import {
61
61
  // src/info.ts
62
62
  var INFO = {
63
63
  name: "@easy-cms/plugin-multi-tenant",
64
- ...true ? { version: "0.44.0" } : {}
64
+ ...true ? { version: "0.45.0" } : {}
65
65
  };
66
66
 
67
67
  // src/members.ts
@@ -263,7 +263,9 @@ function multiTenantPlugin(options) {
263
263
  ...field,
264
264
  blocks: field.blocks.map((b) => ({ ...b, fields: withTenantFilters(b.fields) }))
265
265
  };
266
- if (field.type !== "relationship" || !scoped.has(field.to)) return field;
266
+ const target = field.type === "relationship" ? field.to : field.type === "upload" ? MEDIA : null;
267
+ if (field.type !== "relationship" && field.type !== "upload" || !target || !scoped.has(target))
268
+ return field;
267
269
  const own = field.filterOptions;
268
270
  return {
269
271
  ...field,
@@ -284,12 +286,31 @@ function multiTenantPlugin(options) {
284
286
  position: "sidebar",
285
287
  label: { en: "Tenant", th: "Tenant" },
286
288
  // Set from the tenant the request works in; only users with access to all tenants choose.
287
- access: { update: ({ user, context }) => superUser(user, context) }
289
+ access: { update: ({ user, context }) => superUser(user, context) },
290
+ admin: {
291
+ // New documents start in the chosen tenant.
292
+ defaultValue: ({ context }) => tenantOf(context).tenant ?? void 0,
293
+ // A column while every tenant is shown.
294
+ column: ({ user, context }) => superUser(user, context) && tenantOf(context).tenant === null,
295
+ allowCreate: false
296
+ }
288
297
  };
289
- const fillTenant = ({ data, operation, context }) => {
298
+ const fillTenant = (fields) => async ({ data, operation, context, cms }) => {
290
299
  if (operation !== "create" || (data[TENANT_FIELD] ?? null) !== null) return data;
291
300
  const { tenant } = tenantOf(context);
292
- return tenant === null ? data : { ...data, [TENANT_FIELD]: tenant };
301
+ if (tenant !== null) return { ...data, [TENANT_FIELD]: tenant };
302
+ for (const field of fields) {
303
+ const target = field.type === "relationship" ? field.to : field.type === "upload" ? MEDIA : null;
304
+ if (!target || !scoped.has(target) || field.name === TENANT_FIELD) continue;
305
+ const value = [data[field.name]].flat()[0];
306
+ const id = value && typeof value === "object" ? value.id : value;
307
+ if (id === null || id === void 0) continue;
308
+ if (!cms.config.collections.some((c) => c.slug === target)) continue;
309
+ const doc = await cms.findById(target, id, { depth: 0, draft: true });
310
+ const owner = doc?.[TENANT_FIELD];
311
+ if (owner !== null && owner !== void 0) return { ...data, [TENANT_FIELD]: owner };
312
+ }
313
+ return data;
293
314
  };
294
315
  const checkTenant = (slug) => ({ data, operation, originalDoc, context, user }) => {
295
316
  const value = data[TENANT_FIELD] ?? null;
@@ -309,7 +330,15 @@ function multiTenantPlugin(options) {
309
330
  const scopeCollection = (c, builtin) => {
310
331
  const fields = withTenantFilters(
311
332
  c.fields.map(
312
- (f) => (f.unique || f.type === "slug") && f.uniqueWithin === void 0 ? { ...f, uniqueWithin: TENANT_FIELD } : f
333
+ (f) => f.unique || f.type === "slug" ? {
334
+ ...f,
335
+ uniqueWithin: [
336
+ .../* @__PURE__ */ new Set([
337
+ ...f.uniqueWithin === void 0 ? [] : [f.uniqueWithin].flat(),
338
+ TENANT_FIELD
339
+ ])
340
+ ]
341
+ } : f
313
342
  )
314
343
  );
315
344
  const access = { ...builtin, ...c.access };
@@ -324,7 +353,7 @@ function multiTenantPlugin(options) {
324
353
  },
325
354
  hooks: {
326
355
  ...c.hooks,
327
- beforeValidate: [fillTenant, ...c.hooks?.beforeValidate ?? []],
356
+ beforeValidate: [fillTenant(c.fields), ...c.hooks?.beforeValidate ?? []],
328
357
  beforeChange: [...c.hooks?.beforeChange ?? [], checkTenant(c.slug)]
329
358
  }
330
359
  };
@@ -387,7 +416,11 @@ function multiTenantPlugin(options) {
387
416
  labels: { singular: { en: "Tenant", th: "Tenant" }, plural: { en: "Tenants", th: "Tenant" } },
388
417
  icon: "building",
389
418
  useAsTitle: "name",
390
- admin: { group: "settings" },
419
+ admin: {
420
+ group: "settings",
421
+ // Deleting a tenant deletes its content: its name is typed, and what goes is shown.
422
+ confirmDelete: { typeTitle: true, impact: "/tenant-impact" }
423
+ },
391
424
  access: tenantsAccess,
392
425
  hooks: {
393
426
  afterChange: [({ cms }) => forgetTenants(cms)],
@@ -453,7 +486,16 @@ function multiTenantPlugin(options) {
453
486
  if (!hasUsers) collections.push({ slug: "users", fields: [memberships], access: usersAccess() });
454
487
  for (const builtin of [MEDIA, MEDIA_FOLDERS]) {
455
488
  if (scoped.has(builtin) && !declared.has(builtin))
456
- collections.push(scopeCollection({ slug: builtin, fields: [] }, BUILTIN_ACCESS[builtin]));
489
+ collections.push(
490
+ scopeCollection(
491
+ {
492
+ slug: builtin,
493
+ // The built-in `key` of folders: unique per tenant (merged into the built-in field).
494
+ fields: builtin === MEDIA_FOLDERS ? [{ name: "key", type: "text", unique: true }] : []
495
+ },
496
+ BUILTIN_ACCESS[builtin]
497
+ )
498
+ );
457
499
  }
458
500
  const globals = (config.globals ?? []).map((g) => {
459
501
  const filtered = { ...g, fields: withTenantFilters(g.fields) };
@@ -517,6 +559,39 @@ function multiTenantPlugin(options) {
517
559
  };
518
560
  }
519
561
  };
562
+ const impactEndpoint = {
563
+ path: "/tenant-impact",
564
+ method: "get",
565
+ handler: async ({ user, context, cms, url }) => {
566
+ if (!superUser(user, context))
567
+ return Response.json({ errors: [{ message: "Not allowed" }] }, { status: 403 });
568
+ const raw = url.searchParams.get("id") ?? "";
569
+ const id = /^\d+$/.test(raw) ? Number(raw) : raw;
570
+ const counts = [];
571
+ for (const slug of scoped) {
572
+ const config2 = cms.config.collections.find((c) => c.slug === slug);
573
+ if (!config2) continue;
574
+ const n = await cms.count(slug, { where: { [TENANT_FIELD]: { equals: id } } });
575
+ if (n === 0) continue;
576
+ const plural = config2.labels?.plural;
577
+ const en = typeof plural === "string" ? plural : plural?.en ?? slug;
578
+ const th = typeof plural === "string" ? plural : plural?.th ?? en;
579
+ counts.push({ en: `${n} ${en}`, th: `${th} ${n} \u0E23\u0E32\u0E22\u0E01\u0E32\u0E23` });
580
+ }
581
+ const members = await cms.count("users", {
582
+ where: { [`${MEMBERSHIPS_FIELD}.tenant`]: { equals: id } }
583
+ });
584
+ return {
585
+ message: counts.length ? {
586
+ en: `Also deleted: ${counts.map((c) => c.en).join(", ")}. ${members} members lose this tenant; their accounts stay.`,
587
+ th: `\u0E08\u0E30\u0E25\u0E1A\u0E44\u0E1B\u0E14\u0E49\u0E27\u0E22: ${counts.map((c) => c.th).join(", ")} \u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01 ${members} \u0E04\u0E19\u0E08\u0E30\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01 tenant \u0E19\u0E35\u0E49 \u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48`
588
+ } : {
589
+ en: `It has no content. ${members} members lose this tenant; their accounts stay.`,
590
+ th: `\u0E44\u0E21\u0E48\u0E21\u0E35\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32 \u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01 ${members} \u0E04\u0E19\u0E08\u0E30\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01 tenant \u0E19\u0E35\u0E49 \u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48`
591
+ }
592
+ };
593
+ }
594
+ };
520
595
  const assign = {
521
596
  name: "tenants:assign",
522
597
  description: "Give documents without a tenant to one (after adding the plugin to a site)",
@@ -535,24 +610,34 @@ of its own. Run it once after adding the plugin to a site that has content.
535
610
  }
536
611
  for (const slug of scoped) {
537
612
  if (!cms.config.collections.some((c) => c.slug === slug)) continue;
538
- let updated = 0;
539
- for (; ; ) {
540
- const { docs } = await cms.find(slug, {
613
+ const ids = [];
614
+ for (let page = 1; ; page++) {
615
+ const { docs, hasNextPage } = await cms.find(slug, {
541
616
  where: { [TENANT_FIELD]: { exists: false } },
542
- limit: 100,
617
+ sort: "id",
618
+ limit: 500,
619
+ page,
543
620
  depth: 0,
544
621
  draft: true
545
622
  });
546
- if (docs.length === 0) break;
547
- for (const doc of docs) {
548
- await cms.update(slug, doc.id, { [TENANT_FIELD]: tenant.id }, {
623
+ ids.push(...docs.map((d) => d.id));
624
+ if (!hasNextPage) break;
625
+ }
626
+ let updated = 0;
627
+ const skipped = [];
628
+ for (const id of ids) {
629
+ try {
630
+ await cms.update(slug, id, { [TENANT_FIELD]: tenant.id }, {
549
631
  depth: 0,
550
632
  live: true
551
633
  });
552
634
  updated++;
635
+ } catch (error) {
636
+ skipped.push(`${slug} ${id}: ${error.message}`);
553
637
  }
554
638
  }
555
639
  log(`${slug}: ${updated} given to ${tenant.name}`);
640
+ for (const line of skipped) log(` not given: ${line}`);
556
641
  }
557
642
  const context = { tenant: tenant.id, allTenants: false };
558
643
  for (const slug of perTenant) {
@@ -575,9 +660,20 @@ of its own. Run it once after adding the plugin to a site that has content.
575
660
  endpoints: [
576
661
  ...config.endpoints ?? [],
577
662
  optionsEndpoint,
663
+ impactEndpoint,
578
664
  ...membersEndpoints({ tenantsSlug, superUser })
579
665
  ],
580
666
  commands: [...config.commands ?? [], assign],
667
+ // Audit log entries belong to their tenant: its admins see them.
668
+ ...config.audit ? {
669
+ audit: {
670
+ ...config.audit === true ? {} : config.audit,
671
+ scope: (context) => {
672
+ const { tenant } = tenantOf(context);
673
+ return tenant === null ? null : String(tenant);
674
+ }
675
+ }
676
+ } : {},
581
677
  admin: {
582
678
  ...config.admin,
583
679
  modules: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/plugin-multi-tenant",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Multi-tenant Easy CMS: several sites or clients in one CMS, each with its own content, members and roles",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,11 +17,14 @@
17
17
  "dist"
18
18
  ],
19
19
  "peerDependencies": {
20
- "@easy-cms/core": "^0.44.0"
20
+ "@easy-cms/core": "^0.45.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@easy-cms/db-sqlite": "^0.44.0",
24
- "@easy-cms/core": "^0.44.0"
23
+ "@easy-cms/core": "^0.45.0",
24
+ "@easy-cms/db-sqlite": "^0.45.0",
25
+ "@easy-cms/plugin-form-builder": "^0.45.0",
26
+ "@easy-cms/plugin-nested-docs": "^0.45.0",
27
+ "@easy-cms/plugin-redirects": "^0.45.0"
25
28
  },
26
29
  "engines": {
27
30
  "node": ">=22.12"