Add Administration, starting with accounts
An account whose Stalwart role manages accounts now finds Administration in the account menu. It lists, searches, creates and edits accounts -- display name, other addresses, role, storage limit -- sets a new password, and deletes, each offered only when the role holds the matching permission. The server keeps the permissions list from GET /api/account, which it already called for the edition and threw the rest away. Everything else is JMAP x:Account, x:Domain and x:Role calls through the existing /api/jmap proxy, so nothing new is stored and Stalwart decides every call. Stalwart checks a grant against the caller's permissions but not a password change or a delete, so an account that outranks the viewer is shown read-only. Your own password is changed in Settings, which re-seals the session; changing it here would strand it. The mock server gains a directory behind the same permission names, with MOCK_ROLE choosing admin, tenant-admin, helpdesk or user. 68 new strings, translated in all nine catalogues; strings falling back to English stay at 16.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ADMIN_BASELINE, can, canGrantRole, generatePassword, hasAdministration, outranks, permissionSet, resolveRoles, type RoleDef } from "@/lib/adminAccess";
|
||||
|
||||
const set = (...p: string[]) => permissionSet(p);
|
||||
const everything = set(...ADMIN_BASELINE, "sysTenantGet", "jmapEmailGet", "impersonate");
|
||||
const helpdesk = set("sysAccountGet", "sysAccountQuery", "sysAccountUpdate", "jmapEmailGet");
|
||||
const roles = new Map<string, RoleDef>([
|
||||
["user", { id: "user", enabledPermissions: { jmapEmailGet: true } }],
|
||||
["helpdesk", { id: "helpdesk", enabledPermissions: { sysAccountGet: true, sysAccountQuery: true, sysAccountUpdate: true }, roleIds: { user: true } }],
|
||||
["dns", { id: "dns", enabledPermissions: { sysDnsServerUpdate: true }, roleIds: { user: true } }],
|
||||
["loop", { id: "loop", enabledPermissions: {}, roleIds: { loop: true } }],
|
||||
]);
|
||||
|
||||
describe("who is offered administration", () => {
|
||||
it("needs both halves of reading the account list", () => {
|
||||
expect(hasAdministration(set("sysAccountQuery", "sysAccountGet"))).toBe(true);
|
||||
expect(hasAdministration(set("sysAccountQuery"))).toBe(false);
|
||||
expect(hasAdministration(set("sysAccountGet"))).toBe(false);
|
||||
expect(hasAdministration(permissionSet(undefined))).toBe(false);
|
||||
});
|
||||
|
||||
it("reads one permission per object and operation", () => {
|
||||
expect(can(helpdesk, "Account", "Update")).toBe(true);
|
||||
expect(can(helpdesk, "Account", "Destroy")).toBe(false);
|
||||
expect(can(helpdesk, "Domain", "Get")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stalwart checks a grant, but not a password change or a delete. Without this,
|
||||
* anyone allowed to edit accounts could take over one that can do more.
|
||||
*/
|
||||
describe("an account that outranks the viewer", () => {
|
||||
it("an ordinary user never does", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "User" } }, null)).toBe(false);
|
||||
expect(outranks(helpdesk, {}, null)).toBe(false);
|
||||
});
|
||||
|
||||
it("an administrator does, unless the viewer is one too", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Admin" } }, roles)).toBe(true);
|
||||
expect(outranks(everything, { roles: { "@type": "Admin" } }, roles)).toBe(false);
|
||||
});
|
||||
|
||||
it("a custom role does when it carries something the viewer lacks", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, roles)).toBe(false);
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } } }, roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("a role that cannot be read counts against the target, not for it", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { helpdesk: true } } }, null)).toBe(true);
|
||||
expect(outranks(everything, { roles: { "@type": "Custom", roleIds: { gone: true } } }, roles)).toBe(true);
|
||||
});
|
||||
|
||||
it("extra permissions on the account itself are counted", () => {
|
||||
expect(outranks(helpdesk, { roles: { "@type": "User" }, permissions: { "@type": "Merge", enabledPermissions: { sysDomainDestroy: true } } }, roles)).toBe(true);
|
||||
// Replace ignores the roles entirely, so only what it lists matters.
|
||||
expect(outranks(helpdesk, { roles: { "@type": "Custom", roleIds: { dns: true } }, permissions: { "@type": "Replace", enabledPermissions: { jmapEmailGet: true } } }, roles)).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a role that names itself", () => {
|
||||
expect(resolveRoles(["loop"], roles)).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("granting a role", () => {
|
||||
it("is offered only for roles whose every permission the viewer holds", () => {
|
||||
expect(canGrantRole(helpdesk, "helpdesk", roles)).toBe(true);
|
||||
expect(canGrantRole(helpdesk, "dns", roles)).toBe(false);
|
||||
expect(canGrantRole(everything, "missing", roles)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generated passwords", () => {
|
||||
it("are four groups of five unambiguous characters", () => {
|
||||
const p = generatePassword();
|
||||
expect(p).toMatch(/^[a-zA-Z2-9]{5}(-[a-zA-Z2-9]{5}){3}$/);
|
||||
expect(p).not.toMatch(/[01lIO]/);
|
||||
});
|
||||
|
||||
it("skip bytes that would favour the start of the alphabet", () => {
|
||||
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
|
||||
// the first 36 characters twice as often. Bytes of 220 and up are dropped
|
||||
// and more are drawn, so a batch of nothing but those costs a draw.
|
||||
let call = 0;
|
||||
const source = (n: number) => (call++ === 0 ? new Uint8Array(n).fill(250) : Uint8Array.from({ length: n }, (_, i) => i));
|
||||
expect(generatePassword(source)).toBe("abcde-fghjk-mnpqr-stuvw");
|
||||
expect(call).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { aliasList, describeDirectoryError, DirectoryError, hasPassword, passwordPatch, quotasWithDisk } from "@/lib/adminDirectory";
|
||||
|
||||
describe("setting a password", () => {
|
||||
it("writes into the existing password credential, keeping its place", () => {
|
||||
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "2": { "@type": "Password" as const, secret: "[********]" } } };
|
||||
expect(passwordPatch(account, "new secret")).toEqual({ "credentials/2/secret": "new secret" });
|
||||
});
|
||||
|
||||
it("adds one after the last index when the account has none", () => {
|
||||
const account = { credentials: { "0": { "@type": "AppPassword" as const }, "3": { "@type": "ApiKey" as const } } };
|
||||
expect(passwordPatch(account, "s")).toEqual({ "credentials/4": { "@type": "Password", secret: "s" } });
|
||||
expect(passwordPatch({}, "s")).toEqual({ "credentials/0": { "@type": "Password", secret: "s" } });
|
||||
expect(hasPassword(account)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lists written back", () => {
|
||||
it("re-index aliases the way the server stores a list", () => {
|
||||
expect(aliasList([{ name: "b", domainId: "d1" }, { name: "c", domainId: "d2", enabled: false }])).toEqual({
|
||||
"0": { enabled: true, name: "b", domainId: "d1", description: null },
|
||||
"1": { enabled: false, name: "c", domainId: "d2", description: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("change the disk limit without touching the other quotas", () => {
|
||||
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, 7)).toEqual({ maxEmails: 10, maxDiskQuota: 7 });
|
||||
expect(quotasWithDisk({ maxEmails: 10, maxDiskQuota: 5 }, null)).toEqual({ maxEmails: 10 });
|
||||
expect(quotasWithDisk(undefined, 0)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("explaining a refusal", () => {
|
||||
it("says what a taken address means", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("primaryKeyViolation", "exists"))).toMatch(/already in use/);
|
||||
});
|
||||
|
||||
it("keeps the server's own words for a password policy", () => {
|
||||
expect(describeDirectoryError(new DirectoryError("invalidProperties", "Password must be at least 8 characters long.", ["secret"]))).toContain("at least 8 characters");
|
||||
});
|
||||
|
||||
it("handles a method-level refusal as well as a set error", () => {
|
||||
expect(describeDirectoryError({ type: "forbidden", message: "x:Account/set: forbidden" })).toMatch(/refused/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* What the signed-in account may administer, read from the permissions Stalwart
|
||||
* reported for it at sign-in.
|
||||
*
|
||||
* None of this is a security boundary, and nothing here should read as one.
|
||||
* Every administrative call is a JMAP `x:` method sent through the ordinary
|
||||
* proxy, and Stalwart checks each of them against the credential making it --
|
||||
* scoping a tenant administrator's queries to their own tenant, and refusing a
|
||||
* write the account may not make. What this decides is only what the client
|
||||
* *offers*: a menu that appears for the people it can do something for, and
|
||||
* buttons that are there when pressing them would work.
|
||||
*
|
||||
* The one place it is more than presentation is `outranks`, which stands in
|
||||
* for a check Stalwart does not make. See there.
|
||||
*/
|
||||
|
||||
export type AdminObject = "Account" | "Domain" | "Role" | "MailingList" | "DkimSignature" | "DnsServer" | "Tenant";
|
||||
export type AdminOp = "Get" | "Query" | "Create" | "Update" | "Destroy";
|
||||
|
||||
export type Permissions = ReadonlySet<string>;
|
||||
|
||||
export function permissionSet(list: readonly string[] | null | undefined): Permissions {
|
||||
return new Set(list ?? []);
|
||||
}
|
||||
|
||||
export function can(perms: Permissions, object: AdminObject, op: AdminOp): boolean {
|
||||
return perms.has(`sys${object}${op}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to offer Administration at all.
|
||||
*
|
||||
* Accounts are the only section so far, and a list that cannot be opened is
|
||||
* not worth a menu entry, so it takes both halves of reading one.
|
||||
*/
|
||||
export function hasAdministration(perms: Permissions): boolean {
|
||||
return can(perms, "Account", "Query") && can(perms, "Account", "Get");
|
||||
}
|
||||
|
||||
/**
|
||||
* What an administrator holds, at the least: Stalwart's built-in Tenant
|
||||
* Administrator role, for the parts of it that manage people and domains.
|
||||
* Anyone who has all of this can already do anything to the accounts an
|
||||
* "Administrator" account could.
|
||||
*/
|
||||
export const ADMIN_BASELINE: readonly string[] = (["Account", "Domain", "Role", "MailingList"] as const).flatMap((o) =>
|
||||
(["Get", "Query", "Create", "Update", "Destroy"] as const).map((op) => `sys${o}${op}`),
|
||||
);
|
||||
|
||||
export type UserRoles = { "@type": "User" } | { "@type": "Admin" } | { "@type": "Custom"; roleIds: Record<string, boolean> };
|
||||
|
||||
export type PermissionsMode =
|
||||
| { "@type": "Inherit" }
|
||||
| { "@type": "Merge" | "Replace"; enabledPermissions?: Record<string, boolean>; disabledPermissions?: Record<string, boolean> };
|
||||
|
||||
export interface RoleDef {
|
||||
id: string;
|
||||
description?: string | null;
|
||||
enabledPermissions?: Record<string, boolean>;
|
||||
roleIds?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account can do something the viewer cannot.
|
||||
*
|
||||
* Stalwart checks that a caller holds every permission they grant -- when
|
||||
* roles or permissions change, and when an account is created. It does not
|
||||
* check when only a password changes, and it does not check a delete. So an
|
||||
* account allowed to edit accounts could reset the password of one with far
|
||||
* more rights than its own and sign in as it. ihasmail refuses to offer that,
|
||||
* and treats such an account as read-only.
|
||||
*
|
||||
* It errs towards refusing. A role that cannot be read -- the viewer lacks
|
||||
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
|
||||
* an unknown grant is not a grant the viewer can be shown to hold. What it
|
||||
* cannot see is tenancy: an "Administrator" account is a tenant administrator
|
||||
* inside a tenant and a server administrator outside one, and a tenant-scoped
|
||||
* viewer is not told which it is looking at. It never sees the second kind,
|
||||
* which is why comparing against the administrator baseline is enough there.
|
||||
*/
|
||||
export function outranks(
|
||||
viewer: Permissions,
|
||||
target: { roles?: UserRoles | null; permissions?: PermissionsMode | null },
|
||||
roles: ReadonlyMap<string, RoleDef> | null,
|
||||
): boolean {
|
||||
let granted = new Set<string>();
|
||||
const kind = target.roles?.["@type"] ?? "User";
|
||||
if (kind === "Admin") {
|
||||
if (!ADMIN_BASELINE.every((p) => viewer.has(p))) return true;
|
||||
} else if (kind === "Custom") {
|
||||
const ids = Object.keys((target.roles as { roleIds?: Record<string, boolean> }).roleIds ?? {});
|
||||
const resolved = resolveRoles(ids, roles);
|
||||
if (!resolved) return true;
|
||||
granted = resolved;
|
||||
}
|
||||
const mode = target.permissions;
|
||||
if (mode && mode["@type"] !== "Inherit") {
|
||||
const enabled = Object.keys(mode.enabledPermissions ?? {});
|
||||
granted = mode["@type"] === "Replace" ? new Set(enabled) : new Set([...granted, ...enabled]);
|
||||
}
|
||||
for (const p of granted) if (!viewer.has(p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Every permission a set of roles grants, nested roles included; null if any cannot be read. */
|
||||
export function resolveRoles(ids: readonly string[], roles: ReadonlyMap<string, RoleDef> | null): Set<string> | null {
|
||||
if (!ids.length) return new Set();
|
||||
if (!roles) return null;
|
||||
const out = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
const walk = (id: string): boolean => {
|
||||
if (seen.has(id)) return true;
|
||||
seen.add(id);
|
||||
const role = roles.get(id);
|
||||
if (!role) return false;
|
||||
for (const p of Object.keys(role.enabledPermissions ?? {})) out.add(p);
|
||||
return Object.keys(role.roleIds ?? {}).every(walk);
|
||||
};
|
||||
return ids.every(walk) ? out : null;
|
||||
}
|
||||
|
||||
/** Whether the viewer could grant a role: they hold everything it carries. */
|
||||
export function canGrantRole(viewer: Permissions, roleId: string, roles: ReadonlyMap<string, RoleDef> | null): boolean {
|
||||
const granted = resolveRoles([roleId], roles);
|
||||
return granted !== null && [...granted].every((p) => viewer.has(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* A password to hand to somebody who will change it.
|
||||
*
|
||||
* Twenty characters from an alphabet without the ones people misread aloud
|
||||
* (0/O, 1/l/I), in groups of five. Rejection sampling, so every character is
|
||||
* equally likely rather than the first few of the alphabet slightly more.
|
||||
*/
|
||||
const ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
export function generatePassword(random: (n: number) => Uint8Array = (n) => crypto.getRandomValues(new Uint8Array(n))): string {
|
||||
const out: string[] = [];
|
||||
const limit = 256 - (256 % ALPHABET.length);
|
||||
while (out.length < 20) {
|
||||
for (const byte of random(32)) {
|
||||
if (byte < limit && out.length < 20) out.push(ALPHABET[byte % ALPHABET.length]!);
|
||||
}
|
||||
}
|
||||
return [0, 5, 10, 15].map((i) => out.slice(i, i + 5).join("")).join("-");
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { client } from "@/jmap/client";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { PermissionsMode, RoleDef, UserRoles } from "@/lib/adminAccess";
|
||||
|
||||
/**
|
||||
* Stalwart 0.16's directory, over the ordinary JMAP proxy.
|
||||
*
|
||||
* 0.16 removed the REST management API (`/api/principal` and the rest); people,
|
||||
* domains and roles are registry objects now, read and written with `x:Account`,
|
||||
* `x:Domain` and `x:Role`. These go through `/api/jmap` like every other call,
|
||||
* authenticated as the signed-in account, so ihasmail holds nothing new: no
|
||||
* route of its own, no store, no cache beyond the component showing the list.
|
||||
*
|
||||
* Shapes, from the 0.16.22 source:
|
||||
*
|
||||
* - A list (credentials, aliases) is an object keyed by index, `{"0": …}`. A
|
||||
* set (memberGroupIds, role ids, permissions) is `{"id": true}`.
|
||||
* - An account's `name` is its local part, and its domain is a `domainId`.
|
||||
* `emailAddress` and `usedDiskQuota` are computed by the server.
|
||||
* - Secrets read back masked. A new password is written to the existing
|
||||
* password credential, so its id -- which OAuth tokens are tied to -- stays.
|
||||
* - Filters are AND only, and the default order is newest first.
|
||||
*
|
||||
* Query and get are two requests rather than one with a result reference.
|
||||
* Whether the registry methods resolve back-references has not been checked on
|
||||
* a live server, and a list that loads a moment slower is a better failure than
|
||||
* one that never loads.
|
||||
*/
|
||||
|
||||
export interface EmailAlias {
|
||||
enabled?: boolean;
|
||||
name: string;
|
||||
domainId: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface Credential {
|
||||
"@type": "Password" | "AppPassword" | "ApiKey";
|
||||
secret?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DirectoryAccount {
|
||||
id: string;
|
||||
"@type": "User" | "Group";
|
||||
name: string;
|
||||
domainId: string;
|
||||
emailAddress?: string;
|
||||
description?: string | null;
|
||||
roles?: UserRoles;
|
||||
permissions?: PermissionsMode;
|
||||
quotas?: Record<string, number>;
|
||||
usedDiskQuota?: number;
|
||||
aliases?: Record<string, EmailAlias>;
|
||||
memberGroupIds?: Record<string, boolean>;
|
||||
credentials?: Record<string, Credential>;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface DirectoryDomain {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ACCOUNT_PROPERTIES = [
|
||||
"@type", "name", "domainId", "emailAddress", "description", "roles", "permissions", "quotas",
|
||||
"usedDiskQuota", "aliases", "memberGroupIds", "credentials", "createdAt",
|
||||
];
|
||||
|
||||
/** The one quota ihasmail edits; the others keep whatever they had. */
|
||||
export const DISK_QUOTA = "maxDiskQuota";
|
||||
|
||||
/** An error with a SetError behind it, kept so the caller can explain it. */
|
||||
export class DirectoryError extends Error {
|
||||
constructor(
|
||||
readonly type: string,
|
||||
readonly description: string | undefined,
|
||||
readonly properties: string[] = [],
|
||||
) {
|
||||
super(description ?? type);
|
||||
this.name = "DirectoryError";
|
||||
}
|
||||
}
|
||||
|
||||
interface QueryResult {
|
||||
ids: string[];
|
||||
total?: number;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export async function queryAccounts(opts: { type: "User" | "Group"; text?: string; position?: number; limit?: number }): Promise<{ ids: string[]; total: number }> {
|
||||
const filter: Record<string, unknown> = { type: opts.type };
|
||||
if (opts.text?.trim()) filter.text = opts.text.trim();
|
||||
const res = await client.call<QueryResult>("x:Account/query", {
|
||||
filter,
|
||||
position: opts.position ?? 0,
|
||||
...(opts.limit ? { limit: opts.limit } : {}),
|
||||
calculateTotal: true,
|
||||
});
|
||||
return { ids: res.ids ?? [], total: res.total ?? res.ids?.length ?? 0 };
|
||||
}
|
||||
|
||||
export async function getAccounts(ids: string[]): Promise<DirectoryAccount[]> {
|
||||
if (!ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids, properties: ACCOUNT_PROPERTIES });
|
||||
// In the order the query gave, which is the order the list is shown in.
|
||||
const byId = new Map(res.list.map((a) => [a.id, a]));
|
||||
return ids.map((id) => byId.get(id)).filter((a): a is DirectoryAccount => Boolean(a));
|
||||
}
|
||||
|
||||
/** Every one of a kind, for the pickers. Capped by what the server allows in a get. */
|
||||
async function all<T>(object: "Domain" | "Role", properties: string[]): Promise<T[]> {
|
||||
const q = await client.call<QueryResult>(`x:${object}/query`, { limit: client.maxObjectsInGet });
|
||||
if (!q.ids?.length) return [];
|
||||
const res = await client.call<{ list: T[] }>(`x:${object}/get`, { ids: q.ids, properties });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
export const listDomains = () => all<DirectoryDomain>("Domain", ["name"]);
|
||||
export const listRoles = () => all<RoleDef>("Role", ["description", "enabledPermissions", "roleIds"]);
|
||||
|
||||
export async function listGroups(): Promise<DirectoryAccount[]> {
|
||||
const q = await queryAccounts({ type: "Group", limit: client.maxObjectsInGet });
|
||||
if (!q.ids.length) return [];
|
||||
const res = await client.call<{ list: DirectoryAccount[] }>("x:Account/get", { ids: q.ids, properties: ["name", "emailAddress", "description"] });
|
||||
return res.list;
|
||||
}
|
||||
|
||||
type SetResponse = Record<string, Record<string, { type: string; description?: string; properties?: string[] } | null> | undefined>;
|
||||
|
||||
function throwIfRefused(res: SetResponse, kind: "notCreated" | "notUpdated" | "notDestroyed"): void {
|
||||
const failure = Object.values(res[kind] ?? {})[0];
|
||||
if (failure) throw new DirectoryError(failure.type, failure.description, failure.properties);
|
||||
}
|
||||
|
||||
export interface NewAccount {
|
||||
name: string;
|
||||
domainId: string;
|
||||
description: string;
|
||||
password: string;
|
||||
roles: UserRoles;
|
||||
diskQuotaBytes: number | null;
|
||||
}
|
||||
|
||||
export async function createAccount(input: NewAccount): Promise<string> {
|
||||
const res = await client.call<SetResponse & { created?: Record<string, { id: string }> }>("x:Account/set", {
|
||||
create: {
|
||||
n: {
|
||||
"@type": "User",
|
||||
name: input.name.trim(),
|
||||
domainId: input.domainId,
|
||||
description: input.description.trim() || null,
|
||||
credentials: { "0": { "@type": "Password", secret: input.password } },
|
||||
roles: input.roles,
|
||||
permissions: { "@type": "Inherit" },
|
||||
quotas: input.diskQuotaBytes ? { [DISK_QUOTA]: input.diskQuotaBytes } : {},
|
||||
aliases: {},
|
||||
memberGroupIds: {},
|
||||
// Required on create. Turning it on is one-way and not offered here.
|
||||
encryptionAtRest: { "@type": "Disabled" },
|
||||
},
|
||||
},
|
||||
});
|
||||
throwIfRefused(res, "notCreated");
|
||||
const id = res.created?.n?.id;
|
||||
if (!id) throw new DirectoryError("serverFail", t("The server did not say whether the account was created."));
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function updateAccount(id: string, patch: Record<string, unknown>): Promise<void> {
|
||||
if (!Object.keys(patch).length) return;
|
||||
const res = await client.call<SetResponse>("x:Account/set", { update: { [id]: patch } });
|
||||
throwIfRefused(res, "notUpdated");
|
||||
}
|
||||
|
||||
export async function destroyAccount(id: string): Promise<void> {
|
||||
const res = await client.call<SetResponse>("x:Account/set", { destroy: [id] });
|
||||
throwIfRefused(res, "notDestroyed");
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch that sets a new password.
|
||||
*
|
||||
* Into the existing password credential when there is one, which keeps its
|
||||
* credential id; as a new credential after the last index when there is not --
|
||||
* an account that has only ever signed in through a directory, say. An account
|
||||
* holds one password at most, so adding a second is never the answer.
|
||||
*/
|
||||
export function passwordPatch(account: Pick<DirectoryAccount, "credentials">, secret: string): Record<string, unknown> {
|
||||
const entries = Object.entries(account.credentials ?? {});
|
||||
const existing = entries.find(([, c]) => c["@type"] === "Password");
|
||||
if (existing) return { [`credentials/${existing[0]}/secret`]: secret };
|
||||
const next = entries.reduce((max, [k]) => Math.max(max, Number(k) + 1), 0);
|
||||
return { [`credentials/${next}`]: { "@type": "Password", secret } };
|
||||
}
|
||||
|
||||
export function hasPassword(account: Pick<DirectoryAccount, "credentials">): boolean {
|
||||
return Object.values(account.credentials ?? {}).some((c) => c["@type"] === "Password");
|
||||
}
|
||||
|
||||
/** Re-index a list of aliases the way the server stores them. */
|
||||
export function aliasList(aliases: EmailAlias[]): Record<string, EmailAlias> {
|
||||
return Object.fromEntries(aliases.map((a, i) => [String(i), { enabled: a.enabled ?? true, name: a.name, domainId: a.domainId, description: a.description ?? null }]));
|
||||
}
|
||||
|
||||
/** The quotas object with the disk limit set or cleared, and every other quota kept. */
|
||||
export function quotasWithDisk(quotas: Record<string, number> | undefined, bytes: number | null): Record<string, number> {
|
||||
const next = { ...(quotas ?? {}) };
|
||||
if (bytes && bytes > 0) next[DISK_QUOTA] = bytes;
|
||||
else delete next[DISK_QUOTA];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Say what went wrong in terms of the person's own action.
|
||||
*
|
||||
* Stalwart's descriptions are often exact and occasionally all there is -- a
|
||||
* password policy says what it wants, in English -- so a description is kept
|
||||
* where it carries something the type does not.
|
||||
*/
|
||||
export function describeDirectoryError(err: unknown): string {
|
||||
if (!(err instanceof DirectoryError)) {
|
||||
const e = err as { type?: string; message?: string };
|
||||
if (e?.type === "forbidden") return t("The mail server refused this. Your role may not allow it.");
|
||||
return e?.message ?? String(err);
|
||||
}
|
||||
switch (err.type) {
|
||||
case "forbidden":
|
||||
return err.description ? t("The mail server refused this: {reason}", { reason: err.description }) : t("The mail server refused this. Your role may not allow it.");
|
||||
case "primaryKeyViolation":
|
||||
return t("That address is already in use on this server, as an account, a list or an alias.");
|
||||
case "invalidForeignKey":
|
||||
return t("One of the chosen domain, role or group can't be used for this account.");
|
||||
case "overQuota":
|
||||
return t("Your organisation has reached the number of accounts it is allowed.");
|
||||
case "objectIsLinked":
|
||||
return t("Something still depends on this, so the server kept it.");
|
||||
case "notFound":
|
||||
return t("This account no longer exists. Someone may have deleted it.");
|
||||
case "invalidProperties":
|
||||
if (err.properties.includes("secret")) return err.description ? t("The password was not accepted: {reason}", { reason: err.description }) : t("The password was not accepted.");
|
||||
return err.description ? t("The mail server rejected a value: {reason}", { reason: err.description }) : t("The mail server rejected a value.");
|
||||
default:
|
||||
return err.description ?? err.type;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user