Skip to content
18 changes: 18 additions & 0 deletions .changeset/setup-admin-wording-zh-cn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/platform-objects': patch
'@objectstack/plugin-security': patch
'@objectstack/plugin-approvals': patch
'@objectstack/plugin-sharing': patch
'@objectstack/plugin-email': patch
'@objectstack/service-settings': patch
---

fix: Setup's form help speaks to an administrator, and the queue pages, the mail status banner and the test-email toast read in Chinese on a zh-CN console

Clause-②: yes (widening)
Metadata-protocol: none

- **Field help (English source and zh-CN):** the permission set and position assignment forms, the out-of-office (approval delegation) rule, and the business unit, organization and user forms no longer describe the implementation. Gone: "fail-closed", "half-open [from, until)", "agent grants", "graph semantics", "expanded by graph queries", "Null = root of tenant", "HRIS", "When true / When false" (zh-CN 「为 true 时」 now reads 「开启后」) and an HTTP status code. The help now says when an assignment starts and ends, what turning a switch on does, and what to fill in. Text only: no field name, key, option value or behaviour changes.
- **`@objectstack/plugin-sharing`:** `sys_queue` and `sys_queue_member` join the plugin's translation bundle, with zh-CN text, so the 队列 and 队列成员 pages, their columns, help and the 新建 button read in Chinese. ja-JP and es-ES carry English fills for now.
- **`@objectstack/plugin-email`:** the live-status statement at the top of Setup → Email and the "Send test email" answer (sent, failed, logged only) are written in the caller's display language, en and zh-CN today; another language gets English.
- **`@objectstack/service-settings`:** a settings action handler (`SettingsActionHandler`) receives an optional `locale`, the caller's display language read from the request the same way the live-status statement's is (`Accept-Language`, else `?locale`); `runAction` takes it as a fifth `display` argument. Existing handlers compile and behave unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
banned: {
label: "Banned",
help: "When true, the user cannot sign in. Toggle via Ban User / Unban User actions."
help: "When on, the user cannot sign in. Use the Ban User / Unban User actions to change it."
},
ban_reason: {
label: "Ban Reason"
Expand Down Expand Up @@ -70,7 +70,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
must_change_password: {
label: "Must Change Password",
help: "When true, the user is blocked (403 PASSWORD_RESET_REQUIRED) until they change their password. Stamped by the admin user-management routes; system-managed."
help: "When on, the user must change their password before they can continue. Turned on when an administrator creates or imports the account, or resets its password; system-managed."
},
mfa_required_at: {
label: "MFA Required At",
Expand Down Expand Up @@ -557,7 +557,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
require_mfa: {
label: "Require Multi-Factor Auth",
help: "When true, every member of this organization must enroll an authenticator app to access data. Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, saving this as true is refused."
help: "Turn on to require every member of this organization to set up an authenticator app before they can access data. Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, saving this as on is refused."
},
timezone: {
label: "Timezone",
Expand Down Expand Up @@ -995,7 +995,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
kind: {
label: "Kind",
help: "Categorisation hint — does not change graph semantics.",
help: "For grouping and reporting only; it does not change where the unit sits in the organization or who it covers.",
options: {
company: "Company",
division: "Division",
Expand All @@ -1006,7 +1006,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
parent_business_unit_id: {
label: "Parent Business Unit",
help: "Self-reference for the org tree. Null = root of tenant. A unit that still has child units cannot be deleted until they are re-parented or deleted."
help: "The unit this one sits under. Leave empty for a top-level unit. A unit that still has units under it cannot be deleted until they are moved under another unit or deleted."
},
timezone: {
label: "Timezone",
Expand All @@ -1022,19 +1022,19 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
active: {
label: "Active",
help: "When false, members are not expanded by graph queries."
help: "Turn off to retire the unit: sharing and other access given to this unit, or to a unit above it, no longer reaches its members or the units under it."
},
effective_from: {
label: "Effective From",
help: "When this business unit came into existence (HRIS sync)."
help: "When this business unit came into existence. Usually filled in by a sync from your HR system."
},
effective_to: {
label: "Effective To",
help: "When this business unit was retired (HRIS sync)."
help: "When this business unit was retired. Usually filled in by a sync from your HR system."
},
external_ref: {
label: "External Reference",
help: "ID in upstream HRIS (Workday / SAP HR / 北森)."
help: "The unit's ID in your HR system (such as Workday, SAP HR or 北森), used to match it on each sync."
},
id: {
label: "Business Unit ID"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
banned: {
label: "已封禁",
help: "为 true 时,用户无法登录。可通过“封禁用户 / 解除封禁”操作切换。"
help: "开启后,用户无法登录。可通过“封禁用户 / 解除封禁”操作切换。"
},
ban_reason: {
label: "封禁原因"
Expand Down Expand Up @@ -70,7 +70,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
must_change_password: {
label: "须修改密码",
help: "为 true 时,用户会被阻止访问(403 PASSWORD_RESET_REQUIRED),直到修改密码。由管理员用户管理接口写入;系统维护。"
help: "开启后,用户必须先修改密码才能继续使用。管理员创建账号、导入用户或重置密码时会自动开启;系统维护。"
},
mfa_required_at: {
label: "MFA 强制生效时间",
Expand Down Expand Up @@ -557,7 +557,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
require_mfa: {
label: "强制多因素认证",
help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。需要本部署开启双重验证(OS_AUTH_TWO_FACTOR=true);双重验证关闭时,保存为 true 会被拒绝。"
help: "开启后,该组织的每位成员都必须先绑定身份验证器 App,才能访问数据。需要本部署开启双重验证(OS_AUTH_TWO_FACTOR=true);双重验证关闭时,保存为开启会被拒绝。"
},
timezone: {
label: "时区",
Expand Down Expand Up @@ -995,7 +995,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
kind: {
label: "类型",
help: "分类提示——不会改变图谱语义。",
help: "仅用于分类和报表,不会改变该单元在组织中的位置和覆盖范围。",
options: {
company: "公司",
division: "事业部",
Expand All @@ -1006,7 +1006,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
parent_business_unit_id: {
label: "上级业务单元",
help: "组织树的自关联字段。Null 表示租户根节点。仍有下级单元的业务单元无法删除,须先将下级单元改挂到其他上级或删除。"
help: "该单元的上级单元。留空表示顶级单元。仍有下级单元的业务单元无法删除,须先将下级单元改挂到其他上级或删除。"
},
timezone: {
label: "时区",
Expand All @@ -1022,19 +1022,19 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
active: {
label: "启用",
help: "为 false 时,图谱查询不会展开该业务单元成员。"
help: "关闭后即停用该单元:授予该单元或其上级单元的共享等访问权限,将不再覆盖其成员及下级单元。"
},
effective_from: {
label: "生效时间",
help: "该业务单元生效的时间(HRIS 同步)。"
help: "该业务单元成立的时间,通常由 HR 系统同步填写。"
},
effective_to: {
label: "失效时间",
help: "该业务单元停用的时间(HRIS 同步)。"
help: "该业务单元撤销的时间,通常由 HR 系统同步填写。"
},
external_ref: {
label: "外部引用",
help: "上游 HRIS 中的 ID(Workday / SAP HR / 北森)。"
help: "该单元在 HR 系统(如 Workday、SAP HR、北森)中的编号,每次同步时用它对应到本单元。"
},
id: {
label: "业务单元 ID"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The help on Setup's New business unit, Edit organization and user forms
* speaks to the administrator filling them in.
*
* A field's help tooltip is the served translation of its `description`.
* These used to describe the implementation: the org tree as a graph
* ("graph semantics", "expanded by graph queries"), a null value
* ("Null = root of tenant"), an acronym for the upstream system ("HRIS"), a
* boolean literal ("When true", zh-CN 「为 true 时」) and an HTTP status code.
* What the administrator needs is what turning the field on does, and what to
* fill in. This pins the served zh-CN text and the English source.
*/

import { describe, expect, it } from 'vitest';
import { SetupAppTranslations } from '../apps/translations/setup.translation.js';
import { SysBusinessUnit } from './sys-business-unit.object.js';
import { SysOrganization } from './sys-organization.object.js';
import { SysUser } from './sys-user.object.js';

/** Implementation vocabulary an administrator's field help must not carry. */
const DEVELOPER_TERMS: ReadonlyArray<readonly [string, RegExp]> = [
['graph vocabulary', /图谱|\bgraph\b/i],
['a null literal', /\bNull\b|为空 =/],
['an acronym for the upstream system', /\bHRIS\b/],
['a boolean literal', /为 ?(true|false) ?时|\bWhen (true|false)\b/i],
['an HTTP status code', /\b[45]\d\d [A-Z][A-Z_]{3,}\b/],
];

const FORMS = {
sys_business_unit: {
schema: SysBusinessUnit,
fields: ['kind', 'parent_business_unit_id', 'active', 'effective_from', 'effective_to', 'external_ref'],
},
sys_organization: { schema: SysOrganization, fields: ['require_mfa'] },
sys_user: { schema: SysUser, fields: ['banned', 'must_change_password'] },
} as const;

function termsIn(text: string): string[] {
return DEVELOPER_TERMS.filter(([, re]) => re.test(text)).map(([name]) => name);
}

type ObjectCopy = { fields?: Record<string, { help?: string }> };

describe('Setup forms: business unit, organization and user help reads as administrator wording', () => {
const zhCN = (SetupAppTranslations['zh-CN'] as { objects: Record<string, ObjectCopy> }).objects;

for (const [object, { schema, fields }] of Object.entries(FORMS)) {
for (const field of fields) {
it(`${object}.${field}: the served zh-CN help is Chinese and names no implementation term`, () => {
const help = zhCN[object]?.fields?.[field]?.help ?? '';
expect(help).toMatch(/[一-鿿]/);
expect(termsIn(help), help).toEqual([]);
});

it(`${object}.${field}: the English source names no implementation term`, () => {
const description = (schema.fields as Record<string, { description?: string }>)[field]?.description ?? '';
expect(description.length).toBeGreaterThan(0);
expect(termsIn(description), description).toEqual([]);
});
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const SysBusinessUnit = ObjectSchema.create({
label: 'Kind',
required: true,
defaultValue: 'department',
description: 'Categorisation hint — does not change graph semantics.',
description: 'For grouping and reporting only; it does not change where the unit sits in the organization or who it covers.',
group: 'Identity',
options: [
{ value: 'company', label: 'Company' },
Expand All @@ -145,7 +145,7 @@ export const SysBusinessUnit = ObjectSchema.create({
required: false,
deleteBehavior: 'restrict',
description:
'Self-reference for the org tree. Null = root of tenant. A unit that still has child units cannot be deleted until they are re-parented or deleted.',
'The unit this one sits under. Leave empty for a top-level unit. A unit that still has units under it cannot be deleted until they are moved under another unit or deleted.',
group: 'Hierarchy',
}),

Expand Down Expand Up @@ -224,29 +224,29 @@ export const SysBusinessUnit = ObjectSchema.create({
label: 'Active',
required: false,
defaultValue: true,
description: 'When false, members are not expanded by graph queries.',
description: 'Turn off to retire the unit: sharing and other access given to this unit, or to a unit above it, no longer reaches its members or the units under it.',
group: 'Lifecycle',
}),

effective_from: Field.datetime({
label: 'Effective From',
required: false,
description: 'When this business unit came into existence (HRIS sync).',
description: 'When this business unit came into existence. Usually filled in by a sync from your HR system.',
group: 'Lifecycle',
}),

effective_to: Field.datetime({
label: 'Effective To',
required: false,
description: 'When this business unit was retired (HRIS sync).',
description: 'When this business unit was retired. Usually filled in by a sync from your HR system.',
group: 'Lifecycle',
}),

external_ref: Field.text({
label: 'External Reference',
required: false,
maxLength: 200,
description: 'ID in upstream HRIS (Workday / SAP HR / 北森).',
description: 'The unit\'s ID in your HR system (such as Workday, SAP HR or 北森), used to match it on each sync.',
group: 'Lifecycle',
}),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,9 +404,9 @@ export const SysOrganization = ObjectSchema.create({
defaultValue: false,
group: 'Configuration',
description:
'When true, every member of this organization must enroll an authenticator app to access data. ' +
'Turn on to require every member of this organization to set up an authenticator app before they can access data. ' +
'Needs two-factor authentication on for this deployment (OS_AUTH_TWO_FACTOR=true); while it is off, ' +
'saving this as true is refused.',
'saving this as on is refused.',
}),

// #14238 — maintainer ruling 2026-09-02, option A (verbatim 「同意」): the
Expand Down
6 changes: 3 additions & 3 deletions packages/platform-objects/src/identity/sys-user.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -981,7 +981,7 @@ export const SysUser = ObjectSchema.create({
defaultValue: false,
readonly: true, // ADR-0092 — toggled via Ban/Unban actions (session side effects)
group: 'Account',
description: 'When true, the user cannot sign in. Toggle via Ban User / Unban User actions.',
description: 'When on, the user cannot sign in. Use the Ban User / Unban User actions to change it.',
}),

ban_reason: Field.text({
Expand Down Expand Up @@ -1085,8 +1085,8 @@ export const SysUser = ObjectSchema.create({
readonly: true,
group: 'Admin',
description:
'When true, the user is blocked (403 PASSWORD_RESET_REQUIRED) until they change ' +
'their password. Stamped by the admin user-management routes; system-managed.',
'When on, the user must change their password before they can continue. ' +
'Turned on when an administrator creates or imports the account, or resets its password; system-managed.',
}),

// ADR-0069 D3 — when enforced MFA first applied to this user; starts the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const SysApprovalDelegation = ObjectSchema.create({
required: false,
group: 'Delegation',
description:
'Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires.',
'The rule stops applying at this time (UTC). Leave empty for no end date.',
}),

reason: Field.text({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* An out-of-office rule's "Valid Until" help says when the rule stops, not
* which interval notation the check uses.
*
* The help tooltip on the `sys_approval_delegation` form is the served
* translation of the field's `description`. It used to read "half-open
* [from, until)" (zh-CN 「半开区间 [from, until)」): the shape of the check,
* which an administrator has no use for. This pins the served zh-CN text and
* the English source.
*/

import { describe, expect, it } from 'vitest';
import { ApprovalsTranslations } from './index.js';
import { SysApprovalDelegation } from '../sys-approval-delegation.object.js';

const INTERVAL_NOTATION = /半开区间|half-open|\[from, until\)/i;

type ObjectCopy = { fields?: Record<string, { help?: string }> };

describe('sys_approval_delegation.valid_until: help reads as administrator wording', () => {
it('the served zh-CN help is Chinese and carries no interval notation', () => {
const objects = (ApprovalsTranslations['zh-CN'] as { objects: Record<string, ObjectCopy> }).objects;
const help = objects.sys_approval_delegation?.fields?.valid_until?.help ?? '';
expect(help).toMatch(/[一-鿿]/);
expect(help).not.toMatch(INTERVAL_NOTATION);
});

it('the English source carries no interval notation', () => {
const description = (SysApprovalDelegation.fields as Record<string, { description?: string }>).valid_until
?.description ?? '';
expect(description.length).toBeGreaterThan(0);
expect(description).not.toMatch(INTERVAL_NOTATION);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
valid_until: {
label: "Valid Until",
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
help: "The rule stops applying at this time (UTC). Leave empty for no end date."
},
reason: {
label: "Reason",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
valid_until: {
label: "Valid Until",
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
help: "The rule stops applying at this time (UTC). Leave empty for no end date."
},
reason: {
label: "Reason",
Expand Down
Loading
Loading