diff --git a/.changeset/phone-sms-i18n.md b/.changeset/phone-sms-i18n.md new file mode 100644 index 0000000000..5613a2d46d --- /dev/null +++ b/.changeset/phone-sms-i18n.md @@ -0,0 +1,17 @@ +--- +"@objectstack/plugin-auth": minor +--- + +feat(auth/i18n): localised, tenant-customisable phone SMS texts (#2815) + +The OTP and invitation SMS bodies were hard-coded English. They now resolve +in two layers: a `sys_notification_template` row for +`(auth.phone_otp | auth.phone_invite, channel 'sms', locale)` — editable in +Setup, seeded once with built-in en/zh rows, tenant edits never overwritten — +falling back to the bundled bilingual texts. The locale follows the +deployment default (`localization.locale` setting, live-rebound); per-user +locale is deferred until `sys_user` grows a locale column. The OTP wording +is purpose-neutral (one provider template covers sign-in and reset, and the +SMS reveals nothing about what the code unlocks). Template lookups are +best-effort — an outage never blocks an OTP send — and the no-OTP-in-logs +red line is unchanged. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 54b33bc3e8..597d1b6647 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -353,6 +353,18 @@ when SMS is deliverable: the created account receives a **credential-free invitation SMS** and the employee completes first sign-in via phone OTP, then sets their own password. +#### SMS text customisation & localisation + +The OTP and invitation bodies are localised and tenant-customisable: a +`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite, +channel 'sms', locale)` wins — built-in English and Chinese rows are seeded +once (never overwriting your edits) and can be changed under Setup → +Notification Templates. The locale follows the deployment default +(`localization.locale` setting) with a `zh-CN → zh → en` fallback chain; +holes are `{{code}}`, `{{appName}}`, `{{minutes}}` (OTP) and `{{appName}}`, +`{{baseUrl}}` (invitation). Template lookups are best-effort — an outage +falls back to the built-in text and never blocks an OTP send. + --- ## OAuth Providers diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index fc07116d91..ba1adb12c2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1233,6 +1233,59 @@ describe('AuthManager', () => { expect(sms.sent[0].body).toMatch(/verification code/); expect(sms.sent[0].body).toContain('http://localhost:3000'); }); + + // #2815 — localised, tenant-customisable SMS bodies. + it('renders the built-in Chinese OTP text for a zh-CN deployment locale', async () => { + const { manager, opts } = await bootOtp(); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('zh-CN'); + + await opts.sendOTP({ phoneNumber: PHONE, code: '123456' }); + expect(sms.sent[0].body).toContain('验证码'); + expect(sms.sent[0].body).toContain('123456'); + expect(sms.sent[0].templateParams).toEqual({ code: '123456' }); + + await manager.sendPhoneInviteSms(PHONE); + expect(sms.sent[1].body).toContain('账号已开通'); + expect(sms.sent[1].body).toContain('http://localhost:3000'); + }); + + it('a tenant sys_notification_template row overrides the built-in text', async () => { + const { manager, opts } = await bootOtp({ + dataEngine: { + async find(object: string, q: any) { + if ( + object === 'sys_notification_template' && + q?.where?.topic === 'auth.phone_otp' && + q?.where?.channel === 'sms' && + q?.where?.locale === 'zh-CN' + ) { + return [{ body: '【定制】验证码 {{code}}({{minutes}} 分钟)' }]; + } + return []; + }, + }, + }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + manager.setDefaultSmsLocale('zh-CN'); + + await opts.sendOTP({ phoneNumber: PHONE, code: '654321' }); + expect(sms.sent[0].body).toBe('【定制】验证码 654321(5 分钟)'); + }); + + it('a broken template lookup falls back to the built-in text (never blocks the send)', async () => { + const { manager, opts } = await bootOtp({ + dataEngine: { async find() { throw new Error('no such table'); } }, + }); + const sms = fakeSms(); + manager.setSmsService(sms.service); + + await opts.sendOTP({ phoneNumber: PHONE, code: '111222' }); + expect(sms.sent[0].body).toContain('111222'); + expect(sms.sent[0].body).toContain('verification code'); + }); }); // #2766 V1.5 — placeholder addresses must never become real recipients. diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 764ac36f00..5e496f5d18 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -18,6 +18,12 @@ import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai'; import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js'; import { isPlaceholderEmail } from './placeholder-email.js'; import { OtpSendGuard } from './otp-send-guard.js'; +import { + PHONE_SMS_TOPICS, + builtinPhoneSmsBody, + interpolatePhoneSms, + loadPhoneSmsTemplateBody, +} from './phone-sms-texts.js'; import { AUTH_USER_CONFIG, AUTH_SESSION_CONFIG, @@ -1615,7 +1621,7 @@ export class AuthManager { // awaits this callback on /phone-number/send-otp), so the guard's // TOO_MANY_REQUESTS becomes an honest 429 to the client. sendOTP: async ({ phoneNumber: phone, code }) => { - await this.deliverPhoneOtp(phone, code, 'sign-in verification'); + await this.deliverPhoneOtp(phone, code); }, // Self-service password reset OTP (`/phone-number/request-password- // reset`). better-auth invokes this via runInBackgroundOrAwait — @@ -1623,7 +1629,7 @@ export class AuthManager { // throw here can neither 500 the request nor leak whether the number // is registered. sendPasswordResetOTP: async ({ phoneNumber: phone, code }) => { - await this.deliverPhoneOtp(phone, code, 'password reset'); + await this.deliverPhoneOtp(phone, code); }, })); } @@ -2135,7 +2141,7 @@ export class AuthManager { * a log line or an error message (the SmsService logs masked numbers * and statuses, never bodies). */ - private async deliverPhoneOtp(phone: string, code: string, purpose: string): Promise { + private async deliverPhoneOtp(phone: string, code: string): Promise { const sms = this.getSmsService(); if (!sms || !this.isPhoneOtpDeliverable()) { // Absent service, or a log-only transport in production (the code @@ -2148,9 +2154,19 @@ export class AuthManager { } const otpCfg = this.config.phoneOtp ?? {}; const minutes = Math.max(1, Math.round((otpCfg.expiresIn ?? 300) / 60)); + // #2815 — localised, tenant-customisable body: a sys_notification_template + // row for (auth.phone_otp, sms, deployment locale) wins; the built-in + // bilingual text is the fallback. Purpose-neutral wording on purpose — + // one provider template covers sign-in and reset, and the SMS reveals + // nothing about what the code unlocks. + const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.otp, { + code, + appName: this.getAppName(), + minutes, + }); const result = await sms.send({ to: phone, - body: `${code} is your ${this.getAppName()} ${purpose} code. It expires in ${minutes} minutes.`, + body, // Template-only providers (Aliyun) substitute into a registered OTP // template; `code` is the conventional variable name. templateParams: { code }, @@ -2176,16 +2192,44 @@ export class AuthManager { throw new Error('SMS_SERVICE_REQUIRED: no SMS service is configured for this deployment.'); } const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); - const body = - `Your ${this.getAppName()} account is ready. Sign in with this phone number using a verification code` + - (baseUrl ? ` at ${baseUrl}` : '') + - ', then set your password.'; + // #2815 — localised, tenant-customisable body (see deliverPhoneOtp). + const body = await this.renderPhoneSmsBody(PHONE_SMS_TOPICS.invite, { + appName: this.getAppName(), + baseUrl, + }); const result = await sms.send({ to: phone, body, templateParams: { content: body } }); if (result.status === 'failed') { throw new Error(`Invitation SMS failed: ${result.error ?? 'SMS delivery failed'}`); } } + /** + * #2815 — the deployment-default locale for auth SMS bodies, sourced from + * the live `localization.locale` setting. AuthPlugin pushes it on + * `kernel:ready` and on every settings change (same pattern as + * {@link setAppName}). Unset ⇒ the built-in English text. + * + * Per-user locale is not resolved yet — `sys_user` carries no locale + * column; when it grows one, resolution should prefer it (#2815). + */ + setDefaultSmsLocale(locale: string | undefined): void { + this.smsLocale = locale?.trim() || undefined; + } + private smsLocale?: string; + + /** + * #2815 — resolve an auth SMS body: the tenant's + * `sys_notification_template` row for `(topic, 'sms', locale chain)` when + * one exists, else the built-in bilingual text. Template lookups are + * best-effort — an outage must never block an OTP send. + */ + private async renderPhoneSmsBody(topic: string, data: Record): Promise { + const template = + (await loadPhoneSmsTemplateBody(this.getDataEngine(), topic, this.smsLocale)) ?? + builtinPhoneSmsBody(topic, this.smsLocale); + return interpolatePhoneSms(template, data); + } + /** * Override the brand name surfaced in built-in auth emails (`{{appName}}`), * sourced from the live `branding.workspace_name` setting. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 21c579c096..37da74cd14 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -387,10 +387,46 @@ export class AuthPlugin implements Plugin { }); ctx.logger.info('Auth: bound appName to settings namespace=branding'); } + + // #2815 — bind the auth SMS locale to the deployment default + // (`localization.locale`) so OTP/invitation texts render in the + // workspace language. Live-rebinds on settings changes. + const applySmsLocale = async () => { + try { + const resolved = await settings.get('localization', 'locale', {}); + const value = resolved?.value; + this.authManager?.setDefaultSmsLocale( + typeof value === 'string' ? value : undefined, + ); + } catch (err: any) { + ctx.logger.warn( + 'Auth: failed to apply localization.locale: ' + (err?.message ?? err), + ); + } + }; + await applySmsLocale(); + if (typeof settings.subscribe === 'function') { + settings.subscribe('localization', () => { + void applySmsLocale(); + }); + } } } catch { // settings service is optional — keep the configured appName. } + + // #2815 — seed the built-in bilingual auth SMS templates into + // sys_notification_template (insert-if-missing; tenant edits are + // never overwritten). Only meaningful when phone sign-in is on; + // the table may not exist yet on a fresh env (messaging provisions + // it at kernel:ready), so failures log-and-continue. + if (this.authManager.isPhoneNumberEnabled()) { + const engine = this.authManager.getDataEngine(); + if (engine) { + const { seedPhoneSmsTemplates } = await import('./phone-sms-texts.js'); + await seedPhoneSmsTemplates(engine, ctx.logger); + } + } } let httpServer: IHttpServer | null = null; diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts new file mode 100644 index 0000000000..fed325c2c9 --- /dev/null +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.test.ts @@ -0,0 +1,121 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { + BUILTIN_PHONE_SMS_TEMPLATES, + PHONE_SMS_TOPICS, + builtinPhoneSmsBody, + interpolatePhoneSms, + loadPhoneSmsTemplateBody, + phoneSmsLocaleChain, + seedPhoneSmsTemplates, +} from './phone-sms-texts.js'; + +describe('phoneSmsLocaleChain', () => { + it('expands a regioned locale and always ends in en', () => { + expect(phoneSmsLocaleChain('zh-CN')).toEqual(['zh-CN', 'zh', 'en']); + expect(phoneSmsLocaleChain('zh')).toEqual(['zh', 'en']); + expect(phoneSmsLocaleChain('en-US')).toEqual(['en-US', 'en']); + expect(phoneSmsLocaleChain(undefined)).toEqual(['en']); + }); +}); + +describe('builtin templates', () => { + it('carry an en row for every topic (terminal fallback guarantee)', () => { + for (const topic of Object.values(PHONE_SMS_TOPICS)) { + expect(builtinPhoneSmsBody(topic, undefined)).toBeTruthy(); + } + }); + + it('resolve zh for zh-CN deployments', () => { + const body = builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'zh-CN'); + expect(body).toContain('验证码'); + expect(body).toContain('{{code}}'); + }); + + it('fall back to en for locales without a bundled text', () => { + const body = builtinPhoneSmsBody(PHONE_SMS_TOPICS.otp, 'ja-JP'); + expect(body).toContain('verification code'); + }); +}); + +describe('interpolatePhoneSms', () => { + it('substitutes holes and blanks unknown ones', () => { + expect( + interpolatePhoneSms('您的 {{appName}} 验证码为 {{code}},{{minutes}} 分钟内有效。', { + appName: '对象栈', + code: '123456', + minutes: 5, + }), + ).toBe('您的 对象栈 验证码为 123456,5 分钟内有效。'); + expect(interpolatePhoneSms('x {{missing}} y', {})).toBe('x y'); + }); +}); + +describe('loadPhoneSmsTemplateBody', () => { + const engineWith = (rows: Array>) => ({ + find: vi.fn(async (_obj: string, q: any) => + rows.filter( + (r) => + r.topic === q.where.topic && + r.channel === q.where.channel && + r.locale === q.where.locale && + r.is_active === true, + ), + ), + insert: vi.fn(), + }); + + it('returns the tenant row for the exact locale', async () => { + const engine = engineWith([ + { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh-CN', is_active: true, body: '自定义 {{code}}' }, + ]); + await expect(loadPhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('自定义 {{code}}'); + }); + + it('walks the locale chain (zh-CN → zh)', async () => { + const engine = engineWith([ + { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', is_active: true, body: 'zh 行 {{code}}' }, + ]); + await expect(loadPhoneSmsTemplateBody(engine, 'auth.phone_otp', 'zh-CN')).resolves.toBe('zh 行 {{code}}'); + }); + + it('yields null with no matching row, no engine, or a broken lookup', async () => { + await expect(loadPhoneSmsTemplateBody(engineWith([]), 'auth.phone_otp', 'zh')).resolves.toBeNull(); + await expect(loadPhoneSmsTemplateBody(undefined, 'auth.phone_otp', 'zh')).resolves.toBeNull(); + const broken = { find: vi.fn(async () => { throw new Error('no such table'); }), insert: vi.fn() }; + await expect(loadPhoneSmsTemplateBody(broken, 'auth.phone_otp', 'zh')).resolves.toBeNull(); + }); +}); + +describe('seedPhoneSmsTemplates', () => { + it('inserts missing rows and never overwrites existing ones', async () => { + const existing = [ + { topic: 'auth.phone_otp', channel: 'sms', locale: 'zh', body: '租户定制', is_active: false }, + ]; + const inserted: Array> = []; + const engine = { + find: vi.fn(async (_obj: string, q: any) => + existing.filter( + (r) => r.topic === q.where.topic && r.channel === q.where.channel && r.locale === q.where.locale, + ), + ), + insert: vi.fn(async (_obj: string, row: any) => { inserted.push(row); return row; }), + }; + await seedPhoneSmsTemplates(engine); + // 4 built-ins, 1 already present (even deactivated!) → 3 inserts. + expect(inserted).toHaveLength(BUILTIN_PHONE_SMS_TEMPLATES.length - 1); + expect(inserted.some((r) => r.topic === 'auth.phone_otp' && r.locale === 'zh')).toBe(false); + }); + + it('isolates per-row failures (missing table) via the logger', async () => { + const warn = vi.fn(); + const engine = { + find: vi.fn(async () => { throw new Error('no such table'); }), + insert: vi.fn(), + }; + await seedPhoneSmsTemplates(engine, { warn }); + expect(warn).toHaveBeenCalledTimes(BUILTIN_PHONE_SMS_TEMPLATES.length); + expect(engine.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/plugin-auth/src/phone-sms-texts.ts b/packages/plugins/plugin-auth/src/phone-sms-texts.ts new file mode 100644 index 0000000000..456a3c06e8 --- /dev/null +++ b/packages/plugins/plugin-auth/src/phone-sms-texts.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Localised auth SMS texts (#2815). + * + * The phone OTP / invitation SMS bodies were hard-coded English (#2780). + * They now resolve in two layers: + * + * 1. **Tenant-customisable templates** — a `sys_notification_template` row + * for `(topic, channel:'sms', locale)`, the same object the messaging + * `sms` channel renders. Operators edit them in Setup; the built-in + * rows below are seeded once and never overwrite an existing row. + * 2. **Built-in fallback** — the bundled texts here (en + zh), used when + * no template row resolves (fresh env, missing table, exotic locale). + * + * The recipient locale is the DEPLOYMENT default (`localization.locale` + * setting) — `sys_user` carries no per-user locale yet; when it grows one, + * resolution should prefer it (tracked in #2815). + * + * Red line unchanged: the OTP code appears only in the rendered body handed + * to the SMS service — never in logs or error messages. + */ + +/** Topics the auth phone SMS templates live under. */ +export const PHONE_SMS_TOPICS = { + otp: 'auth.phone_otp', + invite: 'auth.phone_invite', +} as const; + +/** Shape of the `sys_notification_template` columns this module touches. */ +export interface PhoneSmsTemplateRow { + topic: string; + channel: string; + locale: string; + subject?: string; + body: string; + format: string; + is_active: boolean; +} + +/** + * Built-in texts, seeded as template rows and used directly as the render + * fallback. Holes use the same `{{ path }}` syntax as the messaging + * template renderer (service-messaging/template-renderer.ts). + * + * The OTP text is deliberately purpose-neutral (no "sign-in" vs "reset" + * wording): one registered provider template covers both flows, and the + * SMS reveals nothing about what the code unlocks. + */ +export const BUILTIN_PHONE_SMS_TEMPLATES: readonly PhoneSmsTemplateRow[] = [ + { + topic: PHONE_SMS_TOPICS.otp, + channel: 'sms', + locale: 'en', + subject: 'Verification code', + body: '{{code}} is your {{appName}} verification code. It expires in {{minutes}} minutes.', + format: 'text', + is_active: true, + }, + { + topic: PHONE_SMS_TOPICS.otp, + channel: 'sms', + locale: 'zh', + subject: '验证码', + body: '您的 {{appName}} 验证码为 {{code}},{{minutes}} 分钟内有效,请勿泄露给他人。', + format: 'text', + is_active: true, + }, + { + topic: PHONE_SMS_TOPICS.invite, + channel: 'sms', + locale: 'en', + subject: 'Account invitation', + body: 'Your {{appName}} account is ready. Sign in with this phone number using a verification code at {{baseUrl}}, then set your password.', + format: 'text', + is_active: true, + }, + { + topic: PHONE_SMS_TOPICS.invite, + channel: 'sms', + locale: 'zh', + subject: '账号邀请', + body: '您的 {{appName}} 账号已开通。请访问 {{baseUrl}},使用本手机号通过验证码登录,然后设置您的密码。', + format: 'text', + is_active: true, + }, +]; + +/** + * Locale resolution chain: `zh-CN` → `['zh-CN', 'zh', 'en']`. English is + * always the terminal fallback — the built-in table is guaranteed to carry + * an `en` row for every topic. + */ +export function phoneSmsLocaleChain(locale: string | undefined): string[] { + const out: string[] = []; + const push = (l?: string) => { + const v = l?.trim(); + if (v && !out.includes(v)) out.push(v); + }; + push(locale); + if (locale && locale.includes('-')) push(locale.split('-')[0]); + push('en'); + return out; +} + +const TOKEN = /\{\{\s*([\w.$]+)\s*\}\}/g; + +/** + * `{{ hole }}` interpolation — same single-pass, logic-free semantics as + * the messaging renderer's `interpolate` (kept local: plugin-auth takes no + * dependency on service-messaging). Unknown holes render to ''. + */ +export function interpolatePhoneSms(template: string, data: Record): string { + if (!template) return ''; + return template.replace(TOKEN, (_m, path: string) => { + const v = data[path]; + return v == null ? '' : String(v); + }); +} + +/** Pick the built-in text for `(topic, locale chain)` — `en` always hits. */ +export function builtinPhoneSmsBody(topic: string, locale: string | undefined): string { + for (const loc of phoneSmsLocaleChain(locale)) { + const row = BUILTIN_PHONE_SMS_TEMPLATES.find( + (t) => t.topic === topic && t.locale === loc, + ); + if (row) return row.body; + } + return ''; +} + +/** Minimal engine surface the loader/seeder needs. */ +export interface PhoneSmsTemplateEngine { + find(objectName: string, query?: unknown): Promise; + insert(objectName: string, data: unknown, options?: unknown): Promise; +} + +const TEMPLATE_OBJECT = 'sys_notification_template'; +const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; + +function rowsOf(result: unknown): Array> { + if (Array.isArray(result)) return result as Array>; + const data = (result as { data?: unknown } | null)?.data; + return Array.isArray(data) ? (data as Array>) : []; +} + +/** + * Load the tenant's template body for `(topic, 'sms', locale chain)`. + * Best-effort: any lookup error (missing table, no engine) yields `null` + * so the caller falls back to the built-in text — a template outage must + * never block an OTP send. + */ +export async function loadPhoneSmsTemplateBody( + engine: PhoneSmsTemplateEngine | undefined, + topic: string, + locale: string | undefined, +): Promise { + if (!engine) return null; + for (const loc of phoneSmsLocaleChain(locale)) { + try { + const result = await engine.find(TEMPLATE_OBJECT, { + where: { topic, channel: 'sms', locale: loc, is_active: true }, + limit: 1, + context: SYSTEM_CTX, + }); + const row = rowsOf(result)[0]; + const body = row?.body; + if (typeof body === 'string' && body.trim()) return body; + } catch { + return null; // best-effort — fall back to the built-in text + } + } + return null; +} + +/** + * Seed the built-in rows into `sys_notification_template`, one per + * `(topic, 'sms', locale)`, **only when absent** — a tenant-customised (or + * deactivated) row is never overwritten. Per-row failures are isolated; + * the table may not exist yet on a fresh env (messaging provisions it at + * kernel:ready), so callers log-and-continue. + */ +export async function seedPhoneSmsTemplates( + engine: PhoneSmsTemplateEngine, + logger?: { warn(msg: string): void }, +): Promise { + for (const tpl of BUILTIN_PHONE_SMS_TEMPLATES) { + try { + const existing = await engine.find(TEMPLATE_OBJECT, { + where: { topic: tpl.topic, channel: tpl.channel, locale: tpl.locale }, + limit: 1, + context: SYSTEM_CTX, + }); + if (rowsOf(existing).length > 0) continue; + await engine.insert(TEMPLATE_OBJECT, { ...tpl }, { context: SYSTEM_CTX }); + } catch (err) { + logger?.warn( + `[AuthPlugin] phone SMS template seed failed for ${tpl.topic}/${tpl.locale}: ${(err as Error)?.message ?? err}`, + ); + } + } +}