Show only the Enterprise notice on Tenants when the server is not Enterprise

On a server that does not report Enterprise -- or reports no edition --
tenants hold nobody to anything beyond an ordinary user's permissions, so
the page is the notice alone: no New tenant, no search, no list, and no
tenant query is made. The mock's edition is MOCK_EDITION now (default oss,
as before), so MOCK_EDITION=enterprise brings the section back to work on.
This commit is contained in:
2026-09-15 09:31:10 -07:00
parent fd104a1f34
commit ce5eb04c2d
5 changed files with 101 additions and 15 deletions
+7 -4
View File
@@ -1264,9 +1264,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 the a Stalwart Enterprise feature. On a server that does not report Enterprise — or
page says that anyone inside a tenant has only an ordinary user's permissions. reports no edition at all — the page is only a notice that anyone inside a
For a role with `sysTenantQuery` and `sysTenantGet`, under Access: tenant has only an ordinary user's permissions: no list, no search, nothing to
create. 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
@@ -1815,7 +1817,8 @@ demo user is: `admin` (the default), `tenant-admin` (the queue but not the
history), `helpdesk` — a custom role that may view and edit accounts but not history), `helpdesk` — a custom role that may view and edit accounts but not
create or delete them, and read domains — or `user`, who is not offered the create or delete them, and read domains — or `user`, who is not offered the
menu at all. `MOCK_METRICS=off` refuses the history the way a Community server menu at all. `MOCK_METRICS=off` refuses the history the way a Community server
does. Two mailing lists round it out. does, and `MOCK_EDITION=enterprise` reports Enterprise so Tenants can be
worked on (the default, `oss`, shows only its notice). Two mailing lists round it out.
--- ---
+3 -2
View File
@@ -403,13 +403,14 @@ without a real mailbox. It reproduces the things a naive fake would get wrong,
because each cost a live debugging session: `urn:stalwart:jmap` advertised because each cost a live debugging session: `urn:stalwart:jmap` advertised
**per-account** rather than session-level, identity signatures capped at 2047 **per-account** rather than session-level, identity signatures capped at 2047
**bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than **bytes**, and `CalendarEvent/set` speaking Stalwart's vocabulary rather than
RFC 8984's. Five switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE RFC 8984's. Six switches: `MOCK_NO_FUTURE_RELEASE=1` advertises FUTURERELEASE
and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so and then drops every hold; `MOCK_NO_REGISTRY=1` omits the Stalwart capability so
the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a the sign-in refusal can be tested; and `MOCK_NO_SCHEDULING_SEND=1` refuses a
calendar write that asks for scheduling messages, the way an account without calendar write that asks for scheduling messages, the way an account without
that permission is refused; `MOCK_ROLE` decides who the demo user is for that permission is refused; `MOCK_ROLE` decides who the demo user is for
Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`; Administration — `admin` (the default), `tenant-admin`, `helpdesk` or `user`;
and `MOCK_METRICS=off` refuses the dashboard's metric history, as Community does. `MOCK_METRICS=off` refuses the dashboard's metric history, as Community does;
and `MOCK_EDITION=enterprise` reports Enterprise, which Tenants needs.
It tracks the current release rather than 0.16 in general, and each behaviour It tracks the current release rather than 0.16 in general, and each behaviour
is confirmed against a real server before it is copied here — the comments say is confirmed against a real server before it is copied here — the comments say
+3 -1
View File
@@ -45,6 +45,8 @@ const SHARED_CAPS: Obj = {
const USER = process.env.MOCK_USER ?? "[email protected]"; const USER = process.env.MOCK_USER ?? "[email protected]";
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */ /** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US"; const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
/** What /api/account reports. Tenants are managed only on "enterprise"; MOCK_EDITION=enterprise to develop them. */
const MOCK_EDITION = process.env.MOCK_EDITION ?? "oss";
const PASS = process.env.MOCK_PASS ?? "demo"; const PASS = process.env.MOCK_PASS ?? "demo";
/** /**
* Credential state, mutable so the self-service flows can be exercised against * Credential state, mutable so the self-service flows can be exercised against
@@ -1430,7 +1432,7 @@ export const server = createServer(async (req, res) => {
// The account info endpoint; the only place a server reports its edition. // The account info endpoint; the only place a server reports its edition.
if (url.pathname === "/api/account" && req.method === "GET") { if (url.pathname === "/api/account" && req.method === "GET") {
res.writeHead(200, { "content-type": "application/json" }); res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ permissions: directory.permissions, edition: "oss", locale: MOCK_LOCALE })); return res.end(JSON.stringify({ permissions: directory.permissions, edition: MOCK_EDITION, locale: MOCK_LOCALE }));
} }
// The registry schema, cut down to the permission list the Roles picker // The registry schema, cut down to the permission list the Roles picker
// reads. Gzipped as the real file is, from the 0.16.22 snapshot the // reads. Gzipped as the real file is, from the 0.16.22 snapshot the
+23 -8
View File
@@ -18,14 +18,33 @@ 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.
* *
* Shown to whoever may read them, whatever the edition says -- the edition is a * The section is offered to whoever may read tenants, but on a server that does
* licence claim, not an authority -- but on a server that does not report * not report Enterprise the page is only the notice: tenants there hold nobody
* Enterprise the page says what that means for the people inside one. * to anything beyond an ordinary user's permissions, so there is nothing worth
* creating or listing. A server that reports no edition at all counts as not
* Enterprise.
*/ */
export function TenantsAdmin({ selectedId }: { selectedId?: string }) { export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
if (edition !== "enterprise") {
return (
<div>
<div className="admin-head">
<div className="grow">
<h1>{t("Tenants")}</h1>
<p className="lead">{t("Separate organisations on one server, each with its own people, domains and limits.")}</p>
</div>
</div>
<p className="admin-notice warn">{t("Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.")}</p>
</div>
);
}
return <EnterpriseTenants selectedId={selectedId} />;
}
function EnterpriseTenants({ selectedId }: { selectedId?: string }) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const perms = usePermissions(); const perms = usePermissions();
const edition = useSession((s) => s.session?.ihasmail?.server?.edition ?? null);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [position, setPosition] = useState(0); const [position, setPosition] = useState(0);
@@ -100,10 +119,6 @@ export function TenantsAdmin({ selectedId }: { selectedId?: string }) {
)} )}
</div> </div>
{edition !== "enterprise" && (
<p className="admin-notice warn">{t("Tenants are a Stalwart Enterprise feature. This server does not report Enterprise, so anyone inside a tenant has only an ordinary user's permissions.")}</p>
)}
<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" />
@@ -0,0 +1,65 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Router } from "wouter";
import { memoryLocation } from "wouter/memory-location";
import { useSession } from "@/store/session";
import type { JmapSession } from "@/jmap/types";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const api = vi.hoisted(() => ({ queryTenants: vi.fn(async () => ({ ids: ["t1"], total: 1 })) }));
vi.mock("@/lib/adminTenants", async (original) => ({
...(await original<typeof import("@/lib/adminTenants")>()),
queryTenants: api.queryTenants,
getTenants: vi.fn(async () => [{ id: "t1", name: "Acme Corp", quotas: {}, usedDiskQuota: 0 }]),
}));
const { TenantsAdmin } = await import("../TenantsAdmin");
const PERMS = ["sysTenantGet", "sysTenantQuery", "sysTenantCreate"];
const signIn = (edition: string | null) =>
useSession.setState({ session: { capabilities: {}, accounts: {}, primaryAccounts: {}, username: "[email protected]", ihasmail: { permissions: PERMS, server: { edition } } } as unknown as JmapSession });
/** Tenants are managed on Enterprise only; anywhere else the page is the notice and nothing more. */
describe("the Tenants page", () => {
let host: HTMLDivElement;
let root: Root;
const render = async () => {
const { hook } = memoryLocation({ path: "/admin/tenants" });
await act(async () => {
root.render(<Router hook={hook}><TenantsAdmin /></Router>);
});
await act(async () => {});
};
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
api.queryTenants.mockClear();
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
for (const edition of ["community", "oss", null]) {
it(`shows only the notice on ${edition ?? "a server that reports no edition"}`, async () => {
signIn(edition);
await render();
expect(host.querySelector(".admin-notice.warn")?.textContent).toContain("Tenants are a Stalwart Enterprise feature");
expect(host.textContent).not.toContain("New tenant");
expect(host.querySelector('input[type="search"]')).toBeNull();
expect(host.querySelector(".admin-table")).toBeNull();
expect(api.queryTenants).not.toHaveBeenCalled();
});
}
it("lists and offers tenants on Enterprise, without the notice", async () => {
signIn("enterprise");
await render();
expect(host.querySelector(".admin-notice.warn")).toBeNull();
expect(host.textContent).toContain("New tenant");
expect(host.querySelector(".admin-table")?.textContent).toContain("Acme Corp");
});
});