Say tenants are Enterprise only where the installation asks, as the demo will

On a server that is not Enterprise the Tenants page is still only the
notice. On Enterprise the notice is gone -- a real installation that has
tenants has the licence -- unless SHOW_ENTERPRISE_NOTICES=1 asks for it above
the list. The public demo will set it: it reports Enterprise so tenants can be
shown, and should not suggest they come without the licence. The setting
reaches the browser as session.ihasmail.server.enterpriseNotices.
This commit is contained in:
2026-09-15 09:43:04 -07:00
parent 40df0f658b
commit 5f70e5e8d1
6 changed files with 69 additions and 17 deletions
+6 -4
View File
@@ -1265,10 +1265,11 @@ Stalwart shows the server's English until it is translated.
A tenant is a separate organisation on the same server — its own people, A tenant is a separate organisation on the same server — its own people,
domains and limits, and an administrator who manages only what is in it. It is domains and limits, and an administrator who manages only what is in it. It is
a Stalwart Enterprise feature. On a server that does not report Enterprise — or a Stalwart Enterprise feature. On a server that does not report Enterprise — or
reports no edition at all — the page is only a notice that anyone inside a reports no edition at all — the page is only the notice *Tenants are a Stalwart
tenant has only an ordinary user's permissions: no list, no search, nothing to Enterprise feature.*: no list, no search, nothing to create. On Enterprise the
create. On Enterprise, for a role with `sysTenantQuery` and `sysTenantGet`, notice is left out, unless `SHOW_ENTERPRISE_NOTICES=1` asks for it above the
under Access: list, as the public demo does. On Enterprise, for a role with `sysTenantQuery`
and `sysTenantGet`, under Access:
- **List and search** tenants, with each one's storage and account limit. - **List and search** tenants, with each one's storage and account limit.
- **Create and edit** a tenant's name, logo (an https address, drawn through the - **Create and edit** a tenant's name, logo (an https address, drawn through the
@@ -1713,6 +1714,7 @@ wizard, because either would be state.
| Variable | Default | Does | | Variable | Default | Does |
| --- | --- | --- | | --- | --- | --- |
| `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` | | `STALWART_URL` | — | Where Stalwart is; the JMAP session is discovered at `/.well-known/jmap` |
| `SHOW_ENTERPRISE_NOTICES` | `0` | Say an Enterprise-only section (Tenants) is Enterprise-only even when the server is Enterprise. For a demo that reports Enterprise to show those sections; a real installation leaves it off |
| `STALWART_ADMIN_URL` | — | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Separate from `STALWART_URL`, which is often an address only this server can reach; unset, the dashboard names Stalwart's administration without a link | | `STALWART_ADMIN_URL` | — | Where a browser opens Stalwart's own administration, linked from the Administration dashboard. Separate from `STALWART_URL`, which is often an address only this server can reach; unset, the dashboard names Stalwart's administration without a link |
| `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it | | `APP_SECRET` | — | Key material for sealing sessions. **Required in production** — the server refuses to start without it |
| `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address | | `HOST` / `PORT` | `0.0.0.0` / `8080` | Listen address |
+6 -1
View File
@@ -896,7 +896,12 @@ function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null,
* session that may administer -- where the operator says its own * session that may administer -- where the operator says its own
* administration is. * administration is.
*/ */
server: { edition: info.edition, adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username) : null }, server: {
edition: info.edition,
adminUrl: administrationAllowed(config.administration, session.remember) ? adminUrlFor(session.username) : null,
/** SHOW_ENTERPRISE_NOTICES: say "Enterprise feature" on Enterprise too, as the demo does. */
enterpriseNotices: config.showEnterpriseNotices,
},
/** /**
* Whether this session may administer: the installation offers it * Whether this session may administer: the installation offers it
* (ADMINISTRATION) and the person signed in on a device marked as their own. * (ADMINISTRATION) and the person signed in on a device marked as their own.
+7
View File
@@ -307,6 +307,13 @@ export const config = {
*/ */
stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "", stalwartAdminUrl: process.env.STALWART_ADMIN_URL ? httpUrl(process.env.STALWART_ADMIN_URL, "STALWART_ADMIN_URL") : "",
stalwartAdminUrls: stalwartServers.adminUrls, stalwartAdminUrls: stalwartServers.adminUrls,
/**
* Say that an Enterprise-only section is Enterprise-only even on an
* Enterprise server. Off, as a real installation wants it; the public demo
* turns it on, because it reports Enterprise to show those sections and
* should not suggest they come without the licence.
*/
showEnterpriseNotices: bool("SHOW_ENTERPRISE_NOTICES", false),
appSecret, appSecret,
trustProxy: bool("TRUST_PROXY", true), trustProxy: bool("TRUST_PROXY", true),
/** /**
+2
View File
@@ -40,6 +40,8 @@ export interface JmapSession {
edition?: string | null; edition?: string | null;
/** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */ /** Where Stalwart's own administration is (STALWART_ADMIN_URL), for a session that may administer. */
adminUrl?: string | null; adminUrl?: string | null;
/** SHOW_ENTERPRISE_NOTICES: an Enterprise-only section says so even on Enterprise. */
enterpriseNotices?: boolean;
}; };
/** /**
* False when this session may not administer: the operator turned it off, * False when this session may not administer: the operator turned it off,
+18 -8
View File
@@ -18,14 +18,17 @@ const PAGE_SIZE = 50;
* Tenants: separate organisations on one server, each with its own people, * Tenants: separate organisations on one server, each with its own people,
* domains and limits. * domains and limits.
* *
* The section is offered to whoever may read tenants, but on a server that does * The section is offered to whoever may read tenants. On a server that does not
* not report Enterprise the page is only the notice: tenants there hold nobody * report Enterprise -- or reports no edition -- the page is only a notice that
* to anything beyond an ordinary user's permissions, so there is nothing worth * tenants are an Enterprise feature: tenants there hold nobody to anything
* creating or listing. A server that reports no edition at all counts as not * beyond an ordinary user's permissions, so there is nothing worth creating or
* Enterprise. * listing. On Enterprise the notice is left out, unless the installation asks
* for it (SHOW_ENTERPRISE_NOTICES), as the public demo does so as not to
* suggest tenants come without the licence.
*/ */
export function TenantsAdmin({ selectedId }: { selectedId?: string }) { export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null); const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
const notices = useSession((s) => s.session?.ihasmail?.server?.enterpriseNotices === true);
if (edition !== "enterprise") { if (edition !== "enterprise") {
return ( return (
<div> <div>
@@ -35,14 +38,19 @@ export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
<p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p> <p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p>
</div> </div>
</div> </div>
<p className="admin-notice warn">{t("Tenants are a Stalwart Enterprise feature.")}</p> <EnterpriseNotice warn />
</div> </div>
); );
} }
return <EnterpriseTenants selectedId={selectedId} />; return <EnterpriseTenants selectedId={selectedId} notice={notices} />;
} }
function EnterpriseTenants({ selectedId }: { selectedId?: string }) { /** Said on every Tenants page, Enterprise or not. */
function EnterpriseNotice({ warn }: { warn: boolean }) {
return <p className={`admin-notice${warn ? " warn" : ""}`}>{t("Tenants are a Stalwart Enterprise feature.")}</p>;
}
function EnterpriseTenants({ selectedId, notice }: { selectedId?: string; notice: boolean }) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const perms = usePermissions(); const perms = usePermissions();
const [text, setText] = useState(""); const [text, setText] = useState("");
@@ -119,6 +127,8 @@ function EnterpriseTenants({ selectedId }: { selectedId?: string }) {
)} )}
</div> </div>
{notice && <EnterpriseNotice warn={false} />}
<div className="admin-toolbar"> <div className="admin-toolbar">
<label className="admin-search"> <label className="admin-search">
<Search size={16} aria-hidden="true" /> <Search size={16} aria-hidden="true" />
@@ -18,8 +18,8 @@ vi.mock("@/lib/adminTenants", async (original) => ({
const { TenantsAdmin } = await import("../TenantsAdmin"); const { TenantsAdmin } = await import("../TenantsAdmin");
const PERMS = ["sysTenantGet", "sysTenantQuery", "sysTenantCreate"]; const PERMS = ["sysTenantGet", "sysTenantQuery", "sysTenantCreate"];
const signIn = (edition: string | null) => const signIn = (edition: string | null, enterpriseNotices = false) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: PERMS, server: { edition } } } as unknown as JmapSession }); useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: PERMS, server: { edition, enterpriseNotices } } } as unknown as JmapSession });
/** Tenants are managed on Enterprise only; anywhere else the page is the notice and nothing more. */ /** Tenants are managed on Enterprise only; anywhere else the page is the notice and nothing more. */
describe("the Tenants page", () => { describe("the Tenants page", () => {
@@ -55,11 +55,37 @@ describe("the Tenants page", () => {
}); });
} }
it("lists and offers tenants on Enterprise, without the notice", async () => { it("lists and offers tenants on Enterprise, and does not say they are Enterprise", async () => {
signIn("enterprise"); signIn("enterprise");
await render(); await render();
expect(host.querySelector(".admin-notice.warn")).toBeNull(); expect(host.querySelector(".admin-notice")).toBeNull();
expect(host.textContent).toContain("New tenant"); expect(host.textContent).toContain("New tenant");
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp"); expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
}); });
}); });
describe("the Tenants page where the installation asks for Enterprise notices", () => {
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
it("says tenants are Enterprise above the list, as the demo does", async () => {
signIn("enterprise", true);
const { hook } = memoryLocation({ path: "/admin/tenants" });
await act(async () => {
root.render(<Router hook={hook}><TenantsAdmin /></Router>);
});
await act(async () => {});
expect(host.querySelector(".admin-notice")?.textContent).toBe("Tenants are a Stalwart Enterprise feature.");
expect(host.querySelector(".admin-notice.warn")).toBeNull();
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
});
});