@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 +21 -12
- package/dist/index.js +43 -28
- package/package.json +7 -7
package/dist/index.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ interface Member {
|
|
|
34
34
|
readonly role: string;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
interface
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
238
|
-
[MEDIA_FOLDERS]: { read:
|
|
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.
|
|
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 ??
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
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))
|
|
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
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
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
|
|
766
|
-
const list = await tenantList(cms,
|
|
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
|
|
770
|
-
const tenant = await findTenantFor(cms, by
|
|
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.
|
|
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.
|
|
20
|
+
"@easy-cms/core": "^0.49.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@easy-cms/core": "^0.
|
|
24
|
-
"@easy-cms/db-sqlite": "^0.
|
|
25
|
-
"@easy-cms/plugin-form-builder": "^0.
|
|
26
|
-
"@easy-cms/plugin-nested-docs": "^0.
|
|
27
|
-
"@easy-cms/plugin-redirects": "^0.
|
|
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"
|