diff --git a/.changeset/setup-admin-wording-zh-cn.md b/.changeset/setup-admin-wording-zh-cn.md new file mode 100644 index 00000000000..864080bf125 --- /dev/null +++ b/.changeset/setup-admin-wording-zh-cn.md @@ -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. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 223f83c9e55..423e402c972 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -39,7 +39,7 @@ export const enObjects: NonNullable = { }, 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" @@ -70,7 +70,7 @@ export const enObjects: NonNullable = { }, 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", @@ -557,7 +557,7 @@ export const enObjects: NonNullable = { }, 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", @@ -995,7 +995,7 @@ export const enObjects: NonNullable = { }, 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", @@ -1006,7 +1006,7 @@ export const enObjects: NonNullable = { }, 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", @@ -1022,19 +1022,19 @@ export const enObjects: NonNullable = { }, 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" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index fdf529f6ffb..3ed89209302 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -39,7 +39,7 @@ export const zhCNObjects: NonNullable = { }, banned: { label: "已封禁", - help: "为 true 时,用户无法登录。可通过“封禁用户 / 解除封禁”操作切换。" + help: "开启后,用户无法登录。可通过“封禁用户 / 解除封禁”操作切换。" }, ban_reason: { label: "封禁原因" @@ -70,7 +70,7 @@ export const zhCNObjects: NonNullable = { }, must_change_password: { label: "须修改密码", - help: "为 true 时,用户会被阻止访问(403 PASSWORD_RESET_REQUIRED),直到修改密码。由管理员用户管理接口写入;系统维护。" + help: "开启后,用户必须先修改密码才能继续使用。管理员创建账号、导入用户或重置密码时会自动开启;系统维护。" }, mfa_required_at: { label: "MFA 强制生效时间", @@ -557,7 +557,7 @@ export const zhCNObjects: NonNullable = { }, require_mfa: { label: "强制多因素认证", - help: "为 true 时,该组织的每位成员都必须注册身份验证器 App 才能访问数据。需要本部署开启双重验证(OS_AUTH_TWO_FACTOR=true);双重验证关闭时,保存为 true 会被拒绝。" + help: "开启后,该组织的每位成员都必须先绑定身份验证器 App,才能访问数据。需要本部署开启双重验证(OS_AUTH_TWO_FACTOR=true);双重验证关闭时,保存为开启会被拒绝。" }, timezone: { label: "时区", @@ -995,7 +995,7 @@ export const zhCNObjects: NonNullable = { }, kind: { label: "类型", - help: "分类提示——不会改变图谱语义。", + help: "仅用于分类和报表,不会改变该单元在组织中的位置和覆盖范围。", options: { company: "公司", division: "事业部", @@ -1006,7 +1006,7 @@ export const zhCNObjects: NonNullable = { }, parent_business_unit_id: { label: "上级业务单元", - help: "组织树的自关联字段。Null 表示租户根节点。仍有下级单元的业务单元无法删除,须先将下级单元改挂到其他上级或删除。" + help: "该单元的上级单元。留空表示顶级单元。仍有下级单元的业务单元无法删除,须先将下级单元改挂到其他上级或删除。" }, timezone: { label: "时区", @@ -1022,19 +1022,19 @@ export const zhCNObjects: NonNullable = { }, 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" diff --git a/packages/platform-objects/src/identity/setup-form-admin-wording.test.ts b/packages/platform-objects/src/identity/setup-form-admin-wording.test.ts new file mode 100644 index 00000000000..374b3340363 --- /dev/null +++ b/packages/platform-objects/src/identity/setup-form-admin-wording.test.ts @@ -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 = [ + ['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 }; + +describe('Setup forms: business unit, organization and user help reads as administrator wording', () => { + const zhCN = (SetupAppTranslations['zh-CN'] as { objects: Record }).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)[field]?.description ?? ''; + expect(description.length).toBeGreaterThan(0); + expect(termsIn(description), description).toEqual([]); + }); + } + } +}); diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 0706460c0cd..236cbf7b3fb 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -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' }, @@ -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', }), @@ -224,21 +224,21 @@ 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', }), @@ -246,7 +246,7 @@ export const SysBusinessUnit = ObjectSchema.create({ 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', }), diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 93e63f1480c..d18c8d761e6 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -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 diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 09da1ff1e0d..980dec97cd9 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -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({ @@ -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 diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index 063e30c6bbe..f911b75cc0a 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -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({ diff --git a/packages/plugins/plugin-approvals/src/translations/delegation-window-wording.test.ts b/packages/plugins/plugin-approvals/src/translations/delegation-window-wording.test.ts new file mode 100644 index 00000000000..b1f2db4b30e --- /dev/null +++ b/packages/plugins/plugin-approvals/src/translations/delegation-window-wording.test.ts @@ -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 }; + +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 }).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).valid_until + ?.description ?? ''; + expect(description.length).toBeGreaterThan(0); + expect(description).not.toMatch(INTERVAL_NOTATION); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index 6aa3e8c2efe..4ea03d9eb46 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -366,7 +366,7 @@ export const enObjects: NonNullable = { }, 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", diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index 42442133a4e..f1a0d02b5aa 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -366,7 +366,7 @@ export const esESObjects: NonNullable = { }, 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", diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts index 4530c6bdb81..9a26cb46d36 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.source-hashes.generated.ts @@ -35,7 +35,7 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d", "objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d", "objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda", - "objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d", + "objects.sys_approval_delegation.fields.valid_until.help": "7f5921fe2cd373e3", "objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13", "objects.sys_approval_delegation.label": "4389c4fe5f5c9746", "objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db", diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index 66d3115845f..1e2961aa9df 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -366,7 +366,7 @@ export const jaJPObjects: NonNullable = { }, 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", diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts index 7fa82e5f101..c137b4d4b89 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.source-hashes.generated.ts @@ -34,7 +34,7 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d", "objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d", "objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda", - "objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d", + "objects.sys_approval_delegation.fields.valid_until.help": "7f5921fe2cd373e3", "objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13", "objects.sys_approval_delegation.label": "4389c4fe5f5c9746", "objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db", diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index 14bd078936d..5175ddee449 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -366,7 +366,7 @@ export const zhCNObjects: NonNullable = { }, valid_until: { label: "失效时间", - help: "此刻(含)及之后规则不生效(半开区间 [from, until),UTC)。留空 = 永不过期。" + help: "规则到此时间即失效(按 UTC 计)。留空 = 永不过期。" }, reason: { label: "原因", diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index b4930823f85..433c43369e4 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -35,7 +35,7 @@ import type { import { bindEmailTemplateDoor, unbindEmailTemplateDoor } from './email-template-door.js'; import { sweepStrandedOutbox, type OutboxSweepResult } from './outbox-sweep.js'; import { readInternalHeadersJson } from './internal-header-readback.js'; -import { describeLiveMailTransport, type LiveMailTransportStatus } from './live-transport-status.js'; +import { describeLiveMailTransport, mailDeliveryCopy, type LiveMailTransportStatus } from './live-transport-status.js'; import { EMAIL_LOG_OBJECTS, TENANT_EMAIL_OBJECT, @@ -159,12 +159,17 @@ interface MailSettingsSurface { payload?: Record; /** The caller's settings context; `userId` is the signed-in admin who pressed the button. */ ctx?: { userId?: string }; + /** The caller's display language, which the answer is written in. Absent: English. */ + locale?: string; }) => Promise, ): void; /** State the live transport at the top of Setup → Email (service-settings' `registerLiveStatus`). */ registerLiveStatus?( namespace: string, - provider: () => Promise, + provider: (input?: { + /** The caller's display language, which the statement is written in. Absent: English. */ + locale?: string; + }) => Promise, ): void; } @@ -777,9 +782,9 @@ export class EmailServicePlugin implements Plugin { // each page read, after any re-apply still running, so it follows a // transport swap made from the same page. if (typeof settings.registerLiveStatus === 'function') { - settings.registerLiveStatus('mail', async () => { + settings.registerLiveStatus('mail', async (input) => { await Promise.allSettled([...applying]); - return this.service ? describeLiveMailTransport(this.service) : undefined; + return this.service ? describeLiveMailTransport(this.service, input?.locale) : undefined; }); } @@ -800,7 +805,11 @@ export class EmailServicePlugin implements Plugin { // `from_email`. if (typeof settings.registerAction === 'function') { const svc = this.service; - settings.registerAction('mail', 'test', async ({ values, payload, ctx: actionCtx }: any) => { + settings.registerAction('mail', 'test', async ({ values, payload, ctx: actionCtx, locale }: any) => { + // The answer is the toast the administrator reads: written in + // the caller's display language (English when none is given). + const callerLocale = typeof locale === 'string' ? locale : undefined; + const copy = mailDeliveryCopy(callerLocale); const overrides = extractOverrides(payload); const merged: Record = { ...(values ?? {}), ...overrides }; const recipient = await resolveMailTestRecipient(payload, actionCtx?.userId, engine); @@ -818,7 +827,8 @@ export class EmailServicePlugin implements Plugin { // (#5087 — this action used to report success for SMTP // while the live transport was still the LogTransport). let target: EmailService = svc; - let tempDescription = ''; + /** The form's transport the test used, when not the live one — named in the answer. */ + let testedVia: string | undefined; /** One-shot SMTP transport built for this test — closed below. */ let tempSmtp: SmtpTransport | undefined; const provider = String(merged.provider ?? 'smtp'); @@ -843,7 +853,7 @@ export class EmailServicePlugin implements Plugin { ...(svc.options.persistence ? { persistence: svc.options.persistence } : {}), logger: ctx.logger, }); - tempDescription = ` via smtp (${smtp.host}:${smtp.port ?? 587})`; + testedVia = `smtp (${smtp.host}:${smtp.port ?? 587})`; } catch (err: any) { return { ok: false, severity: 'error', message: `Failed to build SMTP transport: ${err?.message ?? String(err)}` }; } @@ -901,7 +911,7 @@ export class EmailServicePlugin implements Plugin { ...(svc.options.persistence ? { persistence: svc.options.persistence } : {}), logger: ctx.logger, }); - tempDescription = ` via ${provider}`; + testedVia = provider; } catch (err: any) { return { ok: false, severity: 'error', message: `Failed to build ${provider} transport: ${err?.message ?? String(err)}` }; } @@ -927,7 +937,7 @@ export class EmailServicePlugin implements Plugin { return { ok: false, severity: 'error', - message: `Test send failed${tempDescription}: ${result.error ?? 'unknown transport error'}`, + message: copy.testFailed(testedVia, result.error ?? 'unknown transport error'), }; } // A LogTransport "send" is not a delivery. Say so instead of @@ -936,8 +946,7 @@ export class EmailServicePlugin implements Plugin { return { ok: false, severity: 'warning', - message: 'No delivery transport is active — the message was only logged and kept in the email log. ' - + 'Configure an SMTP host (or an API provider) and save before testing.', + message: copy.testLoggedLive, }; } // The form's unsaved "None (log only)": the same severity and @@ -947,15 +956,13 @@ export class EmailServicePlugin implements Plugin { return { ok: false, severity: 'warning', - message: 'Nothing was delivered — the provider on the form is "None (log only)", so the test message ' - + 'was only logged and kept in the email log; no mail left this machine. The live transport was ' - + `not used (${describeLiveMailTransport(svc).label}); save the form to stop delivery.`, + message: copy.testLoggedForm(describeLiveMailTransport(svc, callerLocale).label), }; } return { ok: true, severity: 'info', - message: `Sent test email to ${to}${tempDescription} (id=${result.id}).`, + message: copy.testSent(to, testedVia, String(result.id)), }; } catch (err: any) { return { ok: false, severity: 'error', message: err?.message ?? String(err) }; diff --git a/packages/plugins/plugin-email/src/live-transport-status.ts b/packages/plugins/plugin-email/src/live-transport-status.ts index da5b3265630..312bcf12edf 100644 --- a/packages/plugins/plugin-email/src/live-transport-status.ts +++ b/packages/plugins/plugin-email/src/live-transport-status.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { IEmailTransport } from '@objectstack/spec/contracts'; +import { resolveLocale } from '@objectstack/core'; import { SmtpTransport } from './transports/smtp.js'; import { ResendTransport } from './transports/resend.js'; import { PostmarkTransport } from './transports/postmark.js'; @@ -29,40 +30,105 @@ export interface LiveMailTransportSource { options: { transport: IEmailTransport }; } +/** + * The words Setup → Email uses for the live transport and the "Send test + * email" answer, one entry per language this plugin ships copy for. + * + * Kept here and not in a translation bundle: the sentences are composed per + * request with the transport's name, the recipient and the message id, and the + * console's banner lookup would replace a computed live-status text with a + * fixed one (service-settings' `SETTINGS_LIVE_STATUS_BANNER_ID`). The settings + * routes pass the caller's display language (`Accept-Language`, else + * `?locale`); a language with no entry here is answered in English. + */ +export interface MailDeliveryCopy { + hostTransport: string; + noMailLabel: string; + noMailText: string; + deliveredLabel(name: string): string; + deliveredText(name: string): string; + /** "Send test email" succeeded; `via` names the form's transport when the test used it. */ + testSent(to: string, via: string | undefined, id: string): string; + /** The transport refused or failed the test message, in its own words (`error`). */ + testFailed(via: string | undefined, error: string): string; + /** The live transport is the development log, and the test used it. */ + testLoggedLive: string; + /** The form's unsaved "None (log only)" was tested while `liveLabel` is live. */ + testLoggedForm(liveLabel: string): string; +} + +export const MAIL_DELIVERY_COPY: Record<'en' | 'zh-CN', MailDeliveryCopy> = { + en: { + hostTransport: 'a transport supplied by the host application', + noMailLabel: 'No mail leaves this machine', + noMailText: 'The live transport is the development log. Every message this server sends ' + + '(invitations, password resets, notifications) is recorded in `sys_email` as sent, ' + + 'but none is delivered to anyone. To deliver mail, choose a provider below, complete ' + + 'its settings and save.', + deliveredLabel: (name) => `Mail is delivered through ${name}`, + deliveredText: (name) => `The live transport is ${name}. Messages this server sends are handed to it for ` + + 'delivery. A single send can still fail; "Send test email" below reports the provider\'s own answer.', + testSent: (to, via, id) => `Sent test email to ${to}${via ? ` via ${via}` : ''} (id=${id}).`, + testFailed: (via, error) => `Test send failed${via ? ` via ${via}` : ''}: ${error}`, + testLoggedLive: 'No delivery transport is active — the message was only logged and kept in the email log. ' + + 'Configure an SMTP host (or an API provider) and save before testing.', + testLoggedForm: (liveLabel) => 'Nothing was delivered — the provider on the form is "None (log only)", so the test ' + + 'message was only logged and kept in the email log; no mail left this machine. The live transport was ' + + `not used (${liveLabel}); save the form to stop delivery.`, + }, + 'zh-CN': { + hostTransport: '宿主应用提供的投递方式', + noMailLabel: '当前不会发出任何邮件', + noMailText: '当前的投递方式是开发日志:服务器发出的每封邮件(邀请、密码重置、通知)都会记入邮件日志并标记为已发送,' + + '但不会送达任何人。要真正投递邮件,请在下方选择服务商,填好其设置并保存。', + deliveredLabel: (name) => `邮件通过 ${name} 投递`, + deliveredText: (name) => `当前的投递方式是 ${name},服务器发出的邮件会交给它投递。` + + '单次发送仍可能失败;下方的「发送测试邮件」会显示服务商自己的答复。', + testSent: (to, via, id) => `测试邮件已发送至 ${to}${via ? `,经由 ${via}` : ''}(id=${id})。`, + testFailed: (via, error) => `测试发送失败${via ? `(经由 ${via})` : ''}:${error}`, + testLoggedLive: '当前没有可用的投递方式:测试邮件只记入了日志,并保存在邮件日志中。' + + '请先配置 SMTP 主机(或 API 服务商)并保存,再进行测试。', + testLoggedForm: (liveLabel) => '没有投递任何邮件:表单上的服务商是「不发送(仅记录日志,不真正投递)」,' + + '测试邮件只记入了日志并保存在邮件日志中,没有邮件发出本机。' + + `本次测试没有使用当前的投递方式(${liveLabel});保存表单后将停止投递。`, + }, +}; + +const COPY_LOCALES = Object.keys(MAIL_DELIVERY_COPY); + +/** The copy for a caller's display language: exact, then by language, else English. */ +export function mailDeliveryCopy(locale: string | undefined): MailDeliveryCopy { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return MAIL_DELIVERY_COPY[(picked ?? 'en') as keyof typeof MAIL_DELIVERY_COPY]; +} + /** A name an administrator recognises for the live transport. */ -function transportName(transport: IEmailTransport): string { +function transportName(transport: IEmailTransport, copy: MailDeliveryCopy): string { if (transport instanceof SmtpTransport) { const { host, port } = transport.describe() as { host?: unknown; port?: unknown }; return `SMTP (${String(host)}:${String(port)})`; } if (transport instanceof ResendTransport) return 'Resend'; if (transport instanceof PostmarkTransport) return 'Postmark'; - return 'a transport supplied by the host application'; + return copy.hostTransport; } /** * Describe the live transport from `isConfigured()`, read at call time, so the - * statement follows a transport rebuilt from the `mail` namespace. + * statement follows a transport rebuilt from the `mail` namespace, in the + * caller's display `locale` ({@link mailDeliveryCopy}). * * `isConfigured()` decides the branch: `false` means no mail leaves this machine, * whatever the form says. The transport is read only to name it. */ -export function describeLiveMailTransport(source: LiveMailTransportSource): LiveMailTransportStatus { +export function describeLiveMailTransport( + source: LiveMailTransportSource, + locale?: string, +): LiveMailTransportStatus { + const copy = mailDeliveryCopy(locale); if (!source.isConfigured()) { - return { - label: 'No mail leaves this machine', - bannerSeverity: 'warning', - bannerText: 'The live transport is the development log. Every message this server sends ' - + '(invitations, password resets, notifications) is recorded in `sys_email` as sent, ' - + 'but none is delivered to anyone. To deliver mail, choose a provider below, complete ' - + 'its settings and save.', - }; + return { label: copy.noMailLabel, bannerSeverity: 'warning', bannerText: copy.noMailText }; } - const name = transportName(source.options.transport); - return { - label: `Mail is delivered through ${name}`, - bannerSeverity: 'info', - bannerText: `The live transport is ${name}. Messages this server sends are handed to it for ` - + 'delivery. A single send can still fail; "Send test email" below reports the provider\'s own answer.', - }; + const name = transportName(source.options.transport, copy); + return { label: copy.deliveredLabel(name), bannerSeverity: 'info', bannerText: copy.deliveredText(name) }; } diff --git a/packages/plugins/plugin-email/src/mail-log-provider.pin.test.ts b/packages/plugins/plugin-email/src/mail-log-provider.pin.test.ts index bbb0d1b0b64..6e82dd925d7 100644 --- a/packages/plugins/plugin-email/src/mail-log-provider.pin.test.ts +++ b/packages/plugins/plugin-email/src/mail-log-provider.pin.test.ts @@ -27,14 +27,15 @@ interface Resolved { value: unknown; source?: string } function fakeSettings(values: Record) { const listeners: Array<() => void> = []; const actions = new Map Promise>(); - let liveStatus: (() => Promise) | undefined; + type LiveStatusFn = (input?: { locale?: string }) => Promise; + let liveStatus: LiveStatusFn | undefined; return { values, createClient: () => ({}), getNamespace: async () => ({ values }), subscribe: (_ns: string, cb: () => void) => { listeners.push(cb); }, registerAction: (ns: string, id: string, fn: (a: any) => Promise) => { actions.set(`${ns}/${id}`, fn); }, - registerLiveStatus: (ns: string, fn: () => Promise) => { + registerLiveStatus: (ns: string, fn: LiveStatusFn) => { if (ns === 'mail') liveStatus = fn; }, /** Patch the snapshot and emit settings:changed, as a page save does. */ @@ -42,15 +43,20 @@ function fakeSettings(values: Record) { Object.assign(values, patch); for (const l of listeners) l(); }, - /** What the page states, as the settings GET route asks for it (waits for a running re-apply). */ - statement: () => liveStatus!(), + /** + * What the page states, as the settings GET route asks for it (waits for a + * running re-apply), in the caller's display `locale` when one is given. + */ + statement: (locale?: string) => liveStatus!(locale ? { locale } : undefined), /** * The "Send test email" answer for the stored values — with nothing edited - * on the form, or with `form` as the unsaved form state the page posts. + * on the form, or with `form` as the unsaved form state the page posts — + * in the caller's display `locale` when one is given. */ - sendTest: (form?: Record) => actions.get('mail/test')!({ + sendTest: (form?: Record, locale?: string) => actions.get('mail/test')!({ values: Object.fromEntries(Object.entries(values).map(([k, v]) => [k, v.value])), payload: form ? { to: 'admin@example.test', values: form } : { to: 'admin@example.test' }, + ...(locale ? { locale } : {}), }), }; } @@ -226,3 +232,61 @@ describe('"Send test email" with provider "log" on the form, not saved, while SM expect(answer.message).toMatch(/^Sent test email to admin@example\.test via smtp \(smtp\.form\.test:587\)/); }); }); + +// Setup → Email in the caller's display language. The page's statement and the +// "Send test email" answer are both composed here, per request, so both take +// the locale the settings routes read from the request (`Accept-Language`, +// else `?locale`). English stays the answer for no locale and for a language +// this plugin ships no copy for. +describe('Setup → Email answers in the caller\'s language (zh-CN)', () => { + const HAN = /[一-鿿]/; + const SMTP_SAVED = { + provider: { value: 'smtp', source: 'global' }, + smtp_host: { value: 'smtp.saved.test', source: 'global' }, + }; + + it('the out-of-the-box statement says, in Chinese, that no mail is sent', async () => { + const h = await boot({}); + const statement = await h.settings.statement('zh-CN'); + expect(statement?.label).toBe('当前不会发出任何邮件'); + expect(statement?.bannerSeverity).toBe('warning'); + expect(statement?.bannerText).toMatch(HAN); + expect(statement?.bannerText).not.toMatch(/live transport|sys_email/); + }); + + it('a delivering transport is named in a Chinese statement', async () => { + const h = await boot(SMTP_SAVED); + const statement = await h.settings.statement('zh-CN'); + expect(statement?.label).toBe('邮件通过 SMTP (smtp.saved.test:587) 投递'); + expect(statement?.bannerSeverity).toBe('info'); + expect(statement?.bannerText).toMatch(HAN); + }); + + it('the test toast reports the send in Chinese', async () => { + const h = await boot(SMTP_SAVED); + nm.sendMail.mockClear(); + const answer = await h.settings.sendTest( + { provider: 'smtp', smtp_host: 'smtp.form.test', smtp_port: 587 }, + 'zh-CN', + ); + expect(nm.sendMail).toHaveBeenCalledTimes(1); + expect(answer).toMatchObject({ ok: true, severity: 'info' }); + expect(answer.message).toMatch(/^测试邮件已发送至 admin@example\.test,经由 smtp \(smtp\.form\.test:587\)/); + expect(answer.message).not.toMatch(/Sent test email/); + }); + + it('"None (log only)" on the form: the warning is Chinese and names the live transport in Chinese', async () => { + const h = await boot(SMTP_SAVED); + nm.sendMail.mockClear(); + const answer = await h.settings.sendTest({ provider: 'log' }, 'zh-CN'); + expect(nm.sendMail).not.toHaveBeenCalled(); + expect(answer).toMatchObject({ ok: false, severity: 'warning' }); + expect(answer.message).toMatch(/^没有投递任何邮件/); + expect(answer.message).toContain('邮件通过 SMTP (smtp.saved.test:587) 投递'); + }); + + it('CONTROL — a language with no copy answers in English', async () => { + const h = await boot({}); + expect((await h.settings.statement('fr-FR'))?.label).toBe('No mail leaves this machine'); + }); +}); diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index f0d14450552..e5ef5e31f41 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -133,8 +133,8 @@ export const SysUserPermissionSet = ObjectSchema.create({ label: 'Valid From', required: false, description: - 'Grant is inactive before this instant. Null = active immediately. ' + - 'Enforced fail-closed at resolution time — never by a background job.', + 'The assignment takes effect at this time. Leave empty for it to take effect at once. ' + + 'It starts on time, with no scheduled job to wait for.', }), // [ADR-0091 D1; mandatory on D4 break-glass and D6 agent grants; enforced by the D2 predicate] @@ -142,9 +142,9 @@ export const SysUserPermissionSet = ObjectSchema.create({ label: 'Valid Until', required: false, description: - 'Grant is inactive AT and AFTER this instant (half-open [from, until), UTC). ' + - 'Null = never expires. Mandatory on break-glass activations and agent grants. ' + - 'Enforced at resolution time.', + 'The assignment ends at this time (UTC). Leave empty for no end date. ' + + 'Required on emergency access and on an assignment to an AI agent. ' + + 'It ends on time, with no scheduled job to wait for.', }), reason: Field.text({ @@ -153,8 +153,8 @@ export const SysUserPermissionSet = ObjectSchema.create({ maxLength: 500, // [ADR-0091 D1; required on D3 delegation and D4 break-glass rows; D6 agent grants] description: - 'Why this grant exists. Free text; REQUIRED on delegation and break-glass rows. ' + - 'Agent grants carry the task/run attribution here.', + 'Why this assignment exists. Required on delegated and emergency-access assignments. ' + + 'For an AI agent, name the task it was given access for.', }), // [#9730] `delegated_from` was RETIRED from this object (maintainer ruling diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index a6bdb65122b..b0bd6f7f5d1 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -130,8 +130,8 @@ export const SysUserPosition = ObjectSchema.create({ label: 'Valid From', required: false, description: - 'Grant is inactive before this instant. Null = active immediately. ' + - 'Enforced fail-closed at resolution time — never by a background job.', + 'The assignment takes effect at this time. Leave empty for it to take effect at once. ' + + 'It starts on time, with no scheduled job to wait for.', }), // [ADR-0091 D1; mandatory on D3 delegation rows; enforced by the D2 predicate] @@ -139,8 +139,8 @@ export const SysUserPosition = ObjectSchema.create({ label: 'Valid Until', required: false, description: - 'Grant is inactive AT and AFTER this instant (half-open [from, until), UTC). ' + - 'Null = never expires. Mandatory on delegation rows. Enforced at resolution time.', + 'The assignment ends at this time (UTC). Leave empty for no end date. ' + + 'Required on a delegated assignment. It ends on time, with no scheduled job to wait for.', }), reason: Field.text({ diff --git a/packages/plugins/plugin-security/src/translations/assignment-help-wording.test.ts b/packages/plugins/plugin-security/src/translations/assignment-help-wording.test.ts new file mode 100644 index 00000000000..1754d37ee5b --- /dev/null +++ b/packages/plugins/plugin-security/src/translations/assignment-help-wording.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The validity-window and reason help on a permission set assignment and a + * position assignment speaks to the administrator filling in the form. + * + * Setup opens `sys_user_permission_set` (New permission set assignment) and + * `sys_user_position` from a user's record, and each field's help tooltip is + * the served translation of its `description`. The help used to describe the + * implementation: an enforcement posture ("fail-closed"), interval notation + * ("half-open [from, until)") and an internal grant class ("agent grants"). + * What the administrator needs is when the assignment starts and stops, and + * what to write down. This pins the served zh-CN text and the English source. + */ + +import { describe, expect, it } from 'vitest'; +import { SecurityTranslations } from './index.js'; +import { SysUserPermissionSet, SysUserPosition } from '../objects/index.js'; + +/** Implementation vocabulary an administrator's field help must not carry. */ +const DEVELOPER_TERMS: ReadonlyArray = [ + ['an enforcement posture', /fail-closed/i], + ['interval notation', /半开区间|half-open|\[from, until\)/i], + ['an internal grant class', /智能体授权|\bagent grants?\b|task\/run attribution/i], +]; + +const FORMS = { + sys_user_permission_set: SysUserPermissionSet, + sys_user_position: SysUserPosition, +} as const; +const FIELDS = ['valid_from', 'valid_until', 'reason'] as const; + +function termsIn(text: string): string[] { + return DEVELOPER_TERMS.filter(([, re]) => re.test(text)).map(([name]) => name); +} + +type FieldCopy = { help?: string }; +type ObjectCopy = { fields?: Record }; + +describe('assignment forms: validity and reason help reads as administrator wording', () => { + const zhCN = (SecurityTranslations['zh-CN'] as { objects: Record }).objects; + + for (const [object, schema] 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)[field]?.description ?? ''; + expect(description.length).toBeGreaterThan(0); + expect(termsIn(description), description).toEqual([]); + }); + } + } +}); diff --git a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts index 899206e7aa7..7e8a6f44433 100644 --- a/packages/plugins/plugin-security/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/en.objects.generated.ts @@ -46,15 +46,15 @@ export const enObjects: NonNullable = { }, valid_from: { label: "Valid From", - help: "Grant is inactive before this instant. Null = active immediately. Enforced fail-closed at resolution time — never by a background job." + help: "The assignment takes effect at this time. Leave empty for it to take effect at once. It starts on time, with no scheduled job to wait for." }, valid_until: { label: "Valid Until", - help: "Grant is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires. Mandatory on break-glass activations and agent grants. Enforced at resolution time." + help: "The assignment ends at this time (UTC). Leave empty for no end date. Required on emergency access and on an assignment to an AI agent. It ends on time, with no scheduled job to wait for." }, reason: { label: "Reason", - help: "Why this grant exists. Free text; REQUIRED on delegation and break-glass rows. Agent grants carry the task/run attribution here." + help: "Why this assignment exists. Required on delegated and emergency-access assignments. For an AI agent, name the task it was given access for." }, last_certified_at: { label: "Last Certified At", @@ -112,11 +112,11 @@ export const enObjects: NonNullable = { }, valid_from: { label: "Valid From", - help: "Grant is inactive before this instant. Null = active immediately. Enforced fail-closed at resolution time — never by a background job." + help: "The assignment takes effect at this time. Leave empty for it to take effect at once. It starts on time, with no scheduled job to wait for." }, valid_until: { label: "Valid Until", - help: "Grant is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires. Mandatory on delegation rows. Enforced at resolution time." + help: "The assignment ends at this time (UTC). Leave empty for no end date. Required on a delegated assignment. It ends on time, with no scheduled job to wait for." }, reason: { label: "Reason", diff --git a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts index 5d730ff8480..043d61f0a2a 100644 --- a/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-security/src/translations/zh-CN.objects.generated.ts @@ -46,15 +46,15 @@ export const zhCNObjects: NonNullable = { }, valid_from: { label: "生效时间", - help: "在此时刻之前授予无效。为空 = 立即生效。在解析时以 fail-closed 方式强制执行——绝不依赖后台任务。" + help: "分配从此时间起生效。留空则立即生效。到时即生效,无需等待任何定时任务。" }, valid_until: { label: "失效时间", - help: "授权自该时刻起(含)失效(半开区间 [from, until),UTC)。为空表示永不过期。紧急提权和智能体授权必填。在解析时强制执行。" + help: "分配到此时间即失效(按 UTC 计)。留空则长期有效。紧急提权和授予 AI 智能体的分配必须填写。到时即失效,无需等待任何定时任务。" }, reason: { label: "原因", - help: "该授权存在的原因。自由文本;委派和紧急提权记录必填。智能体授权在此记录任务/运行归属。" + help: "做此分配的原因。委派和紧急提权的分配必须填写。授予 AI 智能体时,请写明它为哪项任务获得授权。" }, last_certified_at: { label: "最近认证时间", @@ -112,11 +112,11 @@ export const zhCNObjects: NonNullable = { }, valid_from: { label: "生效时间", - help: "在此时刻之前授予无效。为空 = 立即生效。在解析时以 fail-closed 方式强制执行——绝不依赖后台任务。" + help: "分配从此时间起生效。留空则立即生效。到时即生效,无需等待任何定时任务。" }, valid_until: { label: "失效时间", - help: "在此时刻及之后授予无效(半开区间 [from, until),UTC)。为空 = 永不过期。委派行必填。在解析时强制执行。" + help: "分配到此时间即失效(按 UTC 计)。留空则长期有效。委派的分配必须填写。到时即失效,无需等待任何定时任务。" }, reason: { label: "原因", diff --git a/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts b/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts index 1524ec6fb77..427bf81611e 100644 --- a/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts +++ b/packages/plugins/plugin-sharing/scripts/i18n-extract.config.ts @@ -37,7 +37,7 @@ */ import { defineStack, type ObjectStackDefinition } from '@objectstack/spec'; -import { SysRecordShare, SysSharingRule, SysShareLink } from '../src/objects/index.js'; +import { SysRecordShare, SysSharingRule, SysShareLink, SysQueue, SysQueueMember } from '../src/objects/index.js'; import { enObjects } from '../src/translations/en.objects.generated.js'; import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; @@ -58,7 +58,7 @@ import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; * precision: the annotated type is the function's own return type. */ const config: ObjectStackDefinition = defineStack({ - objects: [SysRecordShare, SysSharingRule, SysShareLink] as any, + objects: [SysRecordShare, SysSharingRule, SysShareLink, SysQueue, SysQueueMember] as any, translations: [ { en: { objects: enObjects } }, { 'zh-CN': { objects: zhCNObjects } }, diff --git a/packages/plugins/plugin-sharing/src/translations/bundle-ownership.test.ts b/packages/plugins/plugin-sharing/src/translations/bundle-ownership.test.ts index bc217a9a024..093c2ddd0f1 100644 --- a/packages/plugins/plugin-sharing/src/translations/bundle-ownership.test.ts +++ b/packages/plugins/plugin-sharing/src/translations/bundle-ownership.test.ts @@ -20,6 +20,8 @@ const OWNED_OBJECTS = new Set([ 'sys_record_share', 'sys_sharing_rule', 'sys_share_link', + 'sys_queue', + 'sys_queue_member', ]); describe('objects translation bundle ownership (ADR-0029 D8)', () => { diff --git a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts index 6ecafec3d25..a8487c1c091 100644 --- a/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/en.objects.generated.ts @@ -290,5 +290,58 @@ export const enObjects: NonNullable = { label: "All" } } + }, + sys_queue: { + label: "Queue", + pluralLabel: "Queues", + description: "A named group of people that unassigned records can wait in until one of them takes the record.", + fields: { + name: { + label: "Name", + help: "Identifies the queue everywhere it is used — on records, in assignment rules, approvals and sharing rules. Lowercase letters, digits and underscores only." + }, + label: { + label: "Display Label" + }, + description: { + label: "Description" + } + }, + _views: { + all_queues: { + label: "All" + } + } + }, + sys_queue_member: { + label: "Queue Member", + pluralLabel: "Queue Members", + description: "Who belongs to a queue: a user, a team, a position or a business unit.", + fields: { + queue: { + label: "Queue", + help: "The name of the queue this member belongs to." + }, + recipient_type: { + label: "Member Type", + help: "What kind of member this is. A team, position or business unit makes everyone in it a member. \"Business unit and subordinates\" also covers every unit below the one you pick.", + options: { + user: "User", + team: "Team", + position: "Position", + business_unit: "Business unit", + unit_and_subordinates: "Business unit and subordinates" + } + }, + recipient_id: { + label: "Member", + help: "The specific user, team, position or business unit." + } + }, + _views: { + by_queue: { + label: "By Queue" + } + } } }; diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts index 1e28a7da3cf..264ebe62ac6 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.objects.generated.ts @@ -290,5 +290,58 @@ export const esESObjects: NonNullable = { label: "Todos" } } + }, + sys_queue: { + label: "Queue", + pluralLabel: "Queues", + description: "A named group of people that unassigned records can wait in until one of them takes the record.", + fields: { + name: { + label: "Name", + help: "Identifies the queue everywhere it is used — on records, in assignment rules, approvals and sharing rules. Lowercase letters, digits and underscores only." + }, + label: { + label: "Display Label" + }, + description: { + label: "Description" + } + }, + _views: { + all_queues: { + label: "All" + } + } + }, + sys_queue_member: { + label: "Queue Member", + pluralLabel: "Queue Members", + description: "Who belongs to a queue: a user, a team, a position or a business unit.", + fields: { + queue: { + label: "Queue", + help: "The name of the queue this member belongs to." + }, + recipient_type: { + label: "Member Type", + help: "What kind of member this is. A team, position or business unit makes everyone in it a member. \"Business unit and subordinates\" also covers every unit below the one you pick.", + options: { + user: "Usuario", + team: "Equipo", + position: "Puesto", + business_unit: "Unidad de negocio", + unit_and_subordinates: "Unidad de negocio y subordinados" + } + }, + recipient_id: { + label: "Member", + help: "The specific user, team, position or business unit." + } + }, + _views: { + by_queue: { + label: "By Queue" + } + } } }; diff --git a/packages/plugins/plugin-sharing/src/translations/es-ES.source-hashes.generated.ts b/packages/plugins/plugin-sharing/src/translations/es-ES.source-hashes.generated.ts index 6d4631f8463..711f10b74e6 100644 --- a/packages/plugins/plugin-sharing/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/es-ES.source-hashes.generated.ts @@ -18,5 +18,23 @@ */ export const esESGeneratedSourceHashes: Readonly> = { + "objects.sys_queue._views.all_queues.label": "6d6f653f8264ab17", + "objects.sys_queue.description": "46f6f4c7eea606fa", + "objects.sys_queue.fields.description.label": "779a4342c8eb6707", + "objects.sys_queue.fields.label.label": "c2da0a529641d467", + "objects.sys_queue.fields.name.help": "dcbe38b864a5e775", + "objects.sys_queue.fields.name.label": "5274417ff1ccaf01", + "objects.sys_queue.label": "87d4b808a85ea103", + "objects.sys_queue.pluralLabel": "226d23b8bd681e98", + "objects.sys_queue_member._views.by_queue.label": "e2fc3d197e62194f", + "objects.sys_queue_member.description": "0a13fcf2729233d6", + "objects.sys_queue_member.fields.queue.help": "2fe880a3e213f85f", + "objects.sys_queue_member.fields.queue.label": "87d4b808a85ea103", + "objects.sys_queue_member.fields.recipient_id.help": "c7c7b9abcdbd2862", + "objects.sys_queue_member.fields.recipient_id.label": "28df6ed83bf1854a", + "objects.sys_queue_member.fields.recipient_type.help": "2ae5686284ebe563", + "objects.sys_queue_member.fields.recipient_type.label": "bdd6823b52227962", + "objects.sys_queue_member.label": "671a45ea261c0e92", + "objects.sys_queue_member.pluralLabel": "46715e86f5231603", "objects.sys_share_link.fields.token.label": "6e8de3393d2cee54", }; diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts index 71ba391cf05..47d14b78cb0 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.objects.generated.ts @@ -290,5 +290,58 @@ export const jaJPObjects: NonNullable = { label: "すべて" } } + }, + sys_queue: { + label: "Queue", + pluralLabel: "Queues", + description: "A named group of people that unassigned records can wait in until one of them takes the record.", + fields: { + name: { + label: "Name", + help: "Identifies the queue everywhere it is used — on records, in assignment rules, approvals and sharing rules. Lowercase letters, digits and underscores only." + }, + label: { + label: "Display Label" + }, + description: { + label: "Description" + } + }, + _views: { + all_queues: { + label: "All" + } + } + }, + sys_queue_member: { + label: "Queue Member", + pluralLabel: "Queue Members", + description: "Who belongs to a queue: a user, a team, a position or a business unit.", + fields: { + queue: { + label: "Queue", + help: "The name of the queue this member belongs to." + }, + recipient_type: { + label: "Member Type", + help: "What kind of member this is. A team, position or business unit makes everyone in it a member. \"Business unit and subordinates\" also covers every unit below the one you pick.", + options: { + user: "ユーザー", + team: "チーム", + position: "ポジション", + business_unit: "ビジネスユニット", + unit_and_subordinates: "ビジネスユニットと下位階層" + } + }, + recipient_id: { + label: "Member", + help: "The specific user, team, position or business unit." + } + }, + _views: { + by_queue: { + label: "By Queue" + } + } } }; diff --git a/packages/plugins/plugin-sharing/src/translations/ja-JP.source-hashes.generated.ts b/packages/plugins/plugin-sharing/src/translations/ja-JP.source-hashes.generated.ts index 2b36a981962..a5bac96fb08 100644 --- a/packages/plugins/plugin-sharing/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/ja-JP.source-hashes.generated.ts @@ -18,4 +18,22 @@ */ export const jaJPGeneratedSourceHashes: Readonly> = { + "objects.sys_queue._views.all_queues.label": "6d6f653f8264ab17", + "objects.sys_queue.description": "46f6f4c7eea606fa", + "objects.sys_queue.fields.description.label": "779a4342c8eb6707", + "objects.sys_queue.fields.label.label": "c2da0a529641d467", + "objects.sys_queue.fields.name.help": "dcbe38b864a5e775", + "objects.sys_queue.fields.name.label": "5274417ff1ccaf01", + "objects.sys_queue.label": "87d4b808a85ea103", + "objects.sys_queue.pluralLabel": "226d23b8bd681e98", + "objects.sys_queue_member._views.by_queue.label": "e2fc3d197e62194f", + "objects.sys_queue_member.description": "0a13fcf2729233d6", + "objects.sys_queue_member.fields.queue.help": "2fe880a3e213f85f", + "objects.sys_queue_member.fields.queue.label": "87d4b808a85ea103", + "objects.sys_queue_member.fields.recipient_id.help": "c7c7b9abcdbd2862", + "objects.sys_queue_member.fields.recipient_id.label": "28df6ed83bf1854a", + "objects.sys_queue_member.fields.recipient_type.help": "2ae5686284ebe563", + "objects.sys_queue_member.fields.recipient_type.label": "bdd6823b52227962", + "objects.sys_queue_member.label": "671a45ea261c0e92", + "objects.sys_queue_member.pluralLabel": "46715e86f5231603", }; diff --git a/packages/plugins/plugin-sharing/src/translations/queue-pages-zh-cn.test.ts b/packages/plugins/plugin-sharing/src/translations/queue-pages-zh-cn.test.ts new file mode 100644 index 00000000000..29d183af998 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/translations/queue-pages-zh-cn.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The 队列 and 队列成员 pages read in Chinese on a zh-CN console. + * + * Setup's Queues and Queue Members pages draw their title, the 新建 button + * (「新建」 + the object label), the list columns and every form field's + * label and help from the served translation of `sys_queue` and + * `sys_queue_member`. This package's bundle carried its three sharing objects + * only, so both pages fell back to the English object metadata and the button + * read 「新建Queue」. This pins what the bundle serves for both objects. + */ + +import { describe, expect, it } from 'vitest'; +import { SharingTranslations } from './index.js'; +import { SysQueue, SysQueueMember } from '../objects/index.js'; + +type FieldCopy = { label?: string; help?: string; options?: Record }; +type ObjectCopy = { label?: string; pluralLabel?: string; description?: string; fields?: Record }; + +const HAN = /[一-鿿]/; + +describe('queue pages: the served zh-CN bundle translates sys_queue and sys_queue_member', () => { + const zhCN = (SharingTranslations['zh-CN'] as { objects: Record }).objects; + + it('names the two objects 队列 and 队列成员', () => { + expect(zhCN.sys_queue?.label).toBe('队列'); + expect(zhCN.sys_queue?.pluralLabel).toBe('队列'); + expect(zhCN.sys_queue_member?.label).toBe('队列成员'); + expect(zhCN.sys_queue_member?.pluralLabel).toBe('队列成员'); + }); + + for (const schema of [SysQueue, SysQueueMember]) { + it(`${schema.name}: the page description and every field label and help are Chinese`, () => { + const copy = zhCN[schema.name]; + expect(copy?.description ?? '').toMatch(HAN); + for (const [field, def] of Object.entries(schema.fields as Record)) { + expect(copy?.fields?.[field]?.label ?? '', `${schema.name}.${field}.label`).toMatch(HAN); + if (def.description) { + expect(copy?.fields?.[field]?.help ?? '', `${schema.name}.${field}.help`).toMatch(HAN); + } + } + }); + } + + it('sys_queue_member: every member type option is Chinese', () => { + const options = zhCN.sys_queue_member?.fields?.recipient_type?.options ?? {}; + expect(Object.keys(options).sort()).toEqual( + ['business_unit', 'position', 'team', 'unit_and_subordinates', 'user'], + ); + for (const [value, label] of Object.entries(options)) expect(label, value).toMatch(HAN); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts index 95724e90670..0fcc0cc3a0f 100644 --- a/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-sharing/src/translations/zh-CN.objects.generated.ts @@ -290,5 +290,58 @@ export const zhCNObjects: NonNullable = { label: "全部" } } + }, + sys_queue: { + label: "队列", + pluralLabel: "队列", + description: "一组有名字的成员。尚未分配负责人的记录可以放进队列,等其中一人认领。", + fields: { + name: { + label: "名称", + help: "在所有用到该队列的地方标识它:记录上、分配规则、审批和共享规则中。只能使用小写字母、数字和下划线。" + }, + label: { + label: "显示名称" + }, + description: { + label: "描述" + } + }, + _views: { + all_queues: { + label: "全部" + } + } + }, + sys_queue_member: { + label: "队列成员", + pluralLabel: "队列成员", + description: "谁属于某个队列:一位用户、一个团队、一个岗位或一个业务单元。", + fields: { + queue: { + label: "队列", + help: "该成员所属队列的名称。" + }, + recipient_type: { + label: "成员类型", + help: "这是哪一类成员。选择团队、岗位或业务单元时,其中的每个人都是成员;“业务单元及下级”还包括所选单元下的每个单元。", + options: { + user: "用户", + team: "团队", + position: "岗位", + business_unit: "业务单元", + unit_and_subordinates: "业务单元及下级" + } + }, + recipient_id: { + label: "成员", + help: "具体的用户、团队、岗位或业务单元。" + } + }, + _views: { + by_queue: { + label: "按队列" + } + } } }; diff --git a/packages/services/service-settings/src/settings-routes.test.ts b/packages/services/service-settings/src/settings-routes.test.ts index f0db599281f..7b10445497b 100644 --- a/packages/services/service-settings/src/settings-routes.test.ts +++ b/packages/services/service-settings/src/settings-routes.test.ts @@ -151,6 +151,36 @@ describe('settings-routes', () => { expect(state.body.data.ok).toBe(true); }); + // The answer an action composes (the "Send test email" toast) is shown to + // the caller, so the handler is told the caller's display language: the top + // Accept-Language tag, else `?locale`, the same reading the page's + // live-status statement gets. It authorizes nothing and stays out of `ctx`. + it('POST action hands the handler the caller\'s display locale, outside ctx', async () => { + const http = new MockHttp(); + const svc = new SettingsService({ env: {} }); + svc.registerManifest(brandingSettingsManifest); + const seen: Array<{ locale?: string; ctxKeys: string[] }> = []; + svc.registerAction('branding', 'ping', ({ locale, ctx }) => { + seen.push({ locale, ctxKeys: Object.keys(ctx) }); + return { ok: true, message: 'pong' }; + }); + registerSettingsRoutes(http, svc, { contextFromRequest: adminProvider }); + + const h = http.routes.get('POST /api/settings/:namespace/:actionId')!; + const zh = makeReqRes({ + params: { namespace: 'branding', actionId: 'ping' }, + body: null, + headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' }, + }); + await h(zh.req, zh.res); + const none = makeReqRes({ params: { namespace: 'branding', actionId: 'ping' }, body: null }); + await h(none.req, none.res); + + expect(zh.state.status).toBe(200); + expect(seen.map((s) => s.locale)).toEqual(['zh-CN', undefined]); + expect(seen[0].ctxKeys).not.toContain('locale'); + }); + // ── [Finding-1] the DEFAULT (no verified provider) is SECURE ────────────── // Header-trusted identity is gone; an unauthenticated request can neither // enumerate protected namespaces nor write them. Since the family took the diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index 676c060ec62..0c6ef576377 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -204,7 +204,7 @@ function refuseHeldSession(res: IHttpResponse, ctx: SettingsContext | undefined) /** * The caller's display language, read the way REST's metadata door reads it: * the top `Accept-Language` tag, else `?locale`. It only picks the language of - * prose a write predicate or a live-status provider composes + * prose a write predicate, a live-status provider or an action handler composes * (`SettingsDisplayOptions`); it authorizes nothing, and it never enters the * caller's context. */ @@ -418,7 +418,7 @@ export function registerSettingsRoutes( mountFamilyRoute('post', `${base}/:namespace/:actionId`, { async serve(req, res, ctx) { const { namespace, actionId } = req.params; - const result = await service.runAction(namespace, actionId, req.body, ctx); + const result = await service.runAction(namespace, actionId, req.body, ctx, { locale: requestLocaleOf(req) }); // The 200/400 split is PRE-EXISTING and preserved verbatim: an action // that ran and reported `ok: false` is still answered 400. Whether a // reported (as opposed to crashed) failure ought to be a 200 carrying the diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 8eb5aa98a40..b602b92b346 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -3266,12 +3266,17 @@ export class SettingsService { return keys.length; } - /** Invoke a declared action (test connection, rotate, …). */ + /** + * Invoke a declared action (test connection, rotate, …). `display.locale` is + * handed to the handler so the answer it composes is written in the caller's + * language; it authorizes nothing. + */ async runAction( namespace: string, actionId: string, payload: unknown, ctx: SettingsContext = {}, + display: SettingsDisplayOptions = {}, ): Promise { const reg = this.registry.get(namespace); if (!reg) throw new UnknownNamespaceError(namespace); @@ -3306,7 +3311,10 @@ export class SettingsService { values[key] = (await this.get(namespace, key, ctx)).value; } try { - return await handler({ namespace, actionId, values, payload, ctx }); + return await handler({ + namespace, actionId, values, payload, ctx, + ...(display.locale !== undefined ? { locale: display.locale } : {}), + }); } catch (err: any) { return { ok: false, diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 6bc2048c577..8f9d7c08f72 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -91,10 +91,10 @@ export interface SettingsContext { export interface SettingsDisplayOptions { /** * The caller's display language (BCP-47), for prose a - * {@link SettingsWritePredicate} refusal or a {@link SettingsLiveStatusProvider} - * statement composes. The settings routes pass it from the request - * (`Accept-Language`, else `?locale`). Absent, such prose is written in - * English. + * {@link SettingsWritePredicate} refusal, a {@link SettingsLiveStatusProvider} + * statement or a {@link SettingsActionHandler} answer composes. The settings + * routes pass it from the request (`Accept-Language`, else `?locale`). + * Absent, such prose is written in English. */ locale?: string; } @@ -356,6 +356,11 @@ export type SettingsActionHandler = (input: { values: Record; payload?: unknown; ctx: SettingsContext; + /** + * The language the answer's `message` is written in — the caller sees it as + * the action's toast. {@link SettingsDisplayOptions.locale}. + */ + locale?: string; }) => Promise | SettingsActionResult; /**