Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/30630-settings-copy-eleven-locales.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/plugin-email': patch
'@objectstack/service-settings': patch
'@objectstack/service-sms': patch
---

fix(settings): settings refusals, live statements and action answers are written in every platform language, and `zh-TW` reads Traditional Chinese

Clause-②: no
Metadata-protocol: none

- **Every module-local settings copy table carries the eleven platform languages** (the platform locale table: English, Simplified and Traditional Chinese, Spanish, Arabic, French, Brazilian Portuguese, Russian, German, Japanese, Korean). Before, they carried four (Setup → Email's live statement two), so a caller in any other language read English. Covered: the allowed-IP-ranges save refusal, the sign-in method floor refusals, the `auth.mfa_required` refusal and the organization MFA refusal and save notice (`@objectstack/plugin-auth`); Setup → Email's live-transport statement and "Send test email" answers (`@objectstack/plugin-email`); the default-currency lock and the "no longer offered" locale option (`@objectstack/service-settings`).
- **`zh-TW`, `zh-HK` and `zh-MO` callers read Traditional Chinese.** Each table looks the caller's code up with core's `resolveLocale`, which reads it through the platform locale table's read aliases; a code naming no language on the table is answered in English, as before.
- **"Send test SMS" (`sms/test`, `@objectstack/service-sms`) answers in the caller's language** (`Accept-Language`, else `?locale`), like the mail and storage connection tests. It read no locale before and answered English in every console language. A provider's own error text still passes through verbatim.
- **The two answers `SettingsService.runAction` writes itself** (the built-in `reset` result and the refusal for an action with no registered handler) are written in the caller's language. With no locale, or one off the platform table, every sentence is the English one callers read before.
91 changes: 84 additions & 7 deletions packages/plugins/plugin-auth/src/allowed-ip-ranges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
*/

import { resolveLocale } from '@objectstack/core';
import type { SettingsCopyLocale } from './settings-copy-locale.js';
import { ipMatchesRange, type AuthManager } from './auth-manager.js';
import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address-forwarding.js';

Expand Down Expand Up @@ -111,14 +112,18 @@ export interface AllowedIpRangesWriteRefusal {
constraint: { clientAddress: string | null };
}

interface RefusalCopy {
export interface AllowedIpRangesRefusalCopy {
outside(address: string): string;
undetermined: string;
recovery: string;
}

/** One entry per locale the settings bundle ships. */
const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = {
/**
* One entry per language on the platform locale table, the four served first
* under their older codes; `resolveLocale` reads a caller's code through that
* table (`zh-TW` → `zh-Hant`).
*/
export const ALLOWED_IP_RANGES_REFUSAL_COPY: Record<SettingsCopyLocale, AllowedIpRangesRefusalCopy> = {
en: {
outside: (address) =>
`No entry in this list matches ${address}, the address you are connected from, so saving it would refuse your `
Expand Down Expand Up @@ -158,13 +163,85 @@ const COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', RefusalCopy> = {
+ 'dirección el servidor pueda determinar.',
recovery: ` Si queda bloqueado, inicie el servidor con ${ALLOWED_IP_RANGES_ENV} con un valor vacío.`,
},
'zh-Hant': {
outside: (address) =>
`這份清單中沒有任何一項與你目前連線所用的位址 ${address} 相符,儲存後你自己下一次登入就會被拒絕。`
+ '請把你的位址或所在網段加進清單,或在清單能相符的位址上儲存。',
undetermined:
'伺服器無法確定你目前連線所用的位址;只要設定了清單,這樣的請求都會被拒絕,你自己下一次登入也不例外。'
+ '請在伺服器能確定位址的連線上儲存。',
recovery: `如果已被鎖在外面,可將環境變數 ${ALLOWED_IP_RANGES_ENV} 設為空值後重新啟動伺服器。`,
},
ar: {
outside: (address) =>
`لا يطابق أي إدخال في هذه القائمة العنوان ${address} الذي تتصل منه، لذا فإن حفظها سيرفض تسجيل دخولك التالي `
+ 'أنت نفسك. أضف عنوانك أو نطاقه إلى القائمة، أو احفظ القائمة من عنوان تطابقه.',
undetermined:
'لا يستطيع الخادم تحديد العنوان الذي تتصل منه، وما دامت هناك قائمة مضبوطة فإنه يرفض كل طلب من هذا النوع، '
+ 'بما في ذلك تسجيل دخولك التالي. احفظ القائمة من اتصال يستطيع الخادم تحديد عنوانه.',
recovery: ` إذا مُنعت من الدخول، فشغّل الخادم مع ضبط ${ALLOWED_IP_RANGES_ENV} على قيمة فارغة.`,
},
fr: {
outside: (address) =>
`Aucune entrée de cette liste ne correspond à ${address}, l’adresse depuis laquelle vous êtes connecté : `
+ 'l’enregistrer refuserait votre propre prochaine connexion. Ajoutez votre adresse ou sa plage à la liste, ou '
+ 'enregistrez la liste depuis une adresse qu’elle autorise.',
undetermined:
'Le serveur ne parvient pas à établir l’adresse depuis laquelle vous êtes connecté et, tant qu’une liste est '
+ 'définie, il refuse toutes ces requêtes, y compris votre propre prochaine connexion. Enregistrez la liste '
+ 'depuis une connexion dont le serveur peut établir l’adresse.',
recovery: ` Si vous êtes bloqué, démarrez le serveur avec ${ALLOWED_IP_RANGES_ENV} défini sur une valeur vide.`,
},
'pt-BR': {
outside: (address) =>
`Nenhuma entrada desta lista corresponde a ${address}, o endereço do qual você está conectado, então salvá-la `
+ 'recusaria o seu próprio próximo login. Adicione o seu endereço ou a faixa dele à lista, ou salve a lista a '
+ 'partir de um endereço que ela aceite.',
undetermined:
'O servidor não consegue determinar o endereço do qual você está conectado e, enquanto houver uma lista '
+ 'definida, recusa todas essas solicitações, inclusive o seu próprio próximo login. Salve a lista a partir de '
+ 'uma conexão cujo endereço o servidor consiga determinar.',
recovery: ` Se você ficar bloqueado, inicie o servidor com ${ALLOWED_IP_RANGES_ENV} definido como um valor vazio.`,
},
ru: {
outside: (address) =>
`Ни одна запись этого списка не соответствует адресу ${address}, с которого вы подключены, поэтому после `
+ 'сохранения ваш собственный следующий вход будет отклонён. Добавьте в список свой адрес или его диапазон '
+ 'либо сохраните список с адреса, который ему соответствует.',
undetermined:
'Сервер не может определить адрес, с которого вы подключены, и, пока задан любой список, отклоняет все такие '
+ 'запросы, включая ваш собственный следующий вход. Сохраните список через подключение, адрес которого сервер '
+ 'может определить.',
recovery: ` Если доступ уже потерян, запустите сервер с пустым значением ${ALLOWED_IP_RANGES_ENV}.`,
},
de: {
outside: (address) =>
`Kein Eintrag dieser Liste passt zu ${address}, der Adresse, von der aus Sie verbunden sind. Nach dem Speichern `
+ 'würde daher Ihre eigene nächste Anmeldung abgewiesen. Fügen Sie Ihre Adresse oder deren Bereich der Liste '
+ 'hinzu oder speichern Sie die Liste von einer Adresse aus, die sie zulässt.',
undetermined:
'Der Server kann die Adresse, von der aus Sie verbunden sind, nicht ermitteln, und solange eine Liste gesetzt '
+ 'ist, weist er alle solchen Anfragen ab, auch Ihre eigene nächste Anmeldung. Speichern Sie die Liste über eine '
+ 'Verbindung, deren Adresse der Server ermitteln kann.',
recovery: ` Wenn Sie ausgesperrt sind, starten Sie den Server mit ${ALLOWED_IP_RANGES_ENV} auf einem leeren Wert.`,
},
ko: {
outside: (address) =>
`이 목록에는 현재 접속한 주소 ${address}와 일치하는 항목이 없어, 저장하면 본인의 다음 로그인이 거부됩니다. `
+ '본인의 주소나 그 대역을 목록에 추가하거나, 목록과 일치하는 주소에서 저장하세요.',
undetermined:
'서버가 현재 접속한 주소를 확인할 수 없으며, 목록이 설정되어 있는 동안에는 이러한 요청을 모두 거부합니다. '
+ '본인의 다음 로그인도 마찬가지입니다. 서버가 주소를 확인할 수 있는 연결에서 목록을 저장하세요.',
recovery: ` 접속이 차단되었다면 ${ALLOWED_IP_RANGES_ENV}를 빈 값으로 설정한 채 서버를 시작하세요.`,
},
};

const COPY_LOCALES = Object.keys(COPY);
const COPY_LOCALES = Object.keys(ALLOWED_IP_RANGES_REFUSAL_COPY);

function copyFor(locale: string | undefined): RefusalCopy {
/** The refusal copy for a caller's locale: through the platform locale table, else English. */
export function allowedIpRangesRefusalCopy(locale: string | undefined): AllowedIpRangesRefusalCopy {
const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined;
return COPY[(picked ?? 'en') as keyof typeof COPY];
return ALLOWED_IP_RANGES_REFUSAL_COPY[(picked ?? 'en') as SettingsCopyLocale];
}

/**
Expand All @@ -184,7 +261,7 @@ export function allowedIpRangesWritePredicate(
if (!mgr) return undefined;
const address = judgedClientAddress(mgr, transport);
if (address && ranges.some((range) => ipMatchesRange(address, range))) return undefined;
const copy = copyFor(locale);
const copy = allowedIpRangesRefusalCopy(locale);
return {
message: (address ? copy.outside(address) : copy.undetermined) + copy.recovery,
constraint: { clientAddress: address ?? null },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,9 @@ describe('the global mfa_required switch needs two-factor enrollment', () => {
expect(off({ next: null, current: true })).toBeUndefined();
expect(off({ next: true, current: 'true' })).toBeUndefined();
expect(mfaRequiredEnrollmentPredicate(() => true)({ next: true, current: false })).toBeUndefined();
// Unknown locales fall back to English.
expect(off({ next: true, current: false, locale: 'fr-FR' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.en);
// A language off the platform locale table falls back to English; French is on it.
expect(off({ next: true, current: false, locale: 'sw-KE' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.en);
expect(off({ next: true, current: false, locale: 'fr-FR' })?.message).toBe(MFA_REQUIRED_ENROLLMENT_COPY.fr);
});

it('a settings service without write predicates is said at warn, only while the pin makes it matter', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

import { resolveLocale } from '@objectstack/core';
import { resolveTwoFactorEnabled } from './auth-manager.js';
import type { SettingsCopyLocale } from './settings-copy-locale.js';

/** The `auth` settings key this guards. */
export const MFA_REQUIRED_SETTING_KEY = 'mfa_required';
Expand All @@ -71,7 +72,13 @@ export function twoFactorEnrollableWithSwitchOn(): boolean {
return resolveTwoFactorEnabled({ twoFactor: true });
}

/** The refusal, per language. */
/**
* The refusal, per language: one entry per language on the platform locale
* table (`@objectstack/spec/system` `PLATFORM_LOCALES`), the four this table
* served first under their older codes (`zh-CN`, `ja-JP`, `es-ES`). Core's
* `resolveLocale` reads the caller's code through that table, so `zh-TW`
* finds the Traditional entry and `pt` the Brazilian one.
*/
export const MFA_REQUIRED_ENROLLMENT_COPY = {
en:
'Multi-factor authentication cannot be required on this deployment yet: OS_AUTH_TWO_FACTOR is set to off, ' +
Expand All @@ -89,7 +96,38 @@ export const MFA_REQUIRED_ENROLLMENT_COPY = {
'desactivado y prevalece sobre este ajuste, así que nadie podría registrar una aplicación de autenticación y ' +
'el requisito no se aplicaría. Establezca OS_AUTH_TWO_FACTOR=true (o elimínelo) en la implementación, ' +
'reinicie y después active esta opción.',
} as const;
'zh-Hant':
'本部署暫時無法強制多因素驗證:OS_AUTH_TWO_FACTOR 被設為關閉,它的優先順序高於本設定,' +
'因此沒有人能綁定驗證器應用程式,這項要求也不會生效。請先在部署上設定 OS_AUTH_TWO_FACTOR=true(或刪除它)並重新啟動,再開啟此項。',
ar:
'لا يمكن فرض المصادقة متعددة العوامل على هذا النشر بعد: تم ضبط OS_AUTH_TWO_FACTOR على الإيقاف، وهو يتقدّم ' +
'على هذا الإعداد، لذا لن يتمكن أحد من تسجيل تطبيق مصادقة ولن يُطبَّق هذا الشرط. اضبط OS_AUTH_TWO_FACTOR=true ' +
'(أو احذفه) على النشر وأعد تشغيله، ثم فعّل هذا الخيار.',
fr:
'L’authentification multifacteur ne peut pas encore être exigée sur ce déploiement : OS_AUTH_TWO_FACTOR est ' +
'désactivé et prévaut sur ce paramètre, si bien que personne ne pourrait enregistrer d’application ' +
'd’authentification et l’exigence ne serait pas appliquée. Définissez OS_AUTH_TWO_FACTOR=true (ou supprimez-le) ' +
'sur le déploiement et redémarrez, puis activez cette option.',
'pt-BR':
'Ainda não é possível exigir a autenticação multifator nesta implantação: OS_AUTH_TWO_FACTOR está desativado e ' +
'prevalece sobre esta configuração, então ninguém conseguiria cadastrar um aplicativo autenticador e a exigência ' +
'não seria aplicada. Defina OS_AUTH_TWO_FACTOR=true (ou remova-o) na implantação e reinicie; depois, ative esta ' +
'opção.',
ru:
'Пока нельзя требовать многофакторную аутентификацию в этом развёртывании: OS_AUTH_TWO_FACTOR выключен и имеет ' +
'приоритет над этой настройкой, поэтому никто не сможет подключить приложение-аутентификатор и требование не ' +
'будет действовать. Установите OS_AUTH_TWO_FACTOR=true (или удалите переменную) в развёртывании и перезапустите ' +
'его, затем включите этот параметр.',
de:
'Die Multi-Faktor-Authentifizierung kann in dieser Bereitstellung noch nicht verlangt werden: OS_AUTH_TWO_FACTOR ' +
'ist ausgeschaltet und hat Vorrang vor dieser Einstellung, sodass niemand eine Authenticator-App einrichten ' +
'könnte und die Anforderung nicht durchgesetzt würde. Setzen Sie OS_AUTH_TWO_FACTOR=true (oder entfernen Sie die ' +
'Variable) in der Bereitstellung, starten Sie neu und schalten Sie diese Option danach ein.',
ko:
'이 배포에서는 아직 다단계 인증을 필수로 설정할 수 없습니다. OS_AUTH_TWO_FACTOR가 꺼짐으로 설정되어 있고 이 설정보다 ' +
'우선하므로, 아무도 인증 앱을 등록할 수 없고 이 요구 사항도 적용되지 않습니다. 배포에서 OS_AUTH_TWO_FACTOR=true로 ' +
'설정하거나 이를 삭제한 뒤 다시 시작하고, 그다음 이 옵션을 켜세요.',
} as const satisfies Record<SettingsCopyLocale, string>;

const COPY_LOCALES = Object.keys(MFA_REQUIRED_ENROLLMENT_COPY);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@ describe('#30411 the refusal is in the caller\'s language, with the same field-l

it.each([
['zh', 'zh-CN'], ['zh-cn', 'zh-CN'], ['zh-Hans-CN', 'zh-CN'], ['ja', 'ja-JP'], ['es-MX', 'es-ES'],
['fr-FR', 'en'], ['', 'en'],
['fr-FR', 'fr'], ['zh-TW', 'zh-Hant'], ['sw-KE', 'en'], ['', 'en'],
] as const)('the request locale %j reads the %s copy (exact, then by language, else English)', (asked, served) => {
expect((organizationMfaWithoutEnrollmentRefusal(asked) as any).message).toBe(ORGANIZATION_MFA_REFUSAL_COPY[served]);
});
Expand Down Expand Up @@ -493,7 +493,9 @@ describe('#30411 an admitted turn-on answers the grace period in force as its sa
for (const [locale, copy] of Object.entries(ORGANIZATION_MFA_REQUIRED_NOTICE_COPY)) {
expect(copy(0), locale).toMatch(/\b0\b/);
expect(copy(0), locale).not.toBe(copy(1));
expect(copy(1), locale).toMatch(/\b1\b/);
// Arabic writes one day as a word, as its grammar does ("يوم واحد"); every other language as a digit.
if (locale === 'ar') expect(copy(1), locale).toContain('يوم واحد');
else expect(copy(1), locale).toMatch(/\b1\b/);
expect(copy(45), locale).toMatch(/\b45\b/);
expect(copy(45), locale).not.toMatch(/#\d/);
}
Expand Down
Loading
Loading