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:
+7
-4
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user