Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/phone-sms-i18n.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 52 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1615,15 +1621,15 @@ 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 —
// errors are logged, the route still answers {status:true} — so a
// 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);
},
}));
}
Expand Down Expand Up @@ -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<void> {
private async deliverPhoneOtp(phone: string, code: string): Promise<void> {
const sms = this.getSmsService();
if (!sms || !this.isPhoneOtpDeliverable()) {
// Absent service, or a log-only transport in production (the code
Expand All @@ -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 },
Expand All @@ -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<string, unknown>): Promise<string> {
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.
Expand Down
36 changes: 36 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
121 changes: 121 additions & 0 deletions packages/plugins/plugin-auth/src/phone-sms-texts.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>) => ({
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<Record<string, unknown>> = [];
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();
});
});
Loading