Each tenant's page carries its legacy protocols switch (LP-9 to LP-18)

A card above the tenant form: whether legacy mail protocols are on or off
for the organization, a one-click "Turn legacy protocols back on", and
"Turn off legacy protocols…", which opens -- before anything can change --
the impact panel with the tenant's own people (LP-15), the statement at
tenant scope and the typed confirmation (LP-16, LP-17). The statement reads
"everyone in {organization}" and names no ports and no firewall: a
tenant's switch closes nothing, other tenants share the ports (LP-13).

It reads and turns inbuxa:TenantProtocolPolicy, with the domain
permissions the server checks for it. It shows on the read-only tenant page
too: a tenant administrator reads its tenant without changing it (MT-12)
and may still turn the switch. Turning it back on while the server has
legacy protocols off is refused by the server, and the refusal is shown.

The banner (LP-18) falls back to the tenant's switch where the server's
can't be read -- inside a tenant -- and reads "off for your
organization".

The impact panel, statement and confirmation move to parts.tsx, shared by
Hardening and the tenant card; the statement takes its scope.

Checked by hand against a local server with a tenant, two of its users and
the admin signed in over IMAP: the tenant card's panel lists the tenant's
two users and not the admin; the statement reads "everyone in Example Co"
with no ports or firewall; turning it off makes the tenant's user get "NO
[ALERT] Your organization allows only INBUXA webmail and JMAP apps" over
IMAP; one click turns it back on; Hardening's panel lists all three. Not
checked by hand: the banner as a tenant administrator sees it.
This commit is contained in:
2026-09-21 13:39:56 -07:00
parent 2aff8c8a10
commit 11d85cd759
7 changed files with 484 additions and 172 deletions
+62
View File
@@ -256,3 +256,65 @@ export function phraseMatches(typed: string): boolean {
export function describeListener(l: PolicyListener): string {
return l.ports.length > 0 ? `${l.name} (${l.ports.join(', ')})` : l.name;
}
// ---- A tenant's switch: inbuxa:TenantProtocolPolicy (LP-9 to LP-14) ----
const TENANT_OBJECT = 'inbuxa:TenantProtocolPolicy';
export interface TenantPolicy {
/** The tenant's id, which is also the policy's. */
id: string;
legacyProtocols: 'enabled' | 'disabled';
/** Milliseconds since the epoch. */
changedAt: number | null;
/** The tenant's own people who used a legacy mail app lately (LP-15), or null from an older server. */
recentLegacyUse: RecentUse[] | null;
}
export function parseTenantPolicy(raw: Record<string, unknown>): TenantPolicy {
return {
id: typeof raw.id === 'string' ? raw.id : '',
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null,
};
}
/**
* A tenant's switch. With no id, the caller's own tenant's -- which is how a
* tenant administrator reads it; a server administrator names the tenant.
*/
export async function fetchTenantPolicy(tenantId: string | null, signal?: AbortSignal): Promise<TenantPolicy> {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest(
[[`${TENANT_OBJECT}/get`, { accountId, ids: tenantId ? [tenantId] : null }, '0']],
signal,
[INBUXA_CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${TENANT_OBJECT}/get`) {
const type = (result as { type?: string } | undefined)?.type;
if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type);
throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed');
}
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
// A tenant admin's /get with no ids holds exactly its own tenant's.
if (!list[0] || (!tenantId && list.length !== 1)) throw new PolicyUnavailable('notFound');
return parseTenantPolicy(list[0]);
}
/** Turns a tenant's switch. The server refuses turning it on while its own is off (LP-9). */
export async function updateTenantPolicy(tenantId: string, legacyProtocols: 'enabled' | 'disabled'): Promise<void> {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest(
[[`${TENANT_OBJECT}/set`, { accountId, update: { [tenantId]: { legacyProtocols } } }, '0']],
undefined,
[INBUXA_CAPABILITY],
);
const [name, result] = responses[0] ?? [];
if (name !== `${TENANT_OBJECT}/set`) {
throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed');
}
const failed = (result as { notUpdated?: Record<string, JmapSetError> | null }).notUpdated?.[tenantId];
if (failed) throw new Error(failed.description ?? failed.type);
}