Skip to content

Commit d284ff3

Browse files
fix(plugin-auth,plugin-email)!: the invitation mail warns once on an untranslated role label and states when it expires (#26072)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGEtunNCqKwXhkVr8xm2ygt)_ Part of #25802 (the in-repo half; the card stays open for the cloud control plane's i18n composition, routed to that lane) Clause-②: no (narrowing: the built-in `auth.invitation` rows declare a new required variable, `expiresAt`, so a `sendTemplate` of `auth.invitation` that names no `expiresAt` is now refused with `MISSING_VARIABLES` where it used to be sent) Metadata-protocol: none (a built-in template row's declared variables, read by the email service at send time; no spec key, authorable spelling or stored shape moves) ## What changes Two fixes to the `auth.invitation` mail. Both are named on the card (the second is from its follow-up comment 6398750019). 1. **The role label: a warn when it stays untranslated.** `invitationRoleLabel` (`plugin-auth/src/invitation-role-label.ts`) now logs one `warn` per send when the mail's locale is not English and a role token's label is still its authored (English) text. The warn names the locale, the tokens and what to check: `[AuthManager] invitation mail: no 'zh-CN' label was found for the membership role(s) 'member'; the mail names the authored (English) label instead. …`. Before this, only a read that threw was reported. The read that answers but holds no zh-CN entry, which is the reported shape (`以 Member 的身份`, no log line), was silent. Each degradation gets one warn: a thrown read keeps its own warn and gets no second one, and a token outside the option list keeps its existing warn and is not counted again. An en-US or en-GB mail reads the authored label as its own text and logs nothing. The mail is always sent. 2. **The expiry.** The sender (`auth-manager-organization-plugin.ts`) passes `invitation.expiresAt` as a new **required** template variable, `expiresAt`, as an ISO-8601 instant. The engine has no conditionals, so every body states the expiry, and a send without it is refused (`MISSING_VARIABLES: expiresAt`) instead of being sent with a dangling "expires on ." sentence. If the value does not read as a date, the sender leaves it out and logs one `warn` that names the invitation id and says the mail will be refused, so the invitation can be resent. Each of the four built-in rows (en-US, zh-CN, ja-JP, es-ES), in both the HTML and the text body, ends with one sentence that renders it as `{{ expiresAt | datetime:long }}`. This uses the date formatter the template layer already has (ADR-0053 Phase 2). The date reads in the row's locale and in the reference timezone the send names. The `long` style prints the zone name, so a reader never has to guess the zone. With UTC the sentences render as 「此邀请将于 2031年3月4日 UTC 05:06:07 过期。」, "This invitation expires on March 4, 2031 at 5:06:07 AM UTC.", 「この招待の有効期限は 2031年3月4日 5:06:07 UTC です。」 and "Esta invitación caduca el 4 de marzo de 2031, 5:06:07 UTC.". ## BREAKING This is for code that sends `auth.invitation` itself. The built-in organization-invitation sender already passes the value, and it is the only sender in this repository (`git grep` at `origin/main`). - **Before.** A send naming `inviter`, `organization`, `role`, `acceptUrl` and `appName` was delivered. - **Now.** The same send is refused with `MISSING_VARIABLES: expiresAt`. - **Fix (one line).** Add the invitation's expiry instant to the send's `data` as `expiresAt`. The changeset carries `adr-0087: not-required (no-migration-prescription)`, and `check-adr-0087-registration` accepts it. ## Measurement (the order's first step, at `origin/main` bbf7061) - **objectstack ships the zh-CN labels:** `platform-objects/src/apps/translations/zh-CN.objects.generated.ts` has `sys_member.fields.role.options` = 所有者 / 管理员 / 受托管理员 / 成员 / 外部成员. - **objectstack reads them through the file-based adapter.** A throwaway probe (not committed) booted a `LiteKernel` with the real `I18nServicePlugin` (the `registered file-based i18n adapter` the cloud log names) and the real `PlatformObjectsPlugin`, then called `invitationRoleLabel('member', 'zh-CN', { getI18n })`: | i18n service configuration | `getLocales()` | label | warns on main | |:---|:---|:---|:---| | default | en, zh-CN, ja-JP, es-ES | 成员 | 0 | | `defaultLocale: 'en', fallbackLocale: 'en'` | en, zh-CN, ja-JP, es-ES | 成员 | 0 | | same + `supportedLocales: ['en']` | en | **Member** | **0** | | same + `supportedLocales: ['en', 'zh']` | en, zh | **Member** | **0** | | same + `supportedLocales: ['en', 'zh-CN']` | en, zh-CN | 成员 | 0 | | `defaultLocale: 'zh-CN', fallbackLocale: 'en'` | en, zh-CN, ja-JP, es-ES | 成员 | 0 | The card's symptom (`Member` and no log line) reproduces only when the service's reported locales do not name `zh-CN`. `translateMetaList` builds its bundle from `getLocales()`. So the label read here is correct. The cloud control plane's i18n composition is the likely cause: a declared `supportedLocales`, or bundle registration. I could not read that half from this repository, and I have reported it to the seat. This PR adds the warn, which names exactly that check. ## Tests Red first. The new and updated pins, run against the BASE implementation (the three source files restored from bbf7061, the tests kept): plugin-auth `invitation-role-label.test.ts` had **5 failed / 19 passed**, and plugin-email (`auth-templates-locales`, `builtin-auth-render-identity`, `plain-text-faces`) had **11 failed / 90 passed**. The source was then restored from HEAD and proven by `git diff HEAD` empty plus a blob-hash match on all three files. Green, at head ee295b9 (after merging `origin/main`): - `pnpm --filter @objectstack/plugin-auth --filter @objectstack/plugin-email run test`: plugin-auth **197/197 files, 3753 passed, 10 skipped**; plugin-email **38/38 files, 699 passed**. The lock VERDICT was command-exit 0. - `run typecheck` on both packages (tsc, examples config, `check:test-typecheck`): exit 0. - `pnpm exec eslint --no-inline-config` over every `.ts` file this PR touches: exit 0. - **Dogfood battery** (owed by the `(narrowing)` arm): `pnpm --filter @objectstack/dogfood test` at ee295b9, after its `^...` closure build. The lock VERDICT was command-exit 0. **Test Files 296 passed, 3 skipped (299); Tests 2523 passed, 18 skipped (2541)**. 0 failures. What the pins hold: - **The role label.** zh-CN with the platform bundles gives 成员 and no warn. A service whose locales omit zh-CN gives `Member` and one warn, whose meta names the role, the locale and that a service was present. No service at all on a zh-CN mail gives one warn: the existing pin asserted silence, and it now asserts the one warn. A list warns once, naming every English token. An out-of-list token is not double-counted. A thrown read produces exactly one invitation warn in total. en-US and en-GB mails produce no warn. - **The expiry.** A `Date` and a string both reach the template as the ISO instant. A missing or unreadable expiry is left out of the data, with one warn naming `inv1` and the refusal. A readable expiry logs no expiry warn. In every locale, a send without `expiresAt` is refused with `MISSING_VARIABLES: expiresAt` and nothing reaches the transport. Per locale, both faces carry the expiry sentence for a UTC send. A send naming `Asia/Shanghai` moves the wall-clock and names `GMT+8`. A zh-CN mail carries no English expiry text. The existing every-fact-in-both-faces pin now covers `expiresAt`. - **The byte-identity pin** (`builtin-auth-render-identity.test.ts`) now renders with `timezone: 'UTC'`, so its bytes never follow the machine's zone. Only the four `auth.invitation` rows' html/text digests moved. Every other template's 28 rows kept their digests under the UTC render. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 69 families at ee295b9 (the same set as the first round). All were run there, with exit codes captured before any pipe. `--ran` reconciles: **69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN**. - NOT MEASURED: `pnpm check:dual-build-cjs-loads` (exit 3, PREREQUISITE NOT MET: it needs a full-repo build). CI runs it. - The changeset gates, read on the committed narrowing changeset: `check-adr-0087-registration --base origin/main` exit 0 (it accepts the `no-migration-prescription` exemption), `check-changeset-no-major` exit 0 and `check-empty-changeset` exit 0. - CI families: see this PR's check-runs. Check Changeset reads this body's `Clause-②` line. ## Acceptance notes - **The same dangle in the TTL templates (follow-up observation, not this PR).** `auth.password_reset`, `auth.magic_link` and `auth.two_factor_otp` declare `expiresInMinutes` `required: false` and print "expires in {{expiresInMinutes}} minutes" unconditionally. A send without the value renders a dangling sentence. `auth.account_invitation` already declares its own `required: true`. - **No timezone is passed.** The invitation send passes no reference timezone, like every other auth send today. So the wall-clock is the server's zone, named in the sentence by the `long` style. Passing the invitee's or the organization's timezone would be a separate change to the locale ladder, which this card does not ask for. - **The two i18n providers handle a base-language locale differently.** This was observed while measuring and is not filed. With `supportedLocales: ['en', 'zh']`, `FileI18nAdapter.getTranslations('zh')` answers `{}`: it matches exactly, and the bundles are keyed `zh-CN`. The kernel's in-memory provider resolves `zh` to the zh-CN bundle. So the same declaration translates on one provider and not on the other. The measurement is a composed-kernel probe only, with no public-door reading, so per the report contract it is a note here and not a card. Owner: none. - `#24154` stays closed. Its ruling (the role in a Chinese invitation reads 「成员」) is what the label read already delivers, and this PR makes the residual miss loud. --- _Generated by [Claude Code](https://claude.ai/code/session_011Ed1XCLEcJnL1JmrzvGgmW)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6d15aca commit d284ff3

8 files changed

Lines changed: 389 additions & 20 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/plugin-email': patch
4+
---
5+
6+
fix(plugin-auth,plugin-email)!: the invitation mail says when its role label stayed English, and states when the invitation expires
7+
8+
**The role label.** The organization invitation mail names the invitee's role by its `sys_member.role` option label in the mail's locale (`成员`, `メンバー`, `Miembro`). When the i18n service answered without a translation for that locale, the mail named the English label (`Member`) inside a Chinese, Japanese or Spanish sentence, and logged nothing. Only a read that threw was reported. That happens when there is no i18n service, when the service holds no bundle for the locale, or when its reported locales (`i18n.supportedLocales`, when declared) do not name the locale as the bundles spell it. Now one `warn` is logged for the send: `[AuthManager] invitation mail: no 'zh-CN' label was found for the membership role(s) 'member'; …`. It names the locale, the roles and what to check. The mail is still sent as before. A mail in English, or a read that already warned because it threw, logs nothing extra.
9+
10+
**The expiry.** The `auth.invitation` mail never said when its link stops working (better-auth's window, 48 hours by default), so an invitation opened late failed at the accept page with no warning. The sender now passes `invitation.expiresAt` as a new template variable, `expiresAt`, as an ISO-8601 instant. Each of the four built-in bodies, HTML and text, ends with one sentence that renders it with the `datetime:long` formatter. The date reads in the mail's locale and the send's reference timezone, with the zone named: 「此邀请将于 2031年3月4日 UTC 05:06:07 过期。」 / "This invitation expires on March 4, 2031 at 5:06:07 AM UTC.". The template engine has no conditionals, so every body states the expiry, and the built-in rows declare `expiresAt` **required**. A send without it is refused (`MISSING_VARIABLES: expiresAt`) and is never sent with a dangling sentence. The built-in sender always has better-auth's expiry. If the value does not read as a date, the sender leaves it out and logs one `warn` naming the invitation id and saying the mail will be refused, so the invitation can be resent. A tenant or app override of `auth.invitation` keeps its own bodies and variables, and can use `{{ expiresAt | datetime:long }}`.
11+
12+
Clause-②: no (narrowing: the built-in `auth.invitation` rows declare a new required variable, `expiresAt`, so a `sendTemplate` of `auth.invitation` that names no `expiresAt` is now refused with `MISSING_VARIABLES` where it used to be sent)
13+
14+
Metadata-protocol: none (a built-in template row's declared variables, read by the email service at send time; no spec key, authorable spelling or stored shape moves)
15+
16+
**BREAKING** for code that sends `auth.invitation` itself (the built-in organization-invitation sender already passes the value).
17+
18+
- **Before.** `auth.invitation` declared no expiry, and a send naming `inviter`, `organization`, `role`, `acceptUrl` and `appName` was delivered.
19+
- **Now.** The same send is refused with `MISSING_VARIABLES: expiresAt`.
20+
- **Fix (one line).** Add the invitation's expiry instant to the send's `data` as `expiresAt`.
21+
22+
<!-- adr-0087: not-required (no-migration-prescription) a send-time refusal of an `auth.invitation` mail that names no `expiresAt`: no authorable key, spelling or stored shape moves, the email-template schema is unchanged, and no stored row is read, rewritten or converted. The repair is a value only the sender holds (the invitation's expiry), which no ledger entry can derive. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers this verdict (not already-registered); and the change is a template row's declaration, not a TS interface (not runtime-interface-only). -->

‎packages/plugins/plugin-auth/src/auth-manager-organization-plugin.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,16 @@ export async function buildOrganizationPlugin(this: OrganizationPluginHost) {
609609
warn: (msg, meta) =>
610610
this.config.logger ? this.config.logger.warn(msg, meta) : console.warn(msg, meta),
611611
});
612+
const expiry = invitationExpiryArg(invitation.expiresAt);
613+
if (expiry.expiresAt === undefined) {
614+
const msg =
615+
`[AuthManager] invitation mail: invitation '${invitation.id}' carries no readable expiry, ` +
616+
'so the auth.invitation mail (which states when it expires) will be refused and not sent; ' +
617+
'resend the invitation once its expiry is set';
618+
const meta = { invitationId: invitation.id, expiresAt: String(invitation.expiresAt) };
619+
if (this.config.logger) this.config.logger.warn(msg, meta);
620+
else console.warn(msg, meta);
621+
}
612622
try {
613623
await emailService.sendTemplate({
614624
template: 'auth.invitation',
@@ -623,6 +633,14 @@ export async function buildOrganizationPlugin(this: OrganizationPluginHost) {
623633
role: roleLabel,
624634
acceptUrl,
625635
appName: this.getAppName(),
636+
// When the link stops working (better-auth's window, 48 h by
637+
// default): every body says so, so an invitation opened late is
638+
// not a surprise at the accept page. Handed over as an ISO-8601
639+
// instant; the template's `datetime` formatter renders it in the
640+
// mail's locale. The template declares it REQUIRED, so an
641+
// unreadable value is never invented: it is left out, said so
642+
// once below, and the send is refused (`MISSING_VARIABLES`).
643+
...expiry,
626644
},
627645
relatedObject: 'sys_invitation',
628646
relatedId: invitation.id,
@@ -681,3 +699,17 @@ export async function buildOrganizationPlugin(this: OrganizationPluginHost) {
681699
}
682700
return organizationPlugin;
683701
}
702+
703+
/**
704+
* The `expiresAt` fragment of the invitation mail's data: the invitation's
705+
* expiry as an ISO-8601 instant, or nothing when the value does not read as a
706+
* date. better-auth hands the callback a `Date`; a string or epoch number is
707+
* read the same way. A value that is not a real instant is left out rather
708+
* than rendered as `Invalid Date`; the template declares the variable
709+
* required, so that send is refused rather than sent with a dangling sentence.
710+
*/
711+
function invitationExpiryArg(expiresAt: unknown): { expiresAt?: string } {
712+
if (!(expiresAt instanceof Date) && typeof expiresAt !== 'string' && typeof expiresAt !== 'number') return {};
713+
const instant = new Date(expiresAt);
714+
return Number.isNaN(instant.getTime()) ? {} : { expiresAt: instant.toISOString() };
715+
}

‎packages/plugins/plugin-auth/src/invitation-role-label.test.ts‎

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ async function invite(opts: {
6767
getI18n?: () => unknown;
6868
/** The INVITER's browser language — better-auth hands this callback its request. */
6969
header?: string;
70+
/**
71+
* better-auth's `invitation.expiresAt`; omitted = a real expiry (the row
72+
* always carries one), passed as `undefined` = the row carries none.
73+
*/
74+
expiresAt?: unknown;
7075
}) {
7176
const { betterAuth } = await import('better-auth');
7277
let captured: any;
@@ -100,7 +105,16 @@ async function invite(opts: {
100105
await org._opts.sendInvitationEmail(
101106
{
102107
email: 'invitee@example.com',
103-
invitation: { id: 'inv1', organizationId: 'o1', role: opts.role },
108+
invitation: {
109+
id: 'inv1',
110+
organizationId: 'o1',
111+
role: opts.role,
112+
...('expiresAt' in opts
113+
? opts.expiresAt !== undefined
114+
? { expiresAt: opts.expiresAt }
115+
: {}
116+
: { expiresAt: new Date('2031-03-04T05:06:07.000Z') }),
117+
},
104118
organization: { name: 'Northwind' },
105119
inviter: { user: { email: 'dana@example.com', name: 'Dana' } },
106120
},
@@ -198,7 +212,9 @@ describe('#24154 — a value with no label, or no i18n service, falls back as st
198212
it('no i18n service names the authored label, never the stored value', async () => {
199213
const { input, warns } = await invite({ role: 'member', deployment: 'zh-CN' });
200214
expect(input.data.role).toBe('Member');
201-
expect(warns.filter((w) => w.msg.includes('invitation mail'))).toHaveLength(0);
215+
// #25802 — and says once that the zh-CN mail carries an English label.
216+
expect(warns.filter((w) => w.msg.includes('invitation mail'))).toHaveLength(1);
217+
expect(untranslatedWarns(warns)).toHaveLength(1);
202218
});
203219

204220
it('a throwing i18n service still delivers the mail, with the authored label and one warn', async () => {
@@ -212,13 +228,118 @@ describe('#24154 — a value with no label, or no i18n service, falls back as st
212228
expect(input.data.role).toBe('Member');
213229
const degraded = warns.filter((w) => w.msg.includes('could not be read from the i18n service'));
214230
expect(degraded).toHaveLength(1);
231+
// One warn for one degradation: the thrown read already said why the
232+
// label is English, so the untranslated-label warn does not repeat it.
233+
expect(warns.filter((w) => w.msg.includes('invitation mail'))).toHaveLength(1);
215234
});
216235

217236
it('an empty value stays empty — there is no role to name', async () => {
218237
await expect(invitationRoleLabel('', 'zh-CN', { getI18n: platformI18n })).resolves.toBe('');
219238
});
220239
});
221240

241+
// ── #25802 — a label with no translation in the mail locale is said once ──
242+
243+
/** The warns that name an untranslated role label. */
244+
function untranslatedWarns(warns: Array<{ msg: string; meta?: unknown }>) {
245+
return warns.filter((w) => w.msg.includes('label was found for the membership role'));
246+
}
247+
248+
/**
249+
* The platform bundles loaded, and the deployment's declared
250+
* `i18n.supportedLocales` set on the service — the read
251+
* `translateMetaList` takes its bundle from (`getLocales()`).
252+
*/
253+
function declaringI18n(supportedLocales: string[]) {
254+
const i18n = platformI18n();
255+
i18n.setSupportedLocales(supportedLocales);
256+
return i18n;
257+
}
258+
259+
describe('#25802 — a zh-CN mail whose role label stayed English says so, once', () => {
260+
it('the platform bundles reach the zh-CN mail: 成员, and no warn', async () => {
261+
const { input, warns } = await invite({ role: 'member', deployment: 'zh-CN', getI18n: platformI18n });
262+
expect(input.data.role).toBe('成员');
263+
expect(warns.filter((w) => w.msg.includes('invitation mail'))).toHaveLength(0);
264+
});
265+
266+
it('a service whose locales omit zh-CN names Member, with one warn naming the role and the locale', async () => {
267+
// The reported shape: the label read answers without throwing, holds no
268+
// zh-CN entry, and the authored English label lands in a Chinese sentence.
269+
const i18n = declaringI18n(['en']);
270+
const { input, warns } = await invite({ role: 'member', deployment: 'zh-CN', getI18n: () => i18n });
271+
expect(input.data.role).toBe('Member');
272+
const named = untranslatedWarns(warns);
273+
expect(named).toHaveLength(1);
274+
expect(named[0]!.msg).toContain("no 'zh-CN' label");
275+
expect(named[0]!.msg).toContain("'member'");
276+
expect(named[0]!.meta).toMatchObject({ role: 'member', locale: 'zh-CN', i18nService: true });
277+
});
278+
279+
it('a list warns once, naming every token that stayed English', async () => {
280+
const i18n = declaringI18n(['en']);
281+
const { input, warns } = await invite({ role: 'admin,member', deployment: 'ja-JP', getI18n: () => i18n });
282+
expect(input.data.role).toContain('Admin');
283+
expect(input.data.role).toContain('Member');
284+
const named = untranslatedWarns(warns);
285+
expect(named).toHaveLength(1);
286+
expect(named[0]!.msg).toContain("'admin', 'member'");
287+
});
288+
289+
it('a value outside the option list is not counted twice', async () => {
290+
const i18n = declaringI18n(['en']);
291+
const { warns } = await invite({ role: 'auditor', deployment: 'zh-CN', getI18n: () => i18n });
292+
expect(warns.filter((w) => w.msg.includes("'auditor'"))).toHaveLength(1);
293+
expect(untranslatedWarns(warns)).toHaveLength(0);
294+
});
295+
296+
it.each(['en-US', 'en-GB'])('an %s mail reads the authored label as its own text: no warn', async (locale) => {
297+
const { input, warns } = await invite({ role: 'member', deployment: locale });
298+
expect(input.data.role).toBe('Member');
299+
expect(warns.filter((w) => w.msg.includes('invitation mail'))).toHaveLength(0);
300+
});
301+
});
302+
303+
// ── #25802 — the mail states when the invitation expires ───────────────────
304+
305+
describe('#25802 — the sender hands the template the invitation expiry', () => {
306+
it('a Date expiry reaches the template as its ISO-8601 instant', async () => {
307+
const { input } = await invite({
308+
role: 'member',
309+
deployment: 'zh-CN',
310+
getI18n: platformI18n,
311+
expiresAt: new Date('2031-03-04T05:06:07.000Z'),
312+
});
313+
expect(input.data.expiresAt).toBe('2031-03-04T05:06:07.000Z');
314+
});
315+
316+
it('a string expiry is read the same way', async () => {
317+
const { input } = await invite({ role: 'member', deployment: 'en-US', expiresAt: '2031-03-04T05:06:07Z' });
318+
expect(input.data.expiresAt).toBe('2031-03-04T05:06:07.000Z');
319+
});
320+
321+
it.each([
322+
['no expiry', undefined],
323+
['an unreadable one', 'not-a-date'],
324+
])('%s is left out rather than invented, with one warn naming the invitation and the refusal', async (_label, expiresAt) => {
325+
// The template declares `expiresAt` required, so the real email service
326+
// refuses this send (`MISSING_VARIABLES`, pinned in plugin-email's
327+
// `auth-templates-locales.test.ts`); the warn makes the lost mail visible.
328+
const { input, warns } = await invite({ role: 'member', deployment: 'en-US', expiresAt });
329+
expect(Object.prototype.hasOwnProperty.call(input.data, 'expiresAt')).toBe(false);
330+
const named = warns.filter((w) => w.msg.includes('carries no readable expiry'));
331+
expect(named).toHaveLength(1);
332+
expect(named[0]!.msg).toContain("invitation 'inv1'");
333+
expect(named[0]!.msg).toContain('will be refused and not sent');
334+
expect(named[0]!.meta).toMatchObject({ invitationId: 'inv1' });
335+
});
336+
337+
it('a readable expiry logs no expiry warn', async () => {
338+
const { warns } = await invite({ role: 'member', deployment: 'en-US' });
339+
expect(warns.filter((w) => w.msg.includes('carries no readable expiry'))).toHaveLength(0);
340+
});
341+
});
342+
222343
// ── the composition: AuthPlugin hands the manager the kernel's i18n ────────
223344

224345
describe('#24154 — AuthPlugin wires the i18n accessor lazily', () => {

‎packages/plugins/plugin-auth/src/invitation-role-label.ts‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ import { EMAIL_TEMPLATE_FLOOR_LOCALE } from '@objectstack/spec/system';
3939
* dropped, never an empty string — and one `warn` names it. An i18n read that
4040
* throws degrades to the authored (English) labels with one `warn`; it never
4141
* blocks the invitation.
42+
*
43+
* ## A label with no translation in the mail's locale is said so once, too
44+
*
45+
* The read can also answer without throwing and still leave the mail in two
46+
* languages: no i18n service at all, a service holding no bundle for the
47+
* mail's locale, or one whose bundle has no entry for the option — each one
48+
* hands back the authored label (`Member`), or the English fallback-chain
49+
* entry that repeats it, inside a Chinese sentence. When the mail's locale is
50+
* not the authored labels' own language ({@link AUTHORED_LABEL_LANGUAGE}) and
51+
* a token's label is still its authored text, one `warn` names the tokens and
52+
* the locale, the same way the thrown read does. The mail still goes; the
53+
* deployment learns that its i18n service never reached this locale's labels.
4254
*/
4355
export async function invitationRoleLabel(
4456
role: string,
@@ -68,10 +80,12 @@ export async function invitationRoleLabel(
6880
if (typeof label === 'string' && label.length > 0) labels.set(String(option.value), label);
6981
}
7082
};
83+
let readFailed = false;
84+
let i18nService: unknown;
7185
try {
7286
const [translated] = (await translateMetaList(
7387
{
74-
resolveI18nService: async () => deps.getI18n?.(),
88+
resolveI18nService: async () => (i18nService = deps.getI18n?.()),
7589
// A packaged base only decides between a catalog and an OVERRIDDEN
7690
// object scalar; option labels never consult it.
7791
resolveProtocol: async () => undefined,
@@ -82,6 +96,7 @@ export async function invitationRoleLabel(
8296
)) as Array<{ fields?: { role?: { options?: unknown } } }>;
8397
collect(translated?.fields?.role?.options);
8498
} catch (err) {
99+
readFailed = true;
85100
deps.warn?.(
86101
'[AuthManager] invitation mail: the membership role labels could not be read from the i18n service; ' +
87102
'the mail names the authored (English) labels instead',
@@ -98,6 +113,23 @@ export async function invitationRoleLabel(
98113
{ role, locale: mailLocale },
99114
);
100115
}
116+
if (!readFailed && !isAuthoredLabelLanguage(mailLocale)) {
117+
const authoredLabels = new Map<string, string>();
118+
for (const option of (authored?.options ?? []) as Array<{ value?: unknown; label?: unknown }>) {
119+
if (typeof option?.label === 'string') authoredLabels.set(String(option.value), option.label);
120+
}
121+
const untranslated = tokens.filter((token) => labels.has(token) && labels.get(token) === authoredLabels.get(token));
122+
if (untranslated.length > 0) {
123+
deps.warn?.(
124+
`[AuthManager] invitation mail: no '${mailLocale}' label was found for the membership role(s) ` +
125+
`${untranslated.map((t) => `'${t}'`).join(', ')}; the mail names the authored (English) label instead. ` +
126+
'The i18n service served no translation of sys_member.role for this locale: check that it holds the ' +
127+
'platform translation bundles and that its locales (i18n.supportedLocales, when declared) name this ' +
128+
'locale as the bundles spell it (zh-CN, not zh)',
129+
{ role, locale: mailLocale, i18nService: i18nService !== undefined },
130+
);
131+
}
132+
}
101133
const named = tokens.map((token) => labels.get(token) ?? token);
102134
if (named.length === 1) return named[0]!;
103135
// `Intl.ListFormat` is ES2021; this package compiles against the ES2020 lib,
@@ -111,6 +143,19 @@ export async function invitationRoleLabel(
111143
return named.join(', ');
112144
}
113145

146+
/**
147+
* The language `sys_member.role`'s authored option labels are written in:
148+
* `platform-objects` authors its metadata in English, and its `en` bundle is
149+
* regenerated from that source. A mail in this language reads the authored
150+
* label as its own text, so only a mail in any OTHER language can be left
151+
* untranslated.
152+
*/
153+
const AUTHORED_LABEL_LANGUAGE = 'en';
154+
155+
function isAuthoredLabelLanguage(locale: string): boolean {
156+
return locale.trim().toLowerCase().split(/[-_]/)[0] === AUTHORED_LABEL_LANGUAGE;
157+
}
158+
114159
type IntlListFormatConstructor = new (
115160
locale: string,
116161
options: { type: 'conjunction' },

0 commit comments

Comments
 (0)