Skip to content

Commit e2c05d6

Browse files
os-zhuangclaude
andauthored
feat(auth/i18n): 手机短信文案国际化(模板可定制 + 内置中英,#2815) (#2826)
* feat(auth/i18n): localised, tenant-customisable phone SMS texts (#2815) - phone-sms-texts.ts: built-in en/zh bodies for auth.phone_otp / auth.phone_invite, {{hole}} interpolation (same semantics as the messaging renderer, kept dependency-free), locale chain (zh-CN -> zh -> en), best-effort sys_notification_template loader, and an insert-if-missing seeder (tenant edits never overwritten) - auth-manager: deliverPhoneOtp/sendPhoneInviteSms render through the template-then-builtin resolution; setDefaultSmsLocale() mirrors setAppName(); OTP wording is now purpose-neutral (one provider template covers sign-in and reset; the SMS reveals nothing about what the code unlocks) - auth-plugin: binds localization.locale (live re-bind on settings changes) and seeds the built-in template rows at kernel:ready when phone sign-in is enabled Template lookups are best-effort - an outage never blocks an OTP send. No-OTP-in-logs red line unchanged. Closes #2815 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LXUXU66dBaP3SSG4ZVtuH * docs(auth): SMS text customisation & localisation section (#2815) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013LXUXU66dBaP3SSG4ZVtuH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a823a95 commit e2c05d6

7 files changed

Lines changed: 493 additions & 8 deletions

File tree

.changeset/phone-sms-i18n.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/plugin-auth": minor
3+
---
4+
5+
feat(auth/i18n): localised, tenant-customisable phone SMS texts (#2815)
6+
7+
The OTP and invitation SMS bodies were hard-coded English. They now resolve
8+
in two layers: a `sys_notification_template` row for
9+
`(auth.phone_otp | auth.phone_invite, channel 'sms', locale)` — editable in
10+
Setup, seeded once with built-in en/zh rows, tenant edits never overwritten —
11+
falling back to the bundled bilingual texts. The locale follows the
12+
deployment default (`localization.locale` setting, live-rebound); per-user
13+
locale is deferred until `sys_user` grows a locale column. The OTP wording
14+
is purpose-neutral (one provider template covers sign-in and reset, and the
15+
SMS reveals nothing about what the code unlocks). Template lookups are
16+
best-effort — an outage never blocks an OTP send — and the no-OTP-in-logs
17+
red line is unchanged.

content/docs/permissions/authentication.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,18 @@ when SMS is deliverable: the created account receives a **credential-free
353353
invitation SMS** and the employee completes first sign-in via phone OTP, then
354354
sets their own password.
355355

356+
#### SMS text customisation & localisation
357+
358+
The OTP and invitation bodies are localised and tenant-customisable: a
359+
`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite,
360+
channel 'sms', locale)` wins — built-in English and Chinese rows are seeded
361+
once (never overwriting your edits) and can be changed under Setup →
362+
Notification Templates. The locale follows the deployment default
363+
(`localization.locale` setting) with a `zh-CN → zh → en` fallback chain;
364+
holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`,
365+
`{{baseUrl}}` (invitation). Template lookups are best-effort — an outage
366+
falls back to the built-in text and never blocks an OTP send.
367+
356368
---
357369

358370
## OAuth Providers

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,6 +1233,59 @@ describe('AuthManager', () => {
12331233
expect(sms.sent[0].body).toMatch(/verification code/);
12341234
expect(sms.sent[0].body).toContain('http://localhost:3000');
12351235
});
1236+
1237+
// #2815 — localised, tenant-customisable SMS bodies.
1238+
it('renders the built-in Chinese OTP text for a zh-CN deployment locale', async () => {
1239+
const { manager, opts } = await bootOtp();
1240+
const sms = fakeSms();
1241+
manager.setSmsService(sms.service);
1242+
manager.setDefaultSmsLocale('zh-CN');
1243+
1244+
await opts.sendOTP({ phoneNumber: PHONE, code: '123456' });
1245+
expect(sms.sent[0].body).toContain('验证码');
1246+
expect(sms.sent[0].body).toContain('123456');
1247+
expect(sms.sent[0].templateParams).toEqual({ code: '123456' });
1248+
1249+
await manager.sendPhoneInviteSms(PHONE);
1250+
expect(sms.sent[1].body).toContain('账号已开通');
1251+
expect(sms.sent[1].body).toContain('http://localhost:3000');
1252+
});
1253+
1254+
it('a tenant sys_notification_template row overrides the built-in text', async () => {
1255+
const { manager, opts } = await bootOtp({
1256+
dataEngine: {
1257+
async find(object: string, q: any) {
1258+
if (
1259+
object === 'sys_notification_template' &&
1260+
q?.where?.topic === 'auth.phone_otp' &&
1261+
q?.where?.channel === 'sms' &&
1262+
q?.where?.locale === 'zh-CN'
1263+
) {
1264+
return [{ body: '【定制】验证码 {{code}}({{minutes}} 分钟)' }];
1265+
}
1266+
return [];
1267+
},
1268+
},
1269+
});
1270+
const sms = fakeSms();
1271+
manager.setSmsService(sms.service);
1272+
manager.setDefaultSmsLocale('zh-CN');
1273+
1274+
await opts.sendOTP({ phoneNumber: PHONE, code: '654321' });
1275+
expect(sms.sent[0].body).toBe('【定制】验证码 654321(5 分钟)');
1276+
});
1277+
1278+
it('a broken template lookup falls back to the built-in text (never blocks the send)', async () => {
1279+
const { manager, opts } = await bootOtp({
1280+
dataEngine: { async find() { throw new Error('no such table'); } },
1281+
});
1282+
const sms = fakeSms();
1283+
manager.setSmsService(sms.service);
1284+
1285+
await opts.sendOTP({ phoneNumber: PHONE, code: '111222' });
1286+
expect(sms.sent[0].body).toContain('111222');
1287+
expect(sms.sent[0].body).toContain('verification code');
1288+
});
12361289
});
12371290

12381291
// #2766 V1.5 — placeholder addresses must never become real recipients.

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

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
1818
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
1919
import { isPlaceholderEmail } from './placeholder-email.js';
2020
import { OtpSendGuard } from './otp-send-guard.js';
21+
import {
22+
PHONE_SMS_TOPICS,
23+
builtinPhoneSmsBody,
24+
interpolatePhoneSms,
25+
loadPhoneSmsTemplateBody,
26+
} from './phone-sms-texts.js';
2127
import {
2228
AUTH_USER_CONFIG,
2329
AUTH_SESSION_CONFIG,
@@ -1615,15 +1621,15 @@ export class AuthManager {
16151621
// awaits this callback on /phone-number/send-otp), so the guard's
16161622
// TOO_MANY_REQUESTS becomes an honest 429 to the client.
16171623
sendOTP: async ({ phoneNumber: phone, code }) => {
1618-
await this.deliverPhoneOtp(phone, code, 'sign-in verification');
1624+
await this.deliverPhoneOtp(phone, code);
16191625
},
16201626
// Self-service password reset OTP (`/phone-number/request-password-
16211627
// reset`). better-auth invokes this via runInBackgroundOrAwait —
16221628
// errors are logged, the route still answers {status:true} — so a
16231629
// throw here can neither 500 the request nor leak whether the number
16241630
// is registered.
16251631
sendPasswordResetOTP: async ({ phoneNumber: phone, code }) => {
1626-
await this.deliverPhoneOtp(phone, code, 'password reset');
1632+
await this.deliverPhoneOtp(phone, code);
16271633
},
16281634
}));
16291635
}
@@ -2135,7 +2141,7 @@ export class AuthManager {
21352141
* a log line or an error message (the SmsService logs masked numbers
21362142
* and statuses, never bodies).
21372143
*/
2138-
private async deliverPhoneOtp(phone: string, code: string, purpose: string): Promise<void> {
2144+
private async deliverPhoneOtp(phone: string, code: string): Promise<void> {
21392145
const sms = this.getSmsService();
21402146
if (!sms || !this.isPhoneOtpDeliverable()) {
21412147
// Absent service, or a log-only transport in production (the code
@@ -2148,9 +2154,19 @@ export class AuthManager {
21482154
}
21492155
const otpCfg = this.config.phoneOtp ?? {};
21502156
const minutes = Math.max(1, Math.round((otpCfg.expiresIn ?? 300) / 60));
2157+
// #2815 — localised, tenant-customisable body: a sys_notification_template
2158+
// row for (auth.phone_otp, sms, deployment locale) wins; the built-in
2159+
// bilingual text is the fallback. Purpose-neutral wording on purpose —
2160+
// one provider template covers sign-in and reset, and the SMS reveals
2161+
// nothing about what the code unlocks.
2162+
const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.otp, {
2163+
code,
2164+
appName: this.getAppName(),
2165+
minutes,
2166+
});
21512167
const result = await sms.send({
21522168
to: phone,
2153-
body: `${code} is your ${this.getAppName()} ${purpose} code. It expires in ${minutes} minutes.`,
2169+
body,
21542170
// Template-only providers (Aliyun) substitute into a registered OTP
21552171
// template; `code` is the conventional variable name.
21562172
templateParams: { code },
@@ -2176,16 +2192,44 @@ export class AuthManager {
21762192
throw new Error('SMS_SERVICE_REQUIRED: no SMS service is configured for this deployment.');
21772193
}
21782194
const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, '');
2179-
const body =
2180-
`Your ${this.getAppName()} account is ready. Sign in with this phone number using a verification code` +
2181-
(baseUrl ? ` at ${baseUrl}` : '') +
2182-
', then set your password.';
2195+
// #2815 — localised, tenant-customisable body (see deliverPhoneOtp).
2196+
const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.invite, {
2197+
appName: this.getAppName(),
2198+
baseUrl,
2199+
});
21832200
const result = await sms.send({ to: phone, body, templateParams: { content: body } });
21842201
if (result.status === 'failed') {
21852202
throw new Error(`Invitation SMS failed: ${result.error ?? 'SMS delivery failed'}`);
21862203
}
21872204
}
21882205

2206+
/**
2207+
* #2815 — the deployment-default locale for auth SMS bodies, sourced from
2208+
* the live `localization.locale` setting. AuthPlugin pushes it on
2209+
* `kernel:ready` and on every settings change (same pattern as
2210+
* {@link setAppName}). Unset ⇒ the built-in English text.
2211+
*
2212+
* Per-user locale is not resolved yet — `sys_user` carries no locale
2213+
* column; when it grows one, resolution should prefer it (#2815).
2214+
*/
2215+
setDefaultSmsLocale(locale: string | undefined): void {
2216+
this.smsLocale = locale?.trim() || undefined;
2217+
}
2218+
private smsLocale?: string;
2219+
2220+
/**
2221+
* #2815 — resolve an auth SMS body: the tenant's
2222+
* `sys_notification_template` row for `(topic, 'sms', locale chain)` when
2223+
* one exists, else the built-in bilingual text. Template lookups are
2224+
* best-effort — an outage must never block an OTP send.
2225+
*/
2226+
private async renderPhoneSmsBody(topic: string, data: Record<string, unknown>): Promise<string> {
2227+
const template =
2228+
(await loadPhoneSmsTemplateBody(this.getDataEngine(), topic, this.smsLocale)) ??
2229+
builtinPhoneSmsBody(topic, this.smsLocale);
2230+
return interpolatePhoneSms(template, data);
2231+
}
2232+
21892233
/**
21902234
* Override the brand name surfaced in built-in auth emails (`{{appName}}`),
21912235
* sourced from the live `branding.workspace_name` setting.

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,10 +387,46 @@ export class AuthPlugin implements Plugin {
387387
});
388388
ctx.logger.info('Auth: bound appName to settings namespace=branding');
389389
}
390+
391+
// #2815 — bind the auth SMS locale to the deployment default
392+
// (`localization.locale`) so OTP/invitation texts render in the
393+
// workspace language. Live-rebinds on settings changes.
394+
const applySmsLocale = async () => {
395+
try {
396+
const resolved = await settings.get('localization', 'locale', {});
397+
const value = resolved?.value;
398+
this.authManager?.setDefaultSmsLocale(
399+
typeof value === 'string' ? value : undefined,
400+
);
401+
} catch (err: any) {
402+
ctx.logger.warn(
403+
'Auth: failed to apply localization.locale: ' + (err?.message ?? err),
404+
);
405+
}
406+
};
407+
await applySmsLocale();
408+
if (typeof settings.subscribe === 'function') {
409+
settings.subscribe('localization', () => {
410+
void applySmsLocale();
411+
});
412+
}
390413
}
391414
} catch {
392415
// settings service is optional — keep the configured appName.
393416
}
417+
418+
// #2815 — seed the built-in bilingual auth SMS templates into
419+
// sys_notification_template (insert-if-missing; tenant edits are
420+
// never overwritten). Only meaningful when phone sign-in is on;
421+
// the table may not exist yet on a fresh env (messaging provisions
422+
// it at kernel:ready), so failures log-and-continue.
423+
if (this.authManager.isPhoneNumberEnabled()) {
424+
const engine = this.authManager.getDataEngine();
425+
if (engine) {
426+
const { seedPhoneSmsTemplates } = await import('./phone-sms-texts.js');
427+
await seedPhoneSmsTemplates(engine, ctx.logger);
428+
}
429+
}
394430
}
395431

396432
let httpServer: IHttpServer | null = null;
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import {
5+
BUILTIN_PHONE_SMS_TEMPLATES,
6+
PHONE_SMS_TOPICS,
7+
builtinPhoneSmsBody,
8+
interpolatePhoneSms,
9+
loadPhoneSmsTemplateBody,
10+
phoneSmsLocaleChain,
11+
seedPhoneSmsTemplates,
12+
} from './phone-sms-texts.js';
13+
14+
describe('phoneSmsLocaleChain', () => {
15+
it('expands a regioned locale and always ends in en', () => {
16+
expect(phoneSmsLocaleChain('zh-CN')).toEqual(['zh-CN', 'zh', 'en']);
17+
expect(phoneSmsLocaleChain('zh')).toEqual(['zh', 'en']);
18+
expect(phoneSmsLocaleChain('en-US')).toEqual(['en-US', 'en']);
19+
expect(phoneSmsLocaleChain(undefined)).toEqual(['en']);
20+
});
21+
});
22+
23+
describe('builtin templates', () => {
24+
it('carry an en row for every topic (terminal fallback guarantee)', () => {
25+
for (const topic of Object.values(PHONE_SMS_TOPICS)) {
26+
expect(builtinPhoneSmsBody(topic, undefined)).toBeTruthy();
27+
}
28+
});
29+
30+
it('resolve zh for zh-CN deployments', () => {
31+
const body = builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'zh-CN');
32+
expect(body).toContain('验证码');
33+
expect(body).toContain('{{code}}');
34+
});
35+
36+
it('fall back to en for locales without a bundled text', () => {
37+
const body = builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'ja-JP');
38+
expect(body).toContain('verification code');
39+
});
40+
});
41+
42+
describe('interpolatePhoneSms', () => {
43+
it('substitutes holes and blanks unknown ones', () => {
44+
expect(
45+
interpolatePhoneSms('您的 {{appName}} 验证码为 {{code}},{{minutes}} 分钟内有效。', {
46+
appName: '对象栈',
47+
code: '123456',
48+
minutes: 5,
49+
}),
50+
).toBe('您的 对象栈 验证码为 123456,5 分钟内有效。');
51+
expect(interpolatePhoneSms('x {{missing}} y', {})).toBe('x y');
52+
});
53+
});
54+
55+
describe('loadPhoneSmsTemplateBody', () => {
56+
const engineWith = (rows: Array<Record<string, unknown>>) => ({
57+
find: vi.fn(async (_obj: string, q: any) =>
58+
rows.filter(
59+
(r) =>
60+
r.topic === q.where.topic &&
61+
r.channel === q.where.channel &&
62+
r.locale === q.where.locale &&
63+
r.is_active === true,
64+
),
65+
),
66+
insert: vi.fn(),
67+
});
68+
69+
it('returns the tenant row for the exact locale', async () => {
70+
const engine = engineWith([
71+
{ topic: 'auth.phone_otp', channel: 'sms', locale: 'zh-CN', is_active: true, body: '自定义 {{code}}' },
72+
]);
73+
await expect(loadPhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('自定义 {{code}}');
74+
});
75+
76+
it('walks the locale chain (zh-CN → zh)', async () => {
77+
const engine = engineWith([
78+
{ topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', is_active: true, body: 'zh 行 {{code}}' },
79+
]);
80+
await expect(loadPhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('zh 行 {{code}}');
81+
});
82+
83+
it('yields null with no matching row, no engine, or a broken lookup', async () => {
84+
await expect(loadPhoneSmsTemplateBody(engineWith([]), 'auth.phone_otp', 'zh')).resolves.toBeNull();
85+
await expect(loadPhoneSmsTemplateBody(undefined, 'auth.phone_otp', 'zh')).resolves.toBeNull();
86+
const broken = { find: vi.fn(async () => { throw new Error('no such table'); }), insert: vi.fn() };
87+
await expect(loadPhoneSmsTemplateBody(broken, 'auth.phone_otp', 'zh')).resolves.toBeNull();
88+
});
89+
});
90+
91+
describe('seedPhoneSmsTemplates', () => {
92+
it('inserts missing rows and never overwrites existing ones', async () => {
93+
const existing = [
94+
{ topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', body: '租户定制', is_active: false },
95+
];
96+
const inserted: Array<Record<string, unknown>> = [];
97+
const engine = {
98+
find: vi.fn(async (_obj: string, q: any) =>
99+
existing.filter(
100+
(r) => r.topic === q.where.topic && r.channel === q.where.channel && r.locale === q.where.locale,
101+
),
102+
),
103+
insert: vi.fn(async (_obj: string, row: any) => { inserted.push(row); return row; }),
104+
};
105+
await seedPhoneSmsTemplates(engine);
106+
// 4 built-ins, 1 already present (even deactivated!) → 3 inserts.
107+
expect(inserted).toHaveLength(BUILTIN_PHONE_SMS_TEMPLATES.length - 1);
108+
expect(inserted.some((r) => r.topic === 'auth.phone_otp' && r.locale === 'zh')).toBe(false);
109+
});
110+
111+
it('isolates per-row failures (missing table) via the logger', async () => {
112+
const warn = vi.fn();
113+
const engine = {
114+
find: vi.fn(async () => { throw new Error('no such table'); }),
115+
insert: vi.fn(),
116+
};
117+
await seedPhoneSmsTemplates(engine, { warn });
118+
expect(warn).toHaveBeenCalledTimes(BUILTIN_PHONE_SMS_TEMPLATES.length);
119+
expect(engine.insert).not.toHaveBeenCalled();
120+
});
121+
});

0 commit comments

Comments
 (0)