@easy-cms/plugin-multi-tenant 0.0.0-stage → 0.43.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kanawoot K.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @easy-cms/plugin-multi-tenant
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Several sites or clients in one Easy CMS: each tenant has its own documents in the collections you name, its own value of the globals you name, and its own members with a role there. Admins switch tenants at the top of the menu; frontends name their tenant with a header or their domain. Part of [Easy CMS](https://github.com/maritonx/easy-cms), the embedded, code-first headless CMS for Nuxt and Next.js.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @easy-cms/plugin-multi-tenant
9
+ ```
10
+
11
+ Or `pnpm add`, `yarn add` or `bun add`.
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { multiTenantPlugin } from '@easy-cms/plugin-multi-tenant'
17
+
18
+ export default defineConfig({
19
+ // …
20
+ plugins: [multiTenantPlugin({ collections: ['posts', 'pages', 'media'], globals: ['site'] })],
21
+ })
22
+ ```
23
+
24
+ ```bash
25
+ curl -H 'x-easy-cms-tenant: brand-a' https://cms.example.com/api/cms/posts
26
+ ```
27
+
28
+ ## Links
29
+
30
+ [Multi-tenant](https://maritonx.github.io/easy-cms/guide/multi-tenant) · [Documentation](https://maritonx.github.io/easy-cms/) ([ภาษาไทย](https://maritonx.github.io/easy-cms/th/)) · [GitHub](https://github.com/maritonx/easy-cms)
31
+
32
+ MIT License
package/dist/admin.js ADDED
@@ -0,0 +1,42 @@
1
+ var u={en:{lead:"People who work in this tenant, and their role here. An account can belong to several tenants.",email:"Email",name:"Name",role:"Role",actions:"Actions",remove:"Remove",removeConfirm:"Remove {email} from this tenant? Their account stays.",add:"Add member",adding:"Adding\u2026",added:"{email} was added.",invited:"{email} was added and emailed a link to set a password.",addedNoEmail:"{email} was added. Email is not set up, so send them a password link from Users.",none:"No members yet.",loading:"Loading\u2026",failed:"Couldn't load the members.",choose:"Choose a tenant at the top of the menu to see its members.",you:"you"},th:{lead:"\u0E04\u0E19\u0E17\u0E35\u0E48\u0E17\u0E33\u0E07\u0E32\u0E19\u0E43\u0E19 tenant \u0E19\u0E35\u0E49\u0E41\u0E25\u0E30\u0E1A\u0E17\u0E1A\u0E32\u0E17\u0E02\u0E2D\u0E07\u0E41\u0E15\u0E48\u0E25\u0E30\u0E04\u0E19 \u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2B\u0E19\u0E36\u0E48\u0E07\u0E2D\u0E22\u0E39\u0E48\u0E44\u0E14\u0E49\u0E2B\u0E25\u0E32\u0E22 tenant",email:"\u0E2D\u0E35\u0E40\u0E21\u0E25",name:"\u0E0A\u0E37\u0E48\u0E2D",role:"\u0E1A\u0E17\u0E1A\u0E32\u0E17",actions:"\u0E01\u0E32\u0E23\u0E17\u0E33\u0E07\u0E32\u0E19",remove:"\u0E19\u0E33\u0E2D\u0E2D\u0E01",removeConfirm:"\u0E19\u0E33 {email} \u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01 tenant \u0E19\u0E35\u0E49\u0E44\u0E2B\u0E21 \u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E02\u0E2D\u0E07\u0E40\u0E02\u0E32\u0E22\u0E31\u0E07\u0E2D\u0E22\u0E39\u0E48",add:"\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01",adding:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E40\u0E1E\u0E34\u0E48\u0E21\u2026",added:"\u0E40\u0E1E\u0E34\u0E48\u0E21 {email} \u0E41\u0E25\u0E49\u0E27",invited:"\u0E40\u0E1E\u0E34\u0E48\u0E21 {email} \u0E41\u0E25\u0E49\u0E27 \u0E41\u0E25\u0E30\u0E2A\u0E48\u0E07\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E17\u0E32\u0E07\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E41\u0E25\u0E49\u0E27",addedNoEmail:"\u0E40\u0E1E\u0E34\u0E48\u0E21 {email} \u0E41\u0E25\u0E49\u0E27 \u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E2D\u0E35\u0E40\u0E21\u0E25 \u0E2A\u0E48\u0E07\u0E25\u0E34\u0E07\u0E01\u0E4C\u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E08\u0E32\u0E01\u0E2B\u0E19\u0E49\u0E32\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E17\u0E19",none:"\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01",loading:"\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026",failed:"\u0E42\u0E2B\u0E25\u0E14\u0E23\u0E32\u0E22\u0E0A\u0E37\u0E48\u0E2D\u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08",choose:"\u0E40\u0E25\u0E37\u0E2D\u0E01 tenant \u0E17\u0E35\u0E48\u0E14\u0E49\u0E32\u0E19\u0E1A\u0E19\u0E02\u0E2D\u0E07\u0E40\u0E21\u0E19\u0E39\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E14\u0E39\u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01",you:"\u0E04\u0E38\u0E13"}},e=l=>l.replace(/[&<>"']/g,a=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[a]),m=`
2
+ :host { display: block; font: inherit; color: var(--text); }
3
+ [hidden] { display: none !important; }
4
+ p { margin: 0 0 1rem; }
5
+ .muted { color: var(--text-muted); }
6
+ .card { border: 1px solid var(--border); border-radius: var(--radius, 12px); background: var(--surface); overflow-x: auto; }
7
+ table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
8
+ th { text-align: left; font-size: 0.75rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.02em; }
9
+ th, td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
10
+ tr:last-child td { border-bottom: 0; }
11
+ form { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; margin: 1.25rem 0 0.5rem; }
12
+ label { display: grid; gap: 0.25rem; font-size: 0.8rem; color: var(--text-muted); }
13
+ input, select {
14
+ min-height: 2.25rem; padding: 0 0.6rem; border: 1px solid var(--border-strong); border-radius: var(--radius-sm, 8px);
15
+ background: var(--surface); color: var(--text); font: inherit; font-size: 0.9rem;
16
+ }
17
+ input { min-width: 16rem; }
18
+ button {
19
+ min-height: 2.25rem; padding: 0 0.9rem; border: 1px solid var(--border-strong); border-radius: var(--radius-sm, 8px);
20
+ background: var(--surface); color: var(--text); font: inherit; font-size: 0.875rem; cursor: pointer;
21
+ }
22
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); font-weight: 600; }
23
+ button:disabled { opacity: 0.6; cursor: default; }
24
+ button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
25
+ .status { min-height: 1.25rem; font-size: 0.875rem; }
26
+ .error { color: var(--danger, #b42318); }
27
+ `,d=class extends HTMLElement{api;uiLocale;user;data=null;state="loading";message="";error="";busy=!1;#e=!1;constructor(){super(),this.attachShadow({mode:"open"})}connectedCallback(){this.render(),this.#e||(this.#e=!0,this.load())}get uiLang(){return this.uiLocale==="th"?"th":"en"}get me(){return this.user?.id===void 0?void 0:String(this.user.id)}t(a,r={}){return u[this.uiLang][a].replace(/\{(\w+)\}/g,(t,s)=>r[s]??"")}async load(){if(this.api){try{this.data=await this.api("GET","/tenant-members"),this.state="ready"}catch(a){this.state=a.status===403?"choose":"failed"}this.render()}}async run(a){this.busy=!0,this.error="",this.message="",this.render();try{this.message=await a()??"",await this.load()}catch(r){this.error=r.message}finally{this.busy=!1,this.render()}}roleOptions(a){return(this.data?.roles??[]).map(r=>`<option value="${e(r.key)}"${r.key===a?" selected":""}>${e(r.name||r.key)}</option>`).join("")}render(){let a=this.shadowRoot;if(this.state!=="ready"||!this.data){let t=this.state==="loading"?this.t("loading"):this.state==="choose"?this.t("choose"):this.t("failed");a.innerHTML=`<style>${m}</style><p class="muted">${e(t)}</p>`;return}let r=this.data.members.map(t=>`<tr data-id="${e(String(t.id))}">
28
+ <td>${e(t.email)}${String(t.id)===this.me?` <span class="muted">(${e(this.t("you"))})</span>`:""}</td>
29
+ <td>${e(t.name??"")}</td>
30
+ <td><select data-role aria-label="${e(`${this.t("role")}: ${t.email}`)}"${this.busy?" disabled":""}>${this.roleOptions(t.role)}</select></td>
31
+ <td>${String(t.id)===this.me?"":`<button type="button" data-remove${this.busy?" disabled":""}>${e(this.t("remove"))}</button>`}</td>
32
+ </tr>`).join("");a.innerHTML=`<style>${m}</style>
33
+ <p class="muted">${e(this.t("lead"))}</p>
34
+ <div class="card">
35
+ ${r?`<table><thead><tr><th>${e(this.t("email"))}</th><th>${e(this.t("name"))}</th><th>${e(this.t("role"))}</th><th><span class="muted">${e(this.t("actions"))}</span></th></tr></thead><tbody>${r}</tbody></table>`:`<p class="muted" style="padding:1rem;margin:0">${e(this.t("none"))}</p>`}
36
+ </div>
37
+ <form>
38
+ <label>${e(this.t("email"))}<input type="email" name="email" required autocomplete="off"></label>
39
+ <label>${e(this.t("role"))}<select name="role">${this.roleOptions("editor")}</select></label>
40
+ <button type="submit" class="primary"${this.busy?" disabled":""}>${e(this.busy?this.t("adding"):this.t("add"))}</button>
41
+ </form>
42
+ <p class="status ${this.error?"error":""}" role="status">${e(this.error||this.message)}</p>`,a.querySelector("form")?.addEventListener("submit",t=>{t.preventDefault();let s=t.target,i=s.elements.namedItem("email").value.trim(),n=s.elements.namedItem("role").value;this.run(async()=>{let o=await this.api?.("POST","/tenant-members",{email:i,role:n});return this.t(o.invited?"invited":this.data?.invites===!1?"addedNoEmail":"added",{email:i})})});for(let t of a.querySelectorAll("tr[data-id]")){let s=t.dataset.id,i=t.querySelector("td")?.textContent?.trim()??"";t.querySelector("[data-role]")?.addEventListener("change",n=>{let o=n.target.value;this.run(async()=>{await this.api?.("PATCH",`/tenant-members/${encodeURIComponent(s)}`,{role:o})})}),t.querySelector("[data-remove]")?.addEventListener("click",()=>{window.confirm(this.t("removeConfirm",{email:i}))&&this.run(async()=>{await this.api?.("DELETE",`/tenant-members/${encodeURIComponent(s)}`)})})}}};customElements.get("ecms-tenant-members")||customElements.define("ecms-tenant-members",d);
@@ -0,0 +1,146 @@
1
+ import { ID, RequestContext, AuthUser, TypedPlugin, EasyCMS } from '@easy-cms/core';
2
+
3
+ /** "All tenants" in the switcher, the header or the cookie. */
4
+ declare const ALL = "*";
5
+ /**
6
+ * What a request knows about tenants, in its `context`:
7
+ * - `tenant`: the id of the tenant it works in, `null` for none (or all of them);
8
+ * - `allTenants`: it may see every tenant (users who have access to all of them, and
9
+ * anonymous reads with `publicReads: 'all'`).
10
+ */
11
+ interface TenantContext {
12
+ readonly tenant: ID | null;
13
+ readonly allTenants: boolean;
14
+ }
15
+ declare const tenantOf: (context: RequestContext | undefined) => TenantContext;
16
+ /** One row of a user's `tenants`. */
17
+ interface Membership {
18
+ readonly tenant: ID;
19
+ readonly role: string;
20
+ }
21
+ /** A tenant as `onRequest` and the helpers know it. */
22
+ interface TenantRef {
23
+ readonly id: ID;
24
+ readonly name: string;
25
+ readonly slug: string;
26
+ readonly domains: readonly string[];
27
+ }
28
+
29
+ /** A member as the Members page shows them. */
30
+ interface Member {
31
+ readonly id: ID;
32
+ readonly email: string;
33
+ readonly name: string | null;
34
+ readonly role: string;
35
+ }
36
+
37
+ interface MultiTenantOptions<S extends string = string, T extends string = string> {
38
+ /**
39
+ * Collections whose documents belong to one tenant, by slug, e.g. `['posts', 'pages',
40
+ * 'media']`. With `media` and `upload.folders`, the media folders too. Others are shared.
41
+ */
42
+ readonly collections: readonly S[];
43
+ /** Globals with a value per tenant, e.g. `['site-settings']`. Others are shared. */
44
+ readonly globals?: readonly string[];
45
+ /** Slug of the tenants collection. Default `tenants`. */
46
+ readonly tenantsSlug?: T;
47
+ /**
48
+ * Users who see and manage every tenant and the whole system. Default: users whose own role
49
+ * is `admin`.
50
+ */
51
+ readonly userHasAccessToAllTenants?: (user: AuthUser) => boolean;
52
+ /**
53
+ * Reads that name no tenant (no header, `?tenant=`, cookie or matching domain): `none`
54
+ * (default) finds nothing; `all` finds every tenant's published documents.
55
+ */
56
+ readonly publicReads?: 'none' | 'all';
57
+ /** Request header naming the tenant, by slug or id. Default `x-easy-cms-tenant`. */
58
+ readonly header?: string;
59
+ /** Cookie in which the admin keeps the chosen tenant. Default `ecms-tenant`. */
60
+ readonly cookie?: string;
61
+ }
62
+ type TenantFieldType<T extends string> = {
63
+ readonly name: 'tenant';
64
+ readonly type: 'relationship';
65
+ readonly to: T;
66
+ };
67
+ /** What `multiTenantPlugin(options)` adds, for the types the Local API infers from the config. */
68
+ type MultiTenantPluginTypes<S extends string, T extends string = 'tenants'> = {
69
+ readonly fields: {
70
+ readonly [K in S]: readonly [TenantFieldType<T>];
71
+ } & {
72
+ readonly users: readonly [
73
+ {
74
+ readonly name: 'tenants';
75
+ readonly type: 'array';
76
+ readonly fields: readonly [
77
+ {
78
+ readonly name: 'tenant';
79
+ readonly type: 'relationship';
80
+ readonly to: T;
81
+ readonly required: true;
82
+ },
83
+ {
84
+ readonly name: 'role';
85
+ readonly type: 'text';
86
+ readonly required: true;
87
+ }
88
+ ];
89
+ }
90
+ ];
91
+ };
92
+ readonly collections: readonly [
93
+ {
94
+ readonly slug: T;
95
+ readonly fields: readonly [
96
+ {
97
+ readonly name: 'name';
98
+ readonly type: 'text';
99
+ readonly required: true;
100
+ },
101
+ {
102
+ readonly name: 'slug';
103
+ readonly type: 'slug';
104
+ },
105
+ {
106
+ readonly name: 'domains';
107
+ readonly type: 'array';
108
+ readonly fields: readonly [
109
+ {
110
+ readonly name: 'domain';
111
+ readonly type: 'text';
112
+ readonly required: true;
113
+ }
114
+ ];
115
+ }
116
+ ];
117
+ }
118
+ ];
119
+ };
120
+ /**
121
+ * Several sites or clients in one CMS: a `tenants` collection, a `tenant` on the documents of
122
+ * the collections you name, members with a role in each tenant, a tenant switcher in the admin,
123
+ * and globals with a value per tenant. Requests name their tenant with a header, `?tenant=`,
124
+ * the admin's cookie or the domain.
125
+ */
126
+ declare function multiTenantPlugin<const S extends string, const T extends string = 'tenants'>(options: MultiTenantOptions<S, T>): TypedPlugin<MultiTenantPluginTypes<S, T>>;
127
+
128
+ /**
129
+ * The tenant of a page of your site, by slug or by domain (`domains` of the tenant), e.g. from
130
+ * the request's host. `null` when none matches.
131
+ */
132
+ declare function findTenantFor(cms: EasyCMS, by: {
133
+ readonly slug?: string;
134
+ readonly host?: string;
135
+ }, tenantsSlug?: string): Promise<TenantRef | null>;
136
+ /**
137
+ * The `context` for Local API calls that read one tenant's content, e.g.
138
+ * `cms.find('posts', { overrideAccess: false, user: null, context: await tenantContext(cms, { host }) })`.
139
+ * A tenant that doesn't exist gives a context that finds nothing.
140
+ */
141
+ declare function tenantContext(cms: EasyCMS, by: {
142
+ readonly slug?: string;
143
+ readonly host?: string;
144
+ }, tenantsSlug?: string): Promise<RequestContext>;
145
+
146
+ export { ALL, type Member, type Membership, type MultiTenantOptions, type MultiTenantPluginTypes, type TenantContext, type TenantRef, findTenantFor, multiTenantPlugin, tenantContext, tenantOf };
package/dist/index.js ADDED
@@ -0,0 +1,648 @@
1
+ // src/shared.ts
2
+ var TENANT_FIELD = "tenant";
3
+ var MEMBERSHIPS_FIELD = "tenants";
4
+ var ALL = "*";
5
+ var tenantOf = (context) => ({
6
+ tenant: context?.tenant ?? null,
7
+ allTenants: context?.allTenants === true
8
+ });
9
+ function membershipsOf(user) {
10
+ const rows = user?.[MEMBERSHIPS_FIELD];
11
+ if (!Array.isArray(rows)) return [];
12
+ return rows.flatMap((row) => {
13
+ const r = row;
14
+ const tenant = r.tenant && typeof r.tenant === "object" ? r.tenant.id : r.tenant;
15
+ return tenant === null || tenant === void 0 ? [] : [{ tenant, role: typeof r.role === "string" ? r.role : "editor" }];
16
+ });
17
+ }
18
+ var NOTHING = {
19
+ and: [{ [TENANT_FIELD]: { exists: true } }, { [TENANT_FIELD]: { exists: false } }]
20
+ };
21
+ var sameId = (a, b) => a !== null && a !== void 0 && b !== null && b !== void 0 && String(a) === String(b);
22
+ var CACHE = /* @__PURE__ */ Symbol.for("easy-cms.multi-tenant.tenants");
23
+ var TTL = 1e4;
24
+ async function tenantList(cms, slug) {
25
+ const holder = cms;
26
+ const cached = holder[CACHE];
27
+ if (cached && Date.now() - cached.at < TTL) return cached.list;
28
+ const list = cms.find(slug, { limit: 0, depth: 0, sort: "name" }).then(
29
+ ({ docs }) => docs.map((doc) => ({
30
+ id: doc.id,
31
+ name: String(doc.name ?? doc.slug ?? doc.id),
32
+ slug: String(doc.slug ?? ""),
33
+ domains: Array.isArray(doc.domains) ? doc.domains.map((d) => String(d.domain ?? "").toLowerCase()).filter(Boolean) : []
34
+ }))
35
+ );
36
+ holder[CACHE] = { at: Date.now(), list };
37
+ list.catch(() => {
38
+ if (holder[CACHE]?.list === list) delete holder[CACHE];
39
+ });
40
+ return list;
41
+ }
42
+ function forgetTenants(cms) {
43
+ delete cms[CACHE];
44
+ }
45
+ var hostName = (host) => (host ?? "").trim().toLowerCase().replace(/:\d+$/, "");
46
+ var findTenant = (list, value) => value ? list.find((t) => String(t.id) === value || t.slug === value) : void 0;
47
+ var tenantOfHost = (list, host) => {
48
+ const name = hostName(host);
49
+ return name ? list.find((t) => t.domains.includes(name)) : void 0;
50
+ };
51
+
52
+ // src/plugin.ts
53
+ import {
54
+ definePlugin,
55
+ isLoggedIn,
56
+ MEDIA,
57
+ MEDIA_FOLDERS,
58
+ ValidationError as ValidationError2
59
+ } from "@easy-cms/core";
60
+
61
+ // src/info.ts
62
+ var INFO = {
63
+ name: "@easy-cms/plugin-multi-tenant",
64
+ ...true ? { version: "0.43.0" } : {}
65
+ };
66
+
67
+ // src/members.ts
68
+ import {
69
+ ForbiddenError,
70
+ NotFoundError,
71
+ ValidationError
72
+ } from "@easy-cms/core";
73
+ function membersEndpoints(options) {
74
+ const tenantFor = (user, context) => {
75
+ const { tenant } = tenantOf(context);
76
+ if (!user) throw new ForbiddenError("Log in first");
77
+ if (tenant === null) throw new ForbiddenError("Choose a tenant first");
78
+ if (user.role !== "admin" && !options.superUser(user, context))
79
+ throw new ForbiddenError("Only admins of this tenant manage its members");
80
+ return tenant;
81
+ };
82
+ const roleOf = async (cms, value) => {
83
+ const roles = await cms.roles.options();
84
+ const role = typeof value === "string" ? value : "";
85
+ if (!roles.some((r) => r.key === role))
86
+ throw new ValidationError("users", [{ field: "role", message: `there is no role "${role}"` }]);
87
+ return role;
88
+ };
89
+ const member = (doc, tenant) => ({
90
+ id: doc.id,
91
+ email: String(doc.email ?? ""),
92
+ name: typeof doc.name === "string" ? doc.name : null,
93
+ role: membershipsOf(doc).find((m) => sameId(m.tenant, tenant))?.role ?? ""
94
+ });
95
+ const rows = (doc) => (doc[MEMBERSHIPS_FIELD] ?? []).map((row) => ({ ...row }));
96
+ const findMember = async (cms, id, tenant) => {
97
+ const doc = await cms.findById("users", /^\d+$/.test(id) ? Number(id) : id, {
98
+ depth: 0
99
+ });
100
+ if (!doc || !membershipsOf(doc).some((m) => sameId(m.tenant, tenant)))
101
+ throw new NotFoundError("members", id);
102
+ return doc;
103
+ };
104
+ return [
105
+ {
106
+ path: "/tenant-members",
107
+ method: "get",
108
+ handler: async ({ user, context, cms }) => {
109
+ const tenant = tenantFor(user, context);
110
+ const { docs } = await cms.find("users", {
111
+ where: { [`${MEMBERSHIPS_FIELD}.tenant`]: { equals: tenant } },
112
+ sort: "email",
113
+ limit: 0,
114
+ depth: 0
115
+ });
116
+ return {
117
+ members: docs.map((doc) => member(doc, tenant)),
118
+ roles: await cms.roles.options(),
119
+ invites: cms.auth.canSendPasswordLinks()
120
+ };
121
+ }
122
+ },
123
+ {
124
+ path: "/tenant-members",
125
+ method: "post",
126
+ handler: async ({ user, context, cms, json, url }) => {
127
+ const tenant = tenantFor(user, context);
128
+ const body = await json();
129
+ const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
130
+ if (!/^[^\s@]+@[^\s@]+$/.test(email))
131
+ throw new ValidationError("users", [
132
+ { field: "email", message: "is not an email address" }
133
+ ]);
134
+ const role = await roleOf(cms, body.role);
135
+ const { docs } = await cms.find("users", {
136
+ where: { email: { equals: email } },
137
+ limit: 1,
138
+ depth: 0
139
+ });
140
+ const existing = docs[0];
141
+ if (existing) {
142
+ const list = rows(existing).filter((row) => !sameId(row.tenant, tenant));
143
+ const doc2 = await cms.update(
144
+ "users",
145
+ existing.id,
146
+ { [MEMBERSHIPS_FIELD]: [...list, { tenant, role }] },
147
+ { depth: 0 }
148
+ );
149
+ return { member: member(doc2, tenant), invited: false };
150
+ }
151
+ const doc = await cms.create(
152
+ "users",
153
+ { email, [MEMBERSHIPS_FIELD]: [{ tenant, role }] },
154
+ { depth: 0 }
155
+ );
156
+ let invited = false;
157
+ if (cms.auth.canSendPasswordLinks(url.origin)) {
158
+ await cms.auth.sendPasswordLink(doc.id, { origin: url.origin });
159
+ invited = true;
160
+ }
161
+ return Response.json({ member: member(doc, tenant), invited }, { status: 201 });
162
+ }
163
+ },
164
+ {
165
+ path: "/tenant-members/:id",
166
+ method: "patch",
167
+ handler: async ({ user, context, cms, json, params }) => {
168
+ const tenant = tenantFor(user, context);
169
+ const doc = await findMember(cms, params.id, tenant);
170
+ const role = await roleOf(cms, (await json()).role);
171
+ const list = rows(doc).map((row) => sameId(row.tenant, tenant) ? { ...row, role } : row);
172
+ const updated = await cms.update(
173
+ "users",
174
+ doc.id,
175
+ { [MEMBERSHIPS_FIELD]: list },
176
+ { depth: 0 }
177
+ );
178
+ return { member: member(updated, tenant) };
179
+ }
180
+ },
181
+ {
182
+ path: "/tenant-members/:id",
183
+ method: "delete",
184
+ handler: async ({ user, context, cms, params }) => {
185
+ const tenant = tenantFor(user, context);
186
+ const doc = await findMember(cms, params.id, tenant);
187
+ if (sameId(doc.id, user?.id))
188
+ throw new ValidationError("users", [
189
+ { field: "id", message: "you can't remove yourself from a tenant" }
190
+ ]);
191
+ const list = rows(doc).filter((row) => !sameId(row.tenant, tenant));
192
+ await cms.update("users", doc.id, { [MEMBERSHIPS_FIELD]: list }, {
193
+ depth: 0
194
+ });
195
+ return { removed: true };
196
+ }
197
+ }
198
+ ];
199
+ }
200
+
201
+ // src/plugin.ts
202
+ var BUILTIN_ACCESS = {
203
+ [MEDIA]: { read: () => true, create: isLoggedIn, update: isLoggedIn, delete: isLoggedIn },
204
+ [MEDIA_FOLDERS]: { read: isLoggedIn, create: isLoggedIn, update: isLoggedIn, delete: isLoggedIn }
205
+ };
206
+ function readCookie(header, name) {
207
+ for (const part of header?.split(";") ?? []) {
208
+ const eq = part.indexOf("=");
209
+ if (eq > 0 && part.slice(0, eq).trim() === name)
210
+ return decodeURIComponent(part.slice(eq + 1).trim());
211
+ }
212
+ return void 0;
213
+ }
214
+ function multiTenantPlugin(options) {
215
+ return definePlugin((config) => {
216
+ const tenantsSlug = options.tenantsSlug ?? "tenants";
217
+ const header = (options.header ?? "x-easy-cms-tenant").toLowerCase();
218
+ const cookie = options.cookie ?? "ecms-tenant";
219
+ const publicReads = options.publicReads ?? "none";
220
+ const isSuper = options.userHasAccessToAllTenants ?? ((user) => user.role === "admin");
221
+ const scoped = new Set(options.collections);
222
+ if (scoped.has(MEDIA) && config.upload?.folders === true) scoped.add(MEDIA_FOLDERS);
223
+ const declared = new Set((config.collections ?? []).map((c) => c.slug));
224
+ const missing = [...scoped].filter(
225
+ (s) => !declared.has(s) && s !== MEDIA && s !== MEDIA_FOLDERS
226
+ );
227
+ if (missing.length)
228
+ throw new Error(
229
+ `multiTenantPlugin: no collection ${missing.map((s) => `"${s}"`).join(", ")} in the config (add plugins that make collections before this one)`
230
+ );
231
+ if (scoped.has(tenantsSlug) || scoped.has("users"))
232
+ throw new Error(`multiTenantPlugin: "${tenantsSlug}" and "users" can't belong to a tenant`);
233
+ const perTenant = new Set(options.globals ?? []);
234
+ const unknownGlobals = [...perTenant].filter(
235
+ (g) => !(config.globals ?? []).some((global) => global.slug === g)
236
+ );
237
+ if (unknownGlobals.length)
238
+ throw new Error(
239
+ `multiTenantPlugin: no global ${unknownGlobals.map((g) => `"${g}"`).join(", ")} in the config`
240
+ );
241
+ const everywhere = (user, context) => tenantOf(context).allTenants || !!user && user.scoped !== true && isSuper(user);
242
+ const superUser = (user, context) => !!user && everywhere(user, context);
243
+ const tenantFilter = (args, op) => {
244
+ const { tenant } = tenantOf(args.context);
245
+ if (op === "create") return tenant !== null || everywhere(args.user, args.context);
246
+ if (tenant !== null) return { [TENANT_FIELD]: { equals: tenant } };
247
+ if (everywhere(args.user, args.context)) return true;
248
+ return op === "read" ? NOTHING : false;
249
+ };
250
+ const narrow = (original, op) => async (args) => {
251
+ const base = await (original ?? isLoggedIn)(args);
252
+ if (base === false) return false;
253
+ const filter = tenantFilter(args, op);
254
+ if (filter === false) return false;
255
+ if (filter === true) return base;
256
+ return base === true ? filter : { and: [base, filter] };
257
+ };
258
+ const withTenantFilters = (fields) => fields.map((field) => {
259
+ if (field.type === "group" || field.type === "array")
260
+ return { ...field, fields: withTenantFilters(field.fields) };
261
+ if (field.type === "blocks")
262
+ return {
263
+ ...field,
264
+ blocks: field.blocks.map((b) => ({ ...b, fields: withTenantFilters(b.fields) }))
265
+ };
266
+ if (field.type !== "relationship" || !scoped.has(field.to)) return field;
267
+ const own = field.filterOptions;
268
+ return {
269
+ ...field,
270
+ filterOptions: async (args) => {
271
+ const base = own ? await own(args) : true;
272
+ const { tenant } = tenantOf(args.context);
273
+ if (tenant === null) return base;
274
+ const mine = { [TENANT_FIELD]: { equals: tenant } };
275
+ return base === true ? mine : { and: [base, mine] };
276
+ }
277
+ };
278
+ });
279
+ const tenantField = {
280
+ name: TENANT_FIELD,
281
+ type: "relationship",
282
+ to: tenantsSlug,
283
+ index: true,
284
+ position: "sidebar",
285
+ label: { en: "Tenant", th: "Tenant" },
286
+ // Set from the tenant the request works in; only users with access to all tenants choose.
287
+ access: { update: ({ user, context }) => superUser(user, context) }
288
+ };
289
+ const fillTenant = ({ data, operation, context }) => {
290
+ if (operation !== "create" || (data[TENANT_FIELD] ?? null) !== null) return data;
291
+ const { tenant } = tenantOf(context);
292
+ return tenant === null ? data : { ...data, [TENANT_FIELD]: tenant };
293
+ };
294
+ const checkTenant = (slug) => ({ data, operation, originalDoc, context, user }) => {
295
+ const value = data[TENANT_FIELD] ?? null;
296
+ const { tenant } = tenantOf(context);
297
+ if (!user && tenant === null) return data;
298
+ const fail = (message) => {
299
+ throw new ValidationError2(slug, [{ field: TENANT_FIELD, message }]);
300
+ };
301
+ if (value === null) fail("is required: choose a tenant");
302
+ if (everywhere(user, context)) return data;
303
+ if (!sameId(value, tenant)) fail("must be the tenant you work in");
304
+ const before = originalDoc?.[TENANT_FIELD] ?? null;
305
+ if (operation === "update" && before !== null && !sameId(before, value))
306
+ fail("can't move to another tenant");
307
+ return data;
308
+ };
309
+ const scopeCollection = (c, builtin) => {
310
+ const fields = withTenantFilters(
311
+ c.fields.map(
312
+ (f) => (f.unique || f.type === "slug") && f.uniqueWithin === void 0 ? { ...f, uniqueWithin: TENANT_FIELD } : f
313
+ )
314
+ );
315
+ const access = { ...builtin, ...c.access };
316
+ return {
317
+ ...c,
318
+ fields: fields.some((f) => f.name === TENANT_FIELD) ? fields : [...fields, tenantField],
319
+ access: {
320
+ read: narrow(access.read, "read"),
321
+ create: narrow(access.create, "create"),
322
+ update: narrow(access.update, "update"),
323
+ delete: narrow(access.delete, "delete")
324
+ },
325
+ hooks: {
326
+ ...c.hooks,
327
+ beforeValidate: [fillTenant, ...c.hooks?.beforeValidate ?? []],
328
+ beforeChange: [...c.hooks?.beforeChange ?? [], checkTenant(c.slug)]
329
+ }
330
+ };
331
+ };
332
+ const memberships = {
333
+ name: MEMBERSHIPS_FIELD,
334
+ type: "array",
335
+ label: { en: "Tenants", th: "Tenant" },
336
+ access: { update: ({ user, context }) => superUser(user, context) },
337
+ fields: [
338
+ { name: "tenant", type: "relationship", to: tenantsSlug, required: true },
339
+ {
340
+ name: "role",
341
+ type: "text",
342
+ required: true,
343
+ defaultValue: "editor",
344
+ label: { en: "Role", th: "\u0E1A\u0E17\u0E1A\u0E32\u0E17" }
345
+ }
346
+ ]
347
+ };
348
+ const usersAccess = (custom) => {
349
+ const and = (own, mine) => own ? async (args) => {
350
+ const a = await own(args);
351
+ if (a === false) return false;
352
+ const b = await mine(args);
353
+ if (b === false) return false;
354
+ if (a === true) return b;
355
+ return b === true ? a : { and: [a, b] };
356
+ } : mine;
357
+ const self = (user) => ({ id: { equals: user.id } });
358
+ return {
359
+ read: and(custom?.read, ({ user, context }) => {
360
+ if (!user) return false;
361
+ if (superUser(user, context)) return true;
362
+ const { tenant } = tenantOf(context);
363
+ return tenant === null ? self(user) : { or: [self(user), { [`${MEMBERSHIPS_FIELD}.tenant`]: { equals: tenant } }] };
364
+ }),
365
+ // Members are added through Members (`/tenant-members`), not by creating users.
366
+ create: and(custom?.create, ({ user, context }) => superUser(user, context)),
367
+ update: and(
368
+ custom?.update,
369
+ ({ user, context }) => !user ? false : superUser(user, context) ? true : self(user)
370
+ ),
371
+ delete: and(custom?.delete, ({ user, context }) => superUser(user, context))
372
+ };
373
+ };
374
+ const tenantsAccess = {
375
+ read: ({ user, context }) => {
376
+ if (!user) return false;
377
+ if (superUser(user, context)) return true;
378
+ const ids = membershipsOf(user).map((m) => m.tenant);
379
+ return ids.length ? { id: { in: ids } } : false;
380
+ },
381
+ create: ({ user, context }) => superUser(user, context),
382
+ update: ({ user, context }) => superUser(user, context),
383
+ delete: ({ user, context }) => superUser(user, context)
384
+ };
385
+ const tenantsCollection = {
386
+ slug: tenantsSlug,
387
+ labels: { singular: { en: "Tenant", th: "Tenant" }, plural: { en: "Tenants", th: "Tenant" } },
388
+ icon: "building",
389
+ useAsTitle: "name",
390
+ admin: { group: "settings" },
391
+ access: tenantsAccess,
392
+ hooks: {
393
+ afterChange: [({ cms }) => forgetTenants(cms)],
394
+ afterDelete: [
395
+ async ({ cms, id }) => {
396
+ forgetTenants(cms);
397
+ await removeTenant(cms, id, [...scoped]);
398
+ }
399
+ ]
400
+ },
401
+ fields: [
402
+ { name: "name", type: "text", required: true, label: { en: "Name", th: "\u0E0A\u0E37\u0E48\u0E2D" } },
403
+ { name: "slug", type: "slug", from: "name", label: { en: "Slug", th: "Slug" } },
404
+ {
405
+ name: "domains",
406
+ type: "array",
407
+ label: { en: "Domains", th: "\u0E42\u0E14\u0E40\u0E21\u0E19" },
408
+ fields: [
409
+ {
410
+ name: "domain",
411
+ type: "text",
412
+ required: true,
413
+ label: { en: "Domain, e.g. example.com", th: "\u0E42\u0E14\u0E40\u0E21\u0E19 \u0E40\u0E0A\u0E48\u0E19 example.com" }
414
+ }
415
+ ]
416
+ }
417
+ ]
418
+ };
419
+ const collections = [];
420
+ const ownTenants = (config.collections ?? []).find((c) => c.slug === tenantsSlug);
421
+ collections.push(
422
+ ownTenants ? {
423
+ ...tenantsCollection,
424
+ ...ownTenants,
425
+ fields: [...tenantsCollection.fields, ...ownTenants.fields],
426
+ access: tenantsAccess,
427
+ hooks: {
428
+ ...ownTenants.hooks,
429
+ afterChange: [
430
+ ...tenantsCollection.hooks?.afterChange ?? [],
431
+ ...ownTenants.hooks?.afterChange ?? []
432
+ ],
433
+ afterDelete: [
434
+ ...tenantsCollection.hooks?.afterDelete ?? [],
435
+ ...ownTenants.hooks?.afterDelete ?? []
436
+ ]
437
+ }
438
+ } : tenantsCollection
439
+ );
440
+ let hasUsers = false;
441
+ for (const c of config.collections ?? []) {
442
+ if (c.slug === tenantsSlug) continue;
443
+ if (c.slug === "users") {
444
+ hasUsers = true;
445
+ collections.push({
446
+ ...c,
447
+ fields: [...withTenantFilters(c.fields), memberships],
448
+ access: { ...c.access, ...usersAccess(c.access) }
449
+ });
450
+ } else if (scoped.has(c.slug)) collections.push(scopeCollection(c, BUILTIN_ACCESS[c.slug]));
451
+ else collections.push({ ...c, fields: withTenantFilters(c.fields) });
452
+ }
453
+ if (!hasUsers) collections.push({ slug: "users", fields: [memberships], access: usersAccess() });
454
+ for (const builtin of [MEDIA, MEDIA_FOLDERS]) {
455
+ if (scoped.has(builtin) && !declared.has(builtin))
456
+ collections.push(scopeCollection({ slug: builtin, fields: [] }, BUILTIN_ACCESS[builtin]));
457
+ }
458
+ const globals = (config.globals ?? []).map((g) => {
459
+ const filtered = { ...g, fields: withTenantFilters(g.fields) };
460
+ if (!perTenant.has(g.slug)) return filtered;
461
+ return {
462
+ ...filtered,
463
+ // One value per tenant; none when the request has no tenant.
464
+ scope: ({ context }) => {
465
+ const { tenant } = tenantOf(context);
466
+ return tenant === null ? null : String(tenant);
467
+ }
468
+ };
469
+ });
470
+ const previous = config.onRequest;
471
+ const onRequest = async (args) => {
472
+ const before = previous ? await previous(args) : void 0;
473
+ const user = before?.user !== void 0 ? before.user : args.user;
474
+ const context = { ...before?.context };
475
+ const list = await tenantList(args.cms, tenantsSlug);
476
+ const asked = args.headers.get(header) ?? args.url?.searchParams.get("tenant") ?? readCookie(args.headers.get("cookie"), cookie) ?? null;
477
+ const byHost = tenantOfHost(
478
+ list,
479
+ args.headers.get("x-forwarded-host") ?? args.headers.get("host") ?? args.url?.host
480
+ );
481
+ const bound = user?.apiKey?.permissions?.context;
482
+ const boundTenant = bound ? tenantOf(bound) : void 0;
483
+ if (user && isSuper(user) && (!boundTenant || boundTenant.tenant === null)) {
484
+ const chosen2 = asked === ALL ? void 0 : findTenant(list, asked);
485
+ return { user, context: { ...context, tenant: chosen2?.id ?? null, allTenants: true } };
486
+ }
487
+ if (user) {
488
+ const mine = membershipsOf(user);
489
+ const member = (t) => t ? mine.find((m) => sameId(m.tenant, t.id)) : void 0;
490
+ 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)));
491
+ return {
492
+ user: { ...user, role: membership?.role ?? user.role, scoped: true },
493
+ context: { ...context, tenant: membership?.tenant ?? null, allTenants: false }
494
+ };
495
+ }
496
+ const chosen = asked && asked !== ALL ? findTenant(list, asked) : byHost;
497
+ return {
498
+ user,
499
+ context: {
500
+ ...context,
501
+ tenant: chosen?.id ?? null,
502
+ allTenants: !chosen && publicReads === "all"
503
+ }
504
+ };
505
+ };
506
+ const optionsEndpoint = {
507
+ path: "/tenant-options",
508
+ method: "get",
509
+ handler: async ({ user, context, cms }) => {
510
+ if (!user) return Response.json({ errors: [{ message: "Log in first" }] }, { status: 401 });
511
+ const list = await tenantList(cms, tenantsSlug);
512
+ const all = superUser(user, context);
513
+ const mine = membershipsOf(user);
514
+ return {
515
+ options: list.filter((t) => all || mine.some((m) => sameId(m.tenant, t.id))).map((t) => ({ value: t.slug || String(t.id), label: t.name })),
516
+ ...all ? { all: { en: "All tenants", th: "\u0E17\u0E38\u0E01 tenant" } } : {}
517
+ };
518
+ }
519
+ };
520
+ const assign = {
521
+ name: "tenants:assign",
522
+ description: "Give documents without a tenant to one (after adding the plugin to a site)",
523
+ help: `Usage: easy-cms tenants:assign <tenant> [options]
524
+
525
+ Sets the tenant (by slug or id) of every document that has none, in the collections of
526
+ multiTenantPlugin, and copies the shared value of each per-tenant global to it when it has none
527
+ of its own. Run it once after adding the plugin to a site that has content.
528
+ `,
529
+ run: async ({ cms, args, log }) => {
530
+ const list = await tenantList(cms, tenantsSlug);
531
+ const tenant = findTenant(list, args[0]);
532
+ if (!tenant) {
533
+ log(args[0] ? `No tenant "${args[0]}"` : "Name a tenant: easy-cms tenants:assign <slug>");
534
+ return 1;
535
+ }
536
+ for (const slug of scoped) {
537
+ if (!cms.config.collections.some((c) => c.slug === slug)) continue;
538
+ let updated = 0;
539
+ for (; ; ) {
540
+ const { docs } = await cms.find(slug, {
541
+ where: { [TENANT_FIELD]: { exists: false } },
542
+ limit: 100,
543
+ depth: 0,
544
+ draft: true
545
+ });
546
+ if (docs.length === 0) break;
547
+ for (const doc of docs) {
548
+ await cms.update(slug, doc.id, { [TENANT_FIELD]: tenant.id }, {
549
+ depth: 0,
550
+ live: true
551
+ });
552
+ updated++;
553
+ }
554
+ }
555
+ log(`${slug}: ${updated} given to ${tenant.name}`);
556
+ }
557
+ const context = { tenant: tenant.id, allTenants: false };
558
+ for (const slug of perTenant) {
559
+ const own = await cms.findGlobal(slug, { context, depth: 0 });
560
+ if (own.updatedAt) continue;
561
+ const shared = await cms.db.findGlobal({ slug });
562
+ if (!shared) continue;
563
+ const { updatedAt: _u, ...data } = shared;
564
+ await cms.updateGlobal(slug, data, { context, depth: 0 });
565
+ log(`${slug}: copied to ${tenant.name}`);
566
+ }
567
+ return 0;
568
+ }
569
+ };
570
+ return {
571
+ ...config,
572
+ collections,
573
+ globals,
574
+ onRequest,
575
+ endpoints: [
576
+ ...config.endpoints ?? [],
577
+ optionsEndpoint,
578
+ ...membersEndpoints({ tenantsSlug, superUser })
579
+ ],
580
+ commands: [...config.commands ?? [], assign],
581
+ admin: {
582
+ ...config.admin,
583
+ modules: [
584
+ .../* @__PURE__ */ new Set([...config.admin?.modules ?? [], "@easy-cms/plugin-multi-tenant/admin"])
585
+ ],
586
+ switcher: { cookie, label: { en: "Tenant", th: "Tenant" }, options: "/tenant-options" },
587
+ pages: [
588
+ ...config.admin?.pages ?? [],
589
+ {
590
+ path: "tenant-members",
591
+ label: { en: "Members", th: "\u0E2A\u0E21\u0E32\u0E0A\u0E34\u0E01" },
592
+ icon: "users",
593
+ group: "settings",
594
+ // Admins of a tenant (and users with access to all, once they choose one).
595
+ access: ({ user }) => user.role === "admin",
596
+ component: { tag: "ecms-tenant-members" }
597
+ }
598
+ ]
599
+ }
600
+ };
601
+ }, INFO);
602
+ }
603
+ async function removeTenant(cms, id, collections) {
604
+ const order = [...collections].sort(
605
+ (a, b) => a === MEDIA_FOLDERS ? 1 : b === MEDIA_FOLDERS ? -1 : 0
606
+ );
607
+ for (const slug of order) {
608
+ if (!cms.config.collections.some((c) => c.slug === slug)) continue;
609
+ for (; ; ) {
610
+ const { docs } = await cms.find(slug, {
611
+ where: { [TENANT_FIELD]: { equals: id } },
612
+ limit: 100,
613
+ depth: 0,
614
+ draft: true
615
+ });
616
+ if (docs.length === 0) break;
617
+ for (const doc of docs) await cms.delete(slug, doc.id).catch(() => void 0);
618
+ }
619
+ }
620
+ const { docs: members } = await cms.find("users", {
621
+ where: { [`${MEMBERSHIPS_FIELD}.tenant`]: { equals: id } },
622
+ limit: 0,
623
+ depth: 0
624
+ });
625
+ for (const member of members) {
626
+ const rows = member[MEMBERSHIPS_FIELD].filter(
627
+ (row) => !sameId(row.tenant, id)
628
+ );
629
+ await cms.update("users", member.id, { [MEMBERSHIPS_FIELD]: rows }, { depth: 0 });
630
+ }
631
+ }
632
+
633
+ // src/index.ts
634
+ async function findTenantFor(cms, by, tenantsSlug = "tenants") {
635
+ const list = await tenantList(cms, tenantsSlug);
636
+ return (by.slug ? findTenant(list, by.slug) : void 0) ?? tenantOfHost(list, by.host) ?? null;
637
+ }
638
+ async function tenantContext(cms, by, tenantsSlug = "tenants") {
639
+ const tenant = await findTenantFor(cms, by, tenantsSlug);
640
+ return { tenant: tenant?.id ?? null, allTenants: false };
641
+ }
642
+ export {
643
+ ALL,
644
+ findTenantFor,
645
+ multiTenantPlugin,
646
+ tenantContext,
647
+ tenantOf
648
+ };
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@easy-cms/plugin-multi-tenant",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.43.0",
4
+ "description": "Multi-tenant Easy CMS: several sites or clients in one CMS, each with its own content, members and roles",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./admin": "./dist/admin.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "peerDependencies": {
20
+ "@easy-cms/core": "^0.43.0"
21
+ },
22
+ "devDependencies": {
23
+ "@easy-cms/core": "^0.43.0",
24
+ "@easy-cms/db-sqlite": "^0.43.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=22.12"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/maritonx/easy-cms.git",
35
+ "directory": "packages/plugin-multi-tenant"
36
+ },
37
+ "keywords": [
38
+ "easy-cms",
39
+ "easy-cms-plugin",
40
+ "multi-tenant",
41
+ "multi-site",
42
+ "tenants"
43
+ ],
44
+ "scripts": {
45
+ "build": "tsup",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run"
48
+ }
6
49
  }