@easy-cms/plugin-multi-tenant 0.47.4 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -34,7 +34,7 @@ interface Member {
34
34
  readonly role: string;
35
35
  }
36
36
 
37
- interface MultiTenantOptions<S extends string = string, T extends string = string> {
37
+ interface MultiTenantPluginOptions<S extends string = string, T extends string = string> {
38
38
  /**
39
39
  * Collections whose documents belong to one tenant, by slug, e.g. `['posts', 'pages',
40
40
  * 'media']`. With `media` and `upload.folders`, the media folders too. Others are shared.
@@ -42,8 +42,16 @@ interface MultiTenantOptions<S extends string = string, T extends string = strin
42
42
  readonly collections: readonly S[];
43
43
  /** Globals with a value per tenant, e.g. `['site-settings']`. Others are shared. */
44
44
  readonly globals?: readonly string[];
45
+ /**
46
+ * Shared collections and globals (not listed above) that the people of a tenant, its admins
47
+ * included, may change. A change applies to every tenant. Default: none; only users with
48
+ * access to all tenants change shared data.
49
+ */
50
+ readonly editShared?: readonly string[];
45
51
  /** Slug of the tenants collection. Default `tenants`. */
46
- readonly tenantsSlug?: T;
52
+ readonly slugs?: {
53
+ readonly tenants?: T;
54
+ };
47
55
  /**
48
56
  * Users who see and manage every tenant and the whole system. Default: users whose own role
49
57
  * is `admin`.
@@ -123,24 +131,25 @@ type MultiTenantPluginTypes<S extends string, T extends string = 'tenants'> = {
123
131
  * and globals with a value per tenant. Requests name their tenant with a header, `?tenant=`,
124
132
  * the admin's cookie or the domain.
125
133
  */
126
- declare function multiTenantPlugin<const S extends string, const T extends string = 'tenants'>(options: MultiTenantOptions<S, T>): TypedPlugin<MultiTenantPluginTypes<S, T>>;
134
+ declare function multiTenantPlugin<const S extends string, const T extends string = 'tenants'>(options: MultiTenantPluginOptions<S, T>): TypedPlugin<MultiTenantPluginTypes<S, T>>;
127
135
 
136
+ /** A tenant by its slug or one of its domains. */
137
+ interface TenantLookup {
138
+ readonly slug?: string;
139
+ readonly host?: string;
140
+ /** Slug of the tenants collection, when the plugin's `slugs.tenants` changed it. */
141
+ readonly collection?: string;
142
+ }
128
143
  /**
129
144
  * The tenant of a page of your site, by slug or by domain (`domains` of the tenant), e.g. from
130
145
  * the request's host. `null` when none matches.
131
146
  */
132
- declare function findTenantFor(cms: EasyCMS, by: {
133
- readonly slug?: string;
134
- readonly host?: string;
135
- }, tenantsSlug?: string): Promise<TenantRef | null>;
147
+ declare function findTenantFor(cms: EasyCMS, by: TenantLookup): Promise<TenantRef | null>;
136
148
  /**
137
149
  * The `context` for Local API calls that read one tenant's content, e.g.
138
150
  * `cms.find('posts', { overrideAccess: false, user: null, context: await tenantContext(cms, { host }) })`.
139
151
  * A tenant that doesn't exist gives a context that finds nothing.
140
152
  */
141
- declare function tenantContext(cms: EasyCMS, by: {
142
- readonly slug?: string;
143
- readonly host?: string;
144
- }, tenantsSlug?: string): Promise<RequestContext>;
153
+ declare function tenantContext(cms: EasyCMS, by: TenantLookup): Promise<RequestContext>;
145
154
 
146
- export { ALL, type Member, type Membership, type MultiTenantOptions, type MultiTenantPluginTypes, type TenantContext, type TenantRef, findTenantFor, multiTenantPlugin, tenantContext, tenantOf };
155
+ export { ALL, type Member, type Membership, type MultiTenantPluginOptions, type MultiTenantPluginTypes, type TenantContext, type TenantLookup, type TenantRef, findTenantFor, multiTenantPlugin, tenantContext, tenantOf };
package/dist/index.js CHANGED
@@ -52,16 +52,17 @@ var tenantOfHost = (list, host) => {
52
52
  // src/plugin.ts
53
53
  import {
54
54
  definePlugin,
55
- isLoggedIn,
56
- MEDIA,
57
- MEDIA_FOLDERS,
55
+ isStaff,
58
56
  ValidationError as ValidationError2
59
57
  } from "@easy-cms/core";
58
+ import { checkRenamedOptions, MEDIA, MEDIA_FOLDERS } from "@easy-cms/core/internal";
60
59
 
61
60
  // src/info.ts
62
61
  var INFO = {
63
62
  name: "@easy-cms/plugin-multi-tenant",
64
- ...true ? { version: "0.47.4" } : {}
63
+ // The plugin API it is written for (`PLUGIN_API_VERSION` of @easy-cms/core).
64
+ apiVersion: 1,
65
+ ...true ? { version: "0.49.0" } : {}
65
66
  };
66
67
 
67
68
  // src/members.ts
@@ -234,8 +235,8 @@ function membersEndpoints(options) {
234
235
 
235
236
  // src/plugin.ts
236
237
  var BUILTIN_ACCESS = {
237
- [MEDIA]: { read: () => true, create: isLoggedIn, update: isLoggedIn, delete: isLoggedIn },
238
- [MEDIA_FOLDERS]: { read: isLoggedIn, create: isLoggedIn, update: isLoggedIn, delete: isLoggedIn }
238
+ [MEDIA]: { read: () => true, create: isStaff, update: isStaff, delete: isStaff },
239
+ [MEDIA_FOLDERS]: { read: isStaff, create: isStaff, update: isStaff, delete: isStaff }
239
240
  };
240
241
  function readCookie(header, name) {
241
242
  for (const part of header?.split(";") ?? []) {
@@ -246,8 +247,9 @@ function readCookie(header, name) {
246
247
  return void 0;
247
248
  }
248
249
  function multiTenantPlugin(options) {
250
+ checkRenamedOptions("multiTenantPlugin", options, { tenantsSlug: "slugs.tenants" });
249
251
  return definePlugin((config) => {
250
- const tenantsSlug = options.tenantsSlug ?? "tenants";
252
+ const tenantsSlug = options.slugs?.tenants ?? "tenants";
251
253
  const header = (options.header ?? "x-easy-cms-tenant").toLowerCase();
252
254
  const cookie = options.cookie ?? "ecms-tenant";
253
255
  const publicReads = options.publicReads ?? "none";
@@ -274,6 +276,8 @@ function multiTenantPlugin(options) {
274
276
  );
275
277
  const superUser = (user, _context) => !!user && user.member !== true && user.scoped !== true && isSuper(user);
276
278
  const everywhere = (user, context) => tenantOf(context).allTenants || superUser(user, context);
279
+ const editShared = new Set(options.editShared ?? []);
280
+ const sharedWrite = (slug, original) => async (args) => args.user?.scoped === true && !editShared.has(slug) ? false : (original ?? isStaff)(args);
277
281
  const tenantFilter = (args, op) => {
278
282
  const { tenant } = tenantOf(args.context);
279
283
  if (op === "create") return tenant !== null || superUser(args.user, args.context);
@@ -282,7 +286,7 @@ function multiTenantPlugin(options) {
282
286
  return superUser(args.user, args.context);
283
287
  };
284
288
  const narrow = (original, op) => async (args) => {
285
- const base = await (original ?? isLoggedIn)(args);
289
+ const base = await (original ?? isStaff)(args);
286
290
  if (base === false) return false;
287
291
  const filter = tenantFilter(args, op);
288
292
  if (filter === false) return false;
@@ -317,13 +321,13 @@ function multiTenantPlugin(options) {
317
321
  type: "relationship",
318
322
  to: tenantsSlug,
319
323
  index: true,
320
- position: "sidebar",
321
324
  label: { en: "Tenant", th: "Tenant" },
322
325
  // Set from the tenant the request works in; only users with access to all tenants choose.
323
326
  access: { update: ({ user, context }) => superUser(user, context) },
324
327
  admin: {
328
+ position: "sidebar",
325
329
  // New documents start in the chosen tenant.
326
- defaultValue: ({ context }) => tenantOf(context).tenant ?? void 0,
330
+ initialValue: ({ context }) => tenantOf(context).tenant ?? void 0,
327
331
  // A column while every tenant is shown.
328
332
  column: ({ user, context }) => superUser(user, context) && tenantOf(context).tenant === null,
329
333
  allowCreate: false
@@ -346,7 +350,7 @@ function multiTenantPlugin(options) {
346
350
  }
347
351
  return data;
348
352
  };
349
- const checkTenant = (slug) => ({ data, operation, originalDoc, context, user }) => {
353
+ const checkTenant = (slug) => ({ data, operation, previousDoc, context, user }) => {
350
354
  const value = data[TENANT_FIELD] ?? null;
351
355
  const { tenant } = tenantOf(context);
352
356
  if (!user && tenant === null) return data;
@@ -356,7 +360,7 @@ function multiTenantPlugin(options) {
356
360
  if (value === null) fail("is required: choose a tenant");
357
361
  if (superUser(user, context)) return data;
358
362
  if (!sameId(value, tenant)) fail("must be the tenant you work in");
359
- const before = originalDoc?.[TENANT_FIELD] ?? null;
363
+ const before = previousDoc?.[TENANT_FIELD] ?? null;
360
364
  if (operation === "update" && before !== null && !sameId(before, value))
361
365
  fail("can't move to another tenant");
362
366
  return data;
@@ -449,9 +453,9 @@ function multiTenantPlugin(options) {
449
453
  const tenantsCollection = {
450
454
  slug: tenantsSlug,
451
455
  labels: { singular: { en: "Tenant", th: "Tenant" }, plural: { en: "Tenants", th: "Tenant" } },
452
- icon: "building",
453
456
  useAsTitle: "name",
454
457
  admin: {
458
+ icon: "building",
455
459
  group: "settings.people",
456
460
  // Deleting a tenant deletes its content: its name is typed, and what goes is shown.
457
461
  confirmDelete: { typeTitle: true, impact: "/tenant-impact" }
@@ -516,7 +520,17 @@ function multiTenantPlugin(options) {
516
520
  access: { ...c.access, ...usersAccess(c.access) }
517
521
  });
518
522
  } else if (scoped.has(c.slug)) collections.push(scopeCollection(c, BUILTIN_ACCESS[c.slug]));
519
- else collections.push({ ...c, fields: withTenantFilters(c.fields) });
523
+ else
524
+ collections.push({
525
+ ...c,
526
+ fields: withTenantFilters(c.fields),
527
+ access: {
528
+ ...c.access,
529
+ create: sharedWrite(c.slug, c.access?.create),
530
+ update: sharedWrite(c.slug, c.access?.update),
531
+ delete: sharedWrite(c.slug, c.access?.delete)
532
+ }
533
+ });
520
534
  }
521
535
  if (!hasUsers) collections.push({ slug: "users", fields: [memberships], access: usersAccess() });
522
536
  for (const builtin of [MEDIA, MEDIA_FOLDERS]) {
@@ -534,7 +548,11 @@ function multiTenantPlugin(options) {
534
548
  }
535
549
  const globals = (config.globals ?? []).map((g) => {
536
550
  const filtered = { ...g, fields: withTenantFilters(g.fields) };
537
- if (!perTenant.has(g.slug)) return filtered;
551
+ if (!perTenant.has(g.slug))
552
+ return {
553
+ ...filtered,
554
+ access: { ...g.access, update: sharedWrite(g.slug, g.access?.update) }
555
+ };
538
556
  return {
539
557
  ...filtered,
540
558
  // One value per tenant; none when the request has no tenant.
@@ -546,9 +564,7 @@ function multiTenantPlugin(options) {
546
564
  });
547
565
  const previous = config.onRequest;
548
566
  const onRequest = async (args) => {
549
- const before = previous ? await previous(args) : void 0;
550
- const user = before?.user !== void 0 ? before.user : args.user;
551
- const context = { ...before?.context };
567
+ const user = args.user;
552
568
  const list = await tenantList(args.cms, tenantsSlug);
553
569
  const asked = args.headers.get(header) ?? args.url?.searchParams.get("tenant") ?? readCookie(args.headers.get("cookie"), cookie) ?? null;
554
570
  const byHost = tenantOfHost(
@@ -559,7 +575,7 @@ function multiTenantPlugin(options) {
559
575
  const boundTenant = bound ? tenantOf(bound) : void 0;
560
576
  if (user && user.member !== true && isSuper(user) && (!boundTenant || boundTenant.tenant === null)) {
561
577
  const chosen2 = asked === ALL ? void 0 : findTenant(list, asked);
562
- return { user, context: { ...context, tenant: chosen2?.id ?? null, allTenants: true } };
578
+ return { user, context: { tenant: chosen2?.id ?? null, allTenants: true } };
563
579
  }
564
580
  if (user && user.member !== true) {
565
581
  const mine = membershipsOf(user);
@@ -567,14 +583,13 @@ function multiTenantPlugin(options) {
567
583
  const membership = boundTenant && boundTenant.tenant !== null ? mine.find((m) => sameId(m.tenant, boundTenant.tenant)) ?? (isSuper(user) ? { tenant: boundTenant.tenant, role: user.role } : void 0) : member(findTenant(list, asked)) ?? member(byHost) ?? mine.find((m) => list.some((t) => sameId(t.id, m.tenant)));
568
584
  return {
569
585
  user: { ...user, role: membership?.role ?? user.role, scoped: true },
570
- context: { ...context, tenant: membership?.tenant ?? null, allTenants: false }
586
+ context: { tenant: membership?.tenant ?? null, allTenants: false }
571
587
  };
572
588
  }
573
589
  const chosen = asked && asked !== ALL ? findTenant(list, asked) : byHost;
574
590
  return {
575
591
  user,
576
592
  context: {
577
- ...context,
578
593
  tenant: chosen?.id ?? null,
579
594
  allTenants: !chosen && publicReads === "all"
580
595
  }
@@ -691,19 +706,19 @@ of its own. Run it once after adding the plugin to a site that has content.
691
706
  ...config,
692
707
  collections,
693
708
  globals,
694
- onRequest,
709
+ onRequest: [...typeof previous === "function" ? [previous] : previous ?? [], onRequest],
695
710
  endpoints: [
696
711
  ...config.endpoints ?? [],
697
712
  optionsEndpoint,
698
713
  impactEndpoint,
699
714
  ...membersEndpoints({ tenantsSlug, superUser, isSuper })
700
715
  ],
701
- commands: [...config.commands ?? [], assign],
716
+ cliCommands: [...config.cliCommands ?? [], assign],
702
717
  // Audit log entries belong to their tenant: its admins see them.
703
718
  ...config.audit ? {
704
719
  audit: {
705
720
  ...config.audit === true ? {} : config.audit,
706
- scope: (context) => {
721
+ scope: ({ context }) => {
707
722
  const { tenant } = tenantOf(context);
708
723
  return tenant === null ? null : String(tenant);
709
724
  }
@@ -762,12 +777,12 @@ async function removeTenant(cms, id, collections) {
762
777
  }
763
778
 
764
779
  // src/index.ts
765
- async function findTenantFor(cms, by, tenantsSlug = "tenants") {
766
- const list = await tenantList(cms, tenantsSlug);
780
+ async function findTenantFor(cms, by) {
781
+ const list = await tenantList(cms, by.collection ?? "tenants");
767
782
  return (by.slug ? findTenant(list, by.slug) : void 0) ?? tenantOfHost(list, by.host) ?? null;
768
783
  }
769
- async function tenantContext(cms, by, tenantsSlug = "tenants") {
770
- const tenant = await findTenantFor(cms, by, tenantsSlug);
784
+ async function tenantContext(cms, by) {
785
+ const tenant = await findTenantFor(cms, by);
771
786
  return { tenant: tenant?.id ?? null, allTenants: false };
772
787
  }
773
788
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/plugin-multi-tenant",
3
- "version": "0.47.4",
3
+ "version": "0.49.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,14 +17,14 @@
17
17
  "dist"
18
18
  ],
19
19
  "peerDependencies": {
20
- "@easy-cms/core": "^0.47.4"
20
+ "@easy-cms/core": "^0.49.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@easy-cms/core": "^0.47.4",
24
- "@easy-cms/db-sqlite": "^0.47.4",
25
- "@easy-cms/plugin-form-builder": "^0.47.4",
26
- "@easy-cms/plugin-nested-docs": "^0.47.4",
27
- "@easy-cms/plugin-redirects": "^0.47.4"
23
+ "@easy-cms/core": "^0.49.0",
24
+ "@easy-cms/db-sqlite": "^0.49.0",
25
+ "@easy-cms/plugin-form-builder": "^0.49.0",
26
+ "@easy-cms/plugin-nested-docs": "^0.49.0",
27
+ "@easy-cms/plugin-redirects": "^0.49.0"
28
28
  },
29
29
  "engines": {
30
30
  "node": ">=22.12"