diff --git a/.changeset/30630-settings-copy-eleven-locales.md b/.changeset/30630-settings-copy-eleven-locales.md new file mode 100644 index 00000000000..d1b8fca32c2 --- /dev/null +++ b/.changeset/30630-settings-copy-eleven-locales.md @@ -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. diff --git a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts index 579bf04d4da..3788afa610c 100644 --- a/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts +++ b/packages/plugins/plugin-auth/src/allowed-ip-ranges.ts @@ -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'; @@ -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 = { en: { outside: (address) => `No entry in this list matches ${address}, the address you are connected from, so saving it would refuse your ` @@ -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]; } /** @@ -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 }, diff --git a/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts index 8d6ef12caef..5303f5e5353 100644 --- a/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts +++ b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.test.ts @@ -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', () => { diff --git a/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts index c3c4a294145..76b594e0c3f 100644 --- a/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts +++ b/packages/plugins/plugin-auth/src/mfa-required-enrollment-predicate.ts @@ -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'; @@ -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, ' + @@ -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; const COPY_LOCALES = Object.keys(MFA_REQUIRED_ENROLLMENT_COPY); diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts index c0f4e0e1e93..4c98c964c1d 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.test.ts @@ -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]); }); @@ -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/); } diff --git a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts index 862fa2ec9e9..86c588b5339 100644 --- a/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts +++ b/packages/plugins/plugin-auth/src/organization-mfa-enrollment-guard.ts @@ -96,6 +96,7 @@ */ import { resolveLocale } from '@objectstack/core'; +import type { SettingsCopyLocale } from './settings-copy-locale.js'; import { SystemObjectName } from '@objectstack/spec/system'; import { validationFailure } from '@objectstack/types'; @@ -145,8 +146,30 @@ export function turnsMfaRequirementOn(value: unknown): boolean { return !OFF_VALUES.has(value); } -/** The locales the copy below carries; English is the fallback. */ -type CopyLocale = 'en' | 'zh-CN' | 'ja-JP' | 'es-ES'; +/** + * The locales the copy below carries: every language on the platform locale + * table ({@link SettingsCopyLocale}); English is the fallback. + */ +type CopyLocale = SettingsCopyLocale; + +/** `n` whole days in Arabic: its six plural forms, the dual and the singular spelled out. */ +function arabicDays(n: number): string { + const rest = n % 100; + if (n === 1) return 'يوم واحد'; + if (n === 2) return 'يومان'; + if (rest >= 3 && rest <= 10) return `${n} أيام`; + if (rest >= 11 && rest <= 99) return `${n} يومًا`; + return `${n} يوم`; +} + +/** `n` whole days in Russian: день / дня / дней by the last one or two digits. */ +function russianDays(n: number): string { + const rest10 = n % 10; + const rest100 = n % 100; + if (rest10 === 1 && rest100 !== 11) return `${n} день`; + if (rest10 >= 2 && rest10 <= 4 && (rest100 < 12 || rest100 > 14)) return `${n} дня`; + return `${n} дней`; +} /** * The refusal, per language: what was refused, why, who it would have locked @@ -183,6 +206,54 @@ export const ORGANIZATION_MFA_REFUSAL_COPY: Readonly> 'OS_AUTH_TWO_FACTOR esté definido como false allí (prevalece sobre los ajustes), también sirve activar el ' + 'ajuste auth.mfa_required: exige la autenticación multifactor en todas las organizaciones y activa el ' + 'registro con él.', + 'zh-Hant': + '本組織暫時無法強制多因素驗證:本部署未開啟雙重驗證的綁定,寬限期結束後所有成員(包括擁有者)都會被攔截,' + + '且無法綁定驗證器應用程式。請先開啟雙重驗證,再在這裡開啟此項:在部署上設定 OS_AUTH_TWO_FACTOR=true。' + + '只要部署上沒有把 OS_AUTH_TWO_FACTOR 設為 false(它的優先順序高於設定),開啟 auth.mfa_required 設定也可以:' + + '它會對所有組織強制多因素驗證,並同時開啟綁定。', + ar: + 'لا تستطيع هذه المؤسسة فرض المصادقة متعددة العوامل بعد: تسجيل المصادقة الثنائية متوقف على هذا النشر، لذا ' + + 'عند انتهاء فترة السماح سيُحظر كل عضو، بمن فيهم المالك، دون أي طريقة لتسجيل تطبيق مصادقة. فعّل المصادقة ' + + 'الثنائية أولًا ثم اشترطها هنا: اضبط OS_AUTH_TWO_FACTOR=true على النشر. وما لم يكن OS_AUTH_TWO_FACTOR ' + + 'مضبوطًا على false هناك (فهو يتقدّم على الإعدادات)، فإن تفعيل الإعداد auth.mfa_required يفي بالغرض أيضًا: ' + + 'فهو يفرض المصادقة متعددة العوامل على كل المؤسسات ويفعّل التسجيل معه.', + fr: + 'Cette organisation ne peut pas encore exiger l’authentification multifacteur : l’enregistrement à deux ' + + 'facteurs est désactivé sur ce déploiement, si bien qu’à la fin du délai de grâce tous les membres, ' + + 'propriétaire compris, seraient bloqués sans moyen d’enregistrer une application d’authentification. Activez ' + + 'd’abord les deux facteurs, puis exigez-les ici : définissez OS_AUTH_TWO_FACTOR=true sur le déploiement. Sauf ' + + 'si OS_AUTH_TWO_FACTOR y est défini sur false (il prévaut sur les paramètres), activer le paramètre ' + + 'auth.mfa_required fonctionne aussi : il exige l’authentification multifacteur pour toutes les organisations ' + + 'et active l’enregistrement en même temps.', + 'pt-BR': + 'Esta organização ainda não pode exigir a autenticação multifator: o cadastro de dois fatores está desativado ' + + 'nesta implantação, então, ao fim do período de carência, todos os membros, inclusive o proprietário, ficariam ' + + 'bloqueados sem como cadastrar um aplicativo autenticador. Ative primeiro os dois fatores e depois exija-os ' + + 'aqui: defina OS_AUTH_TWO_FACTOR=true na implantação. A menos que OS_AUTH_TWO_FACTOR esteja definido como false ' + + 'lá (ele prevalece sobre as configurações), ativar a configuração auth.mfa_required também funciona: ela exige ' + + 'a autenticação multifator em todas as organizações e ativa o cadastro junto.', + ru: + 'Эта организация пока не может требовать многофакторную аутентификацию: подключение двухфакторной ' + + 'аутентификации в этом развёртывании выключено, поэтому по окончании льготного периода все участники, включая ' + + 'владельца, были бы заблокированы без возможности подключить приложение-аутентификатор. Сначала включите ' + + 'двухфакторную аутентификацию, затем требуйте её здесь: установите OS_AUTH_TWO_FACTOR=true в развёртывании. ' + + 'Если там OS_AUTH_TWO_FACTOR не установлен в false (он имеет приоритет над настройками), подойдёт и включение ' + + 'настройки auth.mfa_required: она требует многофакторную аутентификацию во всех организациях и вместе с этим ' + + 'включает подключение.', + de: + 'Diese Organisation kann die Multi-Faktor-Authentifizierung noch nicht verlangen: Die Einrichtung der ' + + 'Zwei-Faktor-Authentifizierung ist in dieser Bereitstellung ausgeschaltet. Nach Ablauf der Schonfrist wäre daher ' + + 'jedes Mitglied, der Eigentümer eingeschlossen, gesperrt, ohne eine Authenticator-App einrichten zu können. ' + + 'Schalten Sie zuerst die Zwei-Faktor-Authentifizierung ein und verlangen Sie sie dann hier: Setzen Sie ' + + 'OS_AUTH_TWO_FACTOR=true in der Bereitstellung. Sofern OS_AUTH_TWO_FACTOR dort nicht auf false gesetzt ist (es ' + + 'hat Vorrang vor den Einstellungen), genügt auch das Einschalten der Einstellung auth.mfa_required: Sie verlangt ' + + 'die Multi-Faktor-Authentifizierung für alle Organisationen und schaltet die Einrichtung gleich mit ein.', + ko: + '이 조직은 아직 다단계 인증을 필수로 설정할 수 없습니다. 이 배포에서는 2단계 인증 등록이 꺼져 있으므로, 유예 ' + + '기간이 끝나면 소유자를 포함한 모든 구성원이 인증 앱을 등록할 방법 없이 차단됩니다. 먼저 2단계 인증을 켠 다음 ' + + '여기에서 필수로 설정하세요. 배포에서 OS_AUTH_TWO_FACTOR=true로 설정하면 됩니다. 배포에서 OS_AUTH_TWO_FACTOR ' + + '값이 false로 설정되어 있지 않다면(이 값은 설정보다 우선합니다) auth.mfa_required 설정을 켜는 방법도 있습니다. 이 ' + + '설정은 모든 조직에 다단계 인증을 요구하고 등록도 함께 켭니다.', }; /** @@ -230,6 +301,76 @@ export const ORGANIZATION_MFA_REQUIRED_NOTICE_COPY: Readonly + days === 0 + ? '本組織現已強制多因素驗證。尚未綁定驗證器應用程式的成員下次使用應用程式時即無法存取資料,直到完成綁定:' + + 'MFA 寬限期為 0 天。已使用驗證器應用程式的成員不受影響。MFA 寬限期可在「設定 → 驗證」中修改。' + : `本組織現已強制多因素驗證。尚未綁定驗證器應用程式的成員有 ${days} 天時間完成綁定,` + + '從要求生效後他們第一次使用應用程式時起算;期滿後,未綁定的成員將無法存取資料,直到完成綁定。' + + '已使用驗證器應用程式的成員不受影響。MFA 寬限期可在「設定 → 驗證」中修改。', + ar: (days) => + days === 0 + ? 'أصبحت المصادقة متعددة العوامل إلزامية لهذه المؤسسة. كل عضو لم يُعدّ تطبيق مصادقة يُحظر من الوصول إلى ' + + 'البيانات في المرة التالية التي يستخدم فيها التطبيق، إلى أن يسجّل تطبيقًا: مدة فترة السماح للمصادقة متعددة ' + + 'العوامل 0 يوم. لا يتأثر الأعضاء الذين يستخدمون تطبيق مصادقة بالفعل. تُضبط فترة السماح في الإعداد ← المصادقة.' + : `أصبحت المصادقة متعددة العوامل إلزامية لهذه المؤسسة. أمام كل عضو لم يُعدّ تطبيق مصادقة مهلة ${arabicDays(days)} ` + + 'لتسجيله، تُحسب من أول مرة يستخدم فيها التطبيق ما دام هذا الشرط قائمًا؛ وبعدها يُحظر من الوصول إلى البيانات ' + + 'إلى أن يسجّله. لا يتأثر الأعضاء الذين يستخدمون تطبيق مصادقة بالفعل. تُضبط فترة السماح في الإعداد ← المصادقة.', + fr: (days) => + days === 0 + ? 'L’authentification multifacteur est désormais obligatoire pour cette organisation. Chaque membre qui n’a ' + + 'pas configuré d’application d’authentification perd l’accès aux données dès sa prochaine utilisation de ' + + 'l’application, jusqu’à ce qu’il en enregistre une : le délai de grâce MFA est de 0 jour. Les membres qui ' + + 'utilisent déjà une application d’authentification ne sont pas concernés. Le délai de grâce MFA se règle ' + + 'dans Configuration → Authentification.' + : 'L’authentification multifacteur est désormais obligatoire pour cette organisation. Chaque membre qui n’a ' + + `pas configuré d’application d’authentification dispose de ${days === 1 ? '1 jour' : `${days} jours`} pour ` + + 'en enregistrer une, à compter de sa première utilisation de l’application depuis que l’exigence est ' + + 'active ; passé ce délai, il perd l’accès aux données jusqu’à ce qu’il le fasse. Les membres qui utilisent ' + + 'déjà une application d’authentification ne sont pas concernés. Le délai de grâce MFA se règle dans ' + + 'Configuration → Authentification.', + 'pt-BR': (days) => + days === 0 + ? 'A autenticação multifator agora é obrigatória nesta organização. Cada membro que não configurou um ' + + 'aplicativo autenticador perde o acesso aos dados na próxima vez que usar o aplicativo, até cadastrar um: o ' + + 'período de carência de MFA é de 0 dias. Os membros que já usam um aplicativo autenticador não são ' + + 'afetados. O período de carência de MFA é definido em Configuração → Autenticação.' + : 'A autenticação multifator agora é obrigatória nesta organização. Cada membro que não configurou um ' + + `aplicativo autenticador tem ${days === 1 ? '1 dia' : `${days} dias`} para cadastrar um, contados a partir ` + + 'da primeira vez que usar o aplicativo enquanto a exigência estiver ativa; depois disso, perde o acesso aos ' + + 'dados até cadastrar. Os membros que já usam um aplicativo autenticador não são afetados. O período de ' + + 'carência de MFA é definido em Configuração → Autenticação.', + ru: (days) => + days === 0 + ? 'Теперь в этой организации обязательна многофакторная аутентификация. Каждый участник, не настроивший ' + + 'приложение-аутентификатор, теряет доступ к данным при следующем использовании приложения, пока не ' + + 'подключит его: льготный период MFA — 0 дней. Участников, которые уже используют приложение-аутентификатор, ' + + 'это не затрагивает. Льготный период MFA задаётся в разделе «Настройка → Аутентификация».' + : 'Теперь в этой организации обязательна многофакторная аутентификация. У каждого участника, не настроившего ' + + `приложение-аутентификатор, есть ${russianDays(days)}, чтобы подключить его, считая с первого использования ` + + 'приложения после введения требования; после этого он теряет доступ к данным, пока не подключит приложение. ' + + 'Участников, которые уже используют приложение-аутентификатор, это не затрагивает. Льготный период MFA ' + + 'задаётся в разделе «Настройка → Аутентификация».', + de: (days) => + days === 0 + ? 'Die Multi-Faktor-Authentifizierung ist für diese Organisation jetzt Pflicht. Jedes Mitglied, das noch keine ' + + 'Authenticator-App eingerichtet hat, ist ab der nächsten Nutzung der App vom Zugriff auf Daten gesperrt, bis ' + + 'es eine einrichtet: Die MFA-Schonfrist beträgt 0 Tage. Mitglieder, die bereits eine Authenticator-App ' + + 'nutzen, sind nicht betroffen. Die MFA-Schonfrist wird unter Einrichtung → Authentifizierung festgelegt.' + : 'Die Multi-Faktor-Authentifizierung ist für diese Organisation jetzt Pflicht. Jedes Mitglied, das noch keine ' + + `Authenticator-App eingerichtet hat, hat ${days === 1 ? '1 Tag' : `${days} Tage`} Zeit, eine einzurichten, ` + + 'gerechnet ab der ersten Nutzung der App, seit sie Pflicht ist; danach ist es vom Zugriff auf Daten ' + + 'gesperrt, bis es das tut. Mitglieder, die bereits eine Authenticator-App nutzen, sind nicht betroffen. Die ' + + 'MFA-Schonfrist wird unter Einrichtung → Authentifizierung festgelegt.', + ko: (days) => + days === 0 + ? '이제 이 조직에서는 다단계 인증이 필수입니다. 인증 앱을 설정하지 않은 구성원은 다음에 앱을 사용할 때부터 ' + + '인증 앱을 등록할 때까지 데이터에 접근할 수 없습니다(MFA 유예 기간 0일). 이미 인증 앱을 사용하는 구성원은 ' + + '영향을 받지 않습니다. MFA 유예 기간은 설정 → 인증에서 변경할 수 있습니다.' + : '이제 이 조직에서는 다단계 인증이 필수입니다. 인증 앱을 설정하지 않은 구성원은 필수가 된 뒤 앱을 처음 ' + + `사용한 때부터 ${days}일 안에 인증 앱을 등록해야 하며, 그 이후에는 등록할 때까지 데이터에 접근할 수 ` + + '없습니다. 이미 인증 앱을 사용하는 구성원은 영향을 받지 않습니다. MFA 유예 기간은 설정 → 인증에서 변경할 수 ' + + '있습니다.', }; const COPY_LOCALES = Object.keys(ORGANIZATION_MFA_REFUSAL_COPY); diff --git a/packages/plugins/plugin-auth/src/settings-copy-locale.test.ts b/packages/plugins/plugin-auth/src/settings-copy-locale.test.ts new file mode 100644 index 00000000000..d6e87077816 --- /dev/null +++ b/packages/plugins/plugin-auth/src/settings-copy-locale.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Every settings copy table this plugin writes carries every language on the +// platform locale table, and a caller's code finds its own language's entry: +// `zh-TW` the Traditional one (never the Simplified), a stored alias its +// table language, anything else English. + +import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS, type PlatformLocaleTag } from '@objectstack/spec/system'; +import type { SettingsCopyLocale } from './settings-copy-locale.js'; +import { ALLOWED_IP_RANGES_REFUSAL_COPY, allowedIpRangesRefusalCopy } from './allowed-ip-ranges.js'; +import { SIGN_IN_FLOOR_COPY, signInFloorCopy } from './sign-in-method-floor.js'; +import { MFA_REQUIRED_ENROLLMENT_COPY, mfaRequiredEnrollmentCopy } from './mfa-required-enrollment-predicate.js'; +import { + ORGANIZATION_MFA_REFUSAL_COPY, + ORGANIZATION_MFA_REQUIRED_NOTICE_COPY, + organizationMfaRequiredNotice, + organizationMfaWithoutEnrollmentRefusal, +} from './organization-mfa-enrollment-guard.js'; + +/** The key each platform table tag is served by. */ +const KEY_OF_TAG: Record = { + en: 'en', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-Hant', es: 'es-ES', ar: 'ar', fr: 'fr', + 'pt-BR': 'pt-BR', ru: 'ru', de: 'de', ja: 'ja-JP', ko: 'ko', +}; +const KEYS = PLATFORM_LOCALE_TAGS.map((tag) => KEY_OF_TAG[tag]); + +/** An entry's sentences, functions called with sample arguments. */ +function sentences(entry: unknown): Record { + if (typeof entry === 'string') return { text: entry }; + if (typeof entry === 'function') return { 0: entry(0), 1: entry(1), 7: entry(7), 22: entry(22) }; + const out: Record = {}; + for (const [field, value] of Object.entries(entry as Record)) { + out[field] = typeof value === 'function' ? value('203.0.113.7') : String(value); + } + return out; +} + +interface Table { + name: string; + table: Record; + /** The table's own lookup, answering the entry a caller's code is served. */ + lookup(locale: string | undefined): unknown; +} + +const TABLES: Table[] = [ + { name: 'allowed IP ranges refusal', table: ALLOWED_IP_RANGES_REFUSAL_COPY, lookup: allowedIpRangesRefusalCopy }, + { name: 'sign-in method floor', table: SIGN_IN_FLOOR_COPY, lookup: signInFloorCopy }, + { name: 'auth.mfa_required refusal', table: MFA_REQUIRED_ENROLLMENT_COPY, lookup: mfaRequiredEnrollmentCopy }, + { + name: 'organization MFA refusal', + table: ORGANIZATION_MFA_REFUSAL_COPY, + lookup: (locale) => organizationMfaWithoutEnrollmentRefusal(locale).message, + }, + { + name: 'organization MFA notice', + table: ORGANIZATION_MFA_REQUIRED_NOTICE_COPY, + lookup: (locale) => organizationMfaRequiredNotice(7, locale), + }, +]; + +/** What a lookup answered, as comparable text. */ +function served(t: Table, locale: string | undefined): string { + const answer = t.lookup(locale); + return JSON.stringify(typeof answer === 'string' ? answer : sentences(answer)); +} + +/** The text a lookup must answer when it serves `key`. */ +function expected(t: Table, key: SettingsCopyLocale): string { + const entry = t.table[key]; + if (t.name === 'organization MFA notice') return JSON.stringify((entry as (d: number) => string)(7)); + return JSON.stringify(typeof entry === 'string' ? entry : sentences(entry)); +} + +describe.each(TABLES)('$name copy carries the eleven platform languages', (t) => { + it('has exactly one entry per platform locale table language', () => { + expect(Object.keys(t.table).sort()).toEqual([...KEYS].sort()); + expect(KEYS).toHaveLength(11); + }); + + it('every entry carries every sentence, written in its own language (no English fill)', () => { + const en = sentences(t.table.en); + for (const key of KEYS) { + const own = sentences(t.table[key]); + expect(Object.keys(own).sort(), key).toEqual(Object.keys(en).sort()); + for (const [field, text] of Object.entries(own)) { + expect(text.trim(), `${key}.${field}`).not.toBe(''); + if (key !== 'en') expect(text, `${key}.${field}`).not.toBe(en[field]); + } + } + }); + + it('each table tag, and each stored alias, is served its own language', () => { + for (const tag of PLATFORM_LOCALE_TAGS) { + expect(served(t, tag), tag).toBe(expected(t, KEY_OF_TAG[tag])); + } + for (const [code, key] of [ + ['zh-TW', 'zh-Hant'], ['zh-HK', 'zh-Hant'], ['zh-CN', 'zh-CN'], ['zh', 'zh-CN'], + ['ja-JP', 'ja-JP'], ['es-ES', 'es-ES'], ['pt', 'pt-BR'], ['en-US', 'en'], ['fr-CA', 'fr'], ['de-AT', 'de'], + ] as const) { + expect(served(t, code), code).toBe(expected(t, key)); + } + }); + + it('zh-TW is never served the Simplified entry', () => { + expect(served(t, 'zh-TW')).not.toBe(expected(t, 'zh-CN')); + }); + + it('a code naming no language here, or none at all, is served English', () => { + for (const code of ['xx', 'tlh', undefined]) { + expect(served(t, code), String(code)).toBe(expected(t, 'en')); + } + }); +}); + +describe('organization MFA notice plurals', () => { + it('Russian and Arabic count days in the right form', () => { + expect(organizationMfaRequiredNotice(1, 'ru')).toContain('1 день'); + expect(organizationMfaRequiredNotice(3, 'ru')).toContain('3 дня'); + expect(organizationMfaRequiredNotice(11, 'ru')).toContain('11 дней'); + expect(organizationMfaRequiredNotice(21, 'ru')).toContain('21 день'); + expect(organizationMfaRequiredNotice(2, 'ar')).toContain('يومان'); + expect(organizationMfaRequiredNotice(7, 'ar')).toContain('7 أيام'); + expect(organizationMfaRequiredNotice(14, 'ar')).toContain('14 يومًا'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/settings-copy-locale.ts b/packages/plugins/plugin-auth/src/settings-copy-locale.ts new file mode 100644 index 00000000000..34f143d17ab --- /dev/null +++ b/packages/plugins/plugin-auth/src/settings-copy-locale.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The keys of this plugin's settings copy tables: the sentences a settings + * write predicate refuses with, or a settings save answers with, in the + * caller's language (the allowed-IP refusal, the sign-in floor, the two MFA + * guards). + * + * One key per language on the platform locale table + * (`@objectstack/spec/system` `PLATFORM_LOCALES`): every surface carries every + * language on it. The four languages these tables served first keep their + * older codes (`zh-CN`, `ja-JP`, `es-ES`); the rest use the table's tags. A + * table looks a caller's code up with core's `resolveLocale`, which reads it + * through the platform table's read aliases, so `zh-TW` finds `zh-Hant` and + * never the Simplified entry, `pt` finds `pt-BR`, and a code naming no + * language here answers `undefined`, which every table serves in English. + */ +export type SettingsCopyLocale = + 'en' | 'zh-CN' | 'zh-Hant' | 'es-ES' | 'ar' | 'fr' | 'pt-BR' | 'ru' | 'de' | 'ja-JP' | 'ko'; diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts index 06af9064ad2..36e18cac061 100644 --- a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts @@ -93,6 +93,7 @@ */ import { resolveLocale } from '@objectstack/core'; +import type { SettingsCopyLocale } from './settings-copy-locale.js'; import type { StandardErrorCode } from '@objectstack/spec/api'; import { probeSsoProvidersPresence, @@ -232,8 +233,11 @@ interface SignInFloorCopy { enterpriseSsoUnverified: string; } -/** One entry per locale the settings bundle ships, as the currency lock's copy. */ -export const SIGN_IN_FLOOR_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', SignInFloorCopy> = { +/** + * One entry per language on the platform locale table, the four served first + * under their older codes ({@link SettingsCopyLocale}). + */ +export const SIGN_IN_FLOOR_COPY: Record = { en: { emailPassword: 'Turning email and password off would leave no way to sign in, so everyone would be locked out, ' + @@ -307,6 +311,135 @@ export const SIGN_IN_FLOOR_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Sign 'confirmar que queda otra forma de iniciar sesión y, con el correo y la contraseña desactivados, este podría ' + 'ser el último. Vuelva a intentarlo cuando la base de datos esté disponible.', }, + 'zh-Hant': { + emailPassword: + '關閉電子郵件/密碼後將沒有任何登入方式,所有人(包括管理員)都將無法登入。請在本次儲存之前或同時保留或設定另一種登入方式:' + + '本頁的 Google 登入(用戶端 ID 和密鑰)、其他社群或 OIDC 登入、企業 SSO 身分提供者,或手機號碼登入。', + google: + '電子郵件/密碼登入關閉時,這項 Google 變更會導致沒有任何登入方式,所有人(包括管理員)都將無法登入。' + + '請保持電子郵件/密碼登入開啟,或在本次儲存之前或同時設定另一種登入方式。', + enterpriseSso: + '這是最後一個企業 SSO 身分提供者,電子郵件/密碼登入又已關閉,沒有其他登入方式,刪除後所有人(包括管理員)都將無法登入。' + + '請先重新開啟電子郵件/密碼登入,或登記另一個身分提供者、設定另一種登入方式,然後再刪除它。', + enterpriseSsoUnverified: + '身分提供者未刪除:無法讀取身分提供者清單來確認仍有其他登入方式,而電子郵件/密碼登入已關閉,它可能是最後一種。' + + '請在資料庫可以存取時重試。', + }, + ar: { + emailPassword: + 'إيقاف البريد الإلكتروني وكلمة المرور لن يترك أي طريقة لتسجيل الدخول، فيُمنع الجميع من الدخول بمن فيهم ' + + 'المسؤولون. أبقِ طريقة أخرى لتسجيل الدخول أو اضبطها قبل هذا الحفظ أو ضمنه: تسجيل الدخول عبر Google في هذه ' + + 'الصفحة (معرّف العميل والسر)، أو موفّر اجتماعي أو OIDC آخر، أو موفّر هوية SSO مؤسسي، أو تسجيل الدخول برقم الهاتف.', + google: + 'مع إيقاف البريد الإلكتروني وكلمة المرور، لن يترك هذا التغيير على Google أي طريقة لتسجيل الدخول، فيُمنع الجميع ' + + 'من الدخول بمن فيهم المسؤولون. أبقِ تسجيل الدخول بالبريد الإلكتروني وكلمة المرور مفعّلًا، أو اضبط طريقة أخرى ' + + 'لتسجيل الدخول قبل هذا الحفظ أو ضمنه.', + enterpriseSso: + 'هذا آخر موفّر هوية SSO مؤسسي، ومع إيقاف البريد الإلكتروني وكلمة المرور لا شيء غيره يتيح لأحد تسجيل الدخول، ' + + 'لذا فإن حذفه سيمنع الجميع من الدخول بمن فيهم المسؤولون. أعد أولًا تفعيل تسجيل الدخول بالبريد الإلكتروني ' + + 'وكلمة المرور، أو سجّل موفّر هوية آخر أو اضبط طريقة أخرى لتسجيل الدخول، ثم احذف هذا الموفّر.', + enterpriseSsoUnverified: + 'لم يُحذف موفّر الهوية: تعذّرت قراءة قائمة موفّري الهوية للتأكد من بقاء طريقة أخرى لتسجيل الدخول، ومع إيقاف ' + + 'البريد الإلكتروني وكلمة المرور قد يكون هذا آخرها. أعد المحاولة عندما تصبح قاعدة البيانات متاحة.', + }, + fr: { + emailPassword: + 'Désactiver l’e-mail et le mot de passe ne laisserait aucun moyen de se connecter : tout le monde serait ' + + 'bloqué, administrateurs compris. Conservez ou configurez une autre méthode de connexion, avant ou dans cet ' + + 'enregistrement : la connexion Google de cette page (ID client et secret), un autre fournisseur social ou ' + + 'OIDC, un fournisseur d’identité SSO d’entreprise ou la connexion par numéro de téléphone.', + google: + 'Avec l’e-mail et le mot de passe désactivés, cette modification de Google ne laisserait aucun moyen de se ' + + 'connecter : tout le monde serait bloqué, administrateurs compris. Laissez la connexion par e-mail et mot de ' + + 'passe activée, ou configurez une autre méthode de connexion, avant ou dans cet enregistrement.', + enterpriseSso: + 'C’est le dernier fournisseur d’identité SSO d’entreprise et, avec l’e-mail et le mot de passe désactivés, ' + + 'rien d’autre ne permet de se connecter : le supprimer bloquerait tout le monde, administrateurs compris. ' + + 'Réactivez d’abord la connexion par e-mail et mot de passe, ou enregistrez un autre fournisseur d’identité ou ' + + 'configurez une autre méthode de connexion, puis supprimez celui-ci.', + enterpriseSsoUnverified: + 'Le fournisseur d’identité n’a pas été supprimé : la liste des fournisseurs d’identité n’a pas pu être lue ' + + 'pour vérifier qu’un autre moyen de connexion subsiste et, avec l’e-mail et le mot de passe désactivés, ' + + 'celui-ci est peut-être le dernier. Réessayez quand la base de données sera accessible.', + }, + 'pt-BR': { + emailPassword: + 'Desativar o e-mail e a senha deixaria sem nenhuma forma de entrar, então todos ficariam bloqueados, inclusive os ' + + 'administradores. Mantenha ou configure outro método de login, antes ou neste mesmo salvamento: o login com ' + + 'Google desta página (ID e segredo do cliente), outro provedor social ou OIDC, um provedor de identidade de SSO ' + + 'corporativo ou o login por número de telefone.', + google: + 'Com o e-mail e a senha desativados, esta alteração no Google deixaria sem nenhuma forma de entrar, então todos ' + + 'ficariam bloqueados, inclusive os administradores. Mantenha o login com e-mail e senha ativado ou configure ' + + 'outro método de login, antes ou neste mesmo salvamento.', + enterpriseSso: + 'Este é o último provedor de identidade de SSO corporativo e, com o e-mail e a senha desativados, nada mais ' + + 'permite entrar, então excluí-lo bloquearia todos, inclusive os administradores. Primeiro reative o login com ' + + 'e-mail e senha, ou cadastre outro provedor de identidade ou configure outro método de login, e depois exclua ' + + 'este.', + enterpriseSsoUnverified: + 'O provedor de identidade não foi excluído: não foi possível ler a lista de provedores de identidade para ' + + 'confirmar que resta outra forma de entrar e, com o e-mail e a senha desativados, este pode ser o último. ' + + 'Tente novamente quando o banco de dados estiver acessível.', + }, + ru: { + emailPassword: + 'Если отключить вход по электронной почте и паролю, не останется ни одного способа входа, и доступ потеряют ' + + 'все, включая администраторов. Сохраните или настройте другой способ входа до этого сохранения или в нём же: ' + + 'вход через Google на этой странице (идентификатор и секрет клиента), другой социальный или OIDC-провайдер, ' + + 'корпоративный поставщик удостоверений SSO или вход по номеру телефона.', + google: + 'При отключённом входе по электронной почте и паролю это изменение Google не оставит ни одного способа входа, ' + + 'и доступ потеряют все, включая администраторов. Оставьте вход по электронной почте и паролю включённым или ' + + 'настройте другой способ входа до этого сохранения или в нём же.', + enterpriseSso: + 'Это последний корпоративный поставщик удостоверений SSO, а вход по электронной почте и паролю отключён, так ' + + 'что войти больше нечем: его удаление лишит доступа всех, включая администраторов. Сначала снова включите вход ' + + 'по электронной почте и паролю либо зарегистрируйте другого поставщика удостоверений или настройте другой ' + + 'способ входа, а затем удалите этого.', + enterpriseSsoUnverified: + 'Поставщик удостоверений не удалён: не удалось прочитать список поставщиков удостоверений, чтобы убедиться, ' + + 'что останется другой способ входа, а при отключённом входе по электронной почте и паролю этот может быть ' + + 'последним. Повторите попытку, когда база данных станет доступна.', + }, + de: { + emailPassword: + 'Wenn Sie E-Mail und Passwort ausschalten, bleibt keine Möglichkeit zur Anmeldung, und alle wären ' + + 'ausgesperrt, Administratoren eingeschlossen. Behalten oder richten Sie eine andere Anmeldemethode ein, vor ' + + 'oder in diesem Speichervorgang: die Google-Anmeldung auf dieser Seite (Client-ID und Secret), einen anderen ' + + 'Social- oder OIDC-Anbieter, einen Enterprise-SSO-Identitätsanbieter oder die Anmeldung per Telefonnummer.', + google: + 'Bei ausgeschalteter Anmeldung mit E-Mail und Passwort ließe diese Änderung an Google keine Möglichkeit zur ' + + 'Anmeldung, und alle wären ausgesperrt, Administratoren eingeschlossen. Lassen Sie die Anmeldung mit E-Mail ' + + 'und Passwort eingeschaltet oder richten Sie vor oder in diesem Speichervorgang eine andere Anmeldemethode ein.', + enterpriseSso: + 'Dies ist der letzte Enterprise-SSO-Identitätsanbieter, und bei ausgeschalteter Anmeldung mit E-Mail und ' + + 'Passwort meldet nichts anderes jemanden an. Ihn zu löschen würde daher alle aussperren, Administratoren ' + + 'eingeschlossen. Schalten Sie zuerst die Anmeldung mit E-Mail und Passwort wieder ein oder registrieren Sie ' + + 'einen anderen Identitätsanbieter bzw. richten Sie eine andere Anmeldemethode ein, und löschen Sie ihn dann.', + enterpriseSsoUnverified: + 'Der Identitätsanbieter wurde nicht gelöscht: Die Liste der Identitätsanbieter konnte nicht gelesen werden, ' + + 'um zu bestätigen, dass eine andere Anmeldemöglichkeit bleibt, und bei ausgeschalteter Anmeldung mit E-Mail ' + + 'und Passwort ist dieser vielleicht der letzte. Versuchen Sie es erneut, sobald die Datenbank erreichbar ist.', + }, + ko: { + emailPassword: + '이메일/비밀번호를 끄면 로그인할 방법이 전혀 남지 않아 관리자를 포함한 모든 사람이 로그인할 수 없게 됩니다. ' + + '이번 저장 전이나 이번 저장에서 다른 로그인 방법을 유지하거나 설정하세요. 이 페이지의 Google 로그인(클라이언트 ID와 ' + + '시크릿), 다른 소셜 또는 OIDC 공급자, 엔터프라이즈 SSO ID 공급자, 또는 전화번호 로그인을 사용할 수 있습니다.', + google: + '이메일/비밀번호 로그인이 꺼진 상태에서 이 Google 변경을 저장하면 로그인할 방법이 전혀 남지 않아 관리자를 포함한 ' + + '모든 사람이 로그인할 수 없게 됩니다. 이메일/비밀번호 로그인을 켜 두거나, 이번 저장 전이나 이번 저장에서 다른 ' + + '로그인 방법을 설정하세요.', + enterpriseSso: + '이것은 마지막 엔터프라이즈 SSO ID 공급자이며, 이메일/비밀번호 로그인도 꺼져 있어 다른 로그인 방법이 없습니다. ' + + '삭제하면 관리자를 포함한 모든 사람이 로그인할 수 없게 됩니다. 먼저 이메일/비밀번호 로그인을 다시 켜거나, 다른 ID ' + + '공급자를 등록하거나 다른 로그인 방법을 설정한 뒤 이 공급자를 삭제하세요.', + enterpriseSsoUnverified: + 'ID 공급자를 삭제하지 않았습니다. 다른 로그인 방법이 남아 있는지 확인하기 위해 ID 공급자 목록을 읽을 수 없었고, ' + + '이메일/비밀번호 로그인이 꺼져 있어 이것이 마지막 방법일 수 있습니다. 데이터베이스에 연결할 수 있게 되면 다시 ' + + '시도하세요.', + }, }; const COPY_LOCALES = Object.keys(SIGN_IN_FLOOR_COPY); diff --git a/packages/plugins/plugin-email/src/live-transport-status.pin.test.ts b/packages/plugins/plugin-email/src/live-transport-status.pin.test.ts index 21ef4e237b6..82303a606c0 100644 --- a/packages/plugins/plugin-email/src/live-transport-status.pin.test.ts +++ b/packages/plugins/plugin-email/src/live-transport-status.pin.test.ts @@ -11,7 +11,15 @@ import { describe, it, expect, vi } from 'vitest'; import { EmailServicePlugin } from './email-plugin.js'; import { EmailService, LogTransport } from './email-service.js'; import { SmtpTransport } from './transports/smtp.js'; -import { describeLiveMailTransport, type LiveMailTransportStatus } from './live-transport-status.js'; +import { + describeLiveMailTransport, + mailDeliveryCopy, + MAIL_DELIVERY_COPY, + type LiveMailTransportStatus, + type MailDeliveryCopy, + type MailDeliveryCopyLocale, +} from './live-transport-status.js'; +import { PLATFORM_LOCALE_TAGS, type PlatformLocaleTag } from '@objectstack/spec/system'; vi.mock('nodemailer', () => { const createTransport = vi.fn(() => ({ sendMail: vi.fn(), close: vi.fn() })); @@ -119,3 +127,59 @@ describe('EmailServicePlugin — the statement Setup → Email makes', () => { expect(after?.label).toBe('Mail is delivered through SMTP (smtp.saved.test:587)'); }); }); + +// Setup → Email's words carry every language on the platform locale table, and +// a caller's code finds its own language's entry: `zh-TW` the Traditional one +// (never the Simplified), a stored alias its table language, anything else +// English. +describe('Setup → Email copy carries the eleven platform languages', () => { + const KEY_OF_TAG: Record = { + en: 'en', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-Hant', es: 'es-ES', ar: 'ar', fr: 'fr', + 'pt-BR': 'pt-BR', ru: 'ru', de: 'de', ja: 'ja-JP', ko: 'ko', + }; + const KEYS = PLATFORM_LOCALE_TAGS.map((tag) => KEY_OF_TAG[tag]); + /** Every sentence of an entry, its functions called with sample arguments. */ + const sentences = (copy: MailDeliveryCopy): Record => ({ + hostTransport: copy.hostTransport, + noMailLabel: copy.noMailLabel, + noMailText: copy.noMailText, + deliveredLabel: copy.deliveredLabel('Resend'), + deliveredText: copy.deliveredText('Resend'), + testSent: copy.testSent('admin@example.test', 'resend', 'msg_1'), + testSentLive: copy.testSent('admin@example.test', undefined, 'msg_1'), + testFailed: copy.testFailed('resend', 'refused'), + testFailedLive: copy.testFailed(undefined, 'refused'), + testLoggedLive: copy.testLoggedLive, + testLoggedForm: copy.testLoggedForm('Resend'), + }); + + it('has exactly one entry per platform locale table language', () => { + expect(Object.keys(MAIL_DELIVERY_COPY).sort()).toEqual([...KEYS].sort()); + expect(KEYS).toHaveLength(11); + }); + + it('every entry carries every sentence, written in its own language (no English fill)', () => { + const en = sentences(MAIL_DELIVERY_COPY.en); + for (const key of KEYS) { + for (const [field, text] of Object.entries(sentences(MAIL_DELIVERY_COPY[key]))) { + expect(text.trim(), `${key}.${field}`).not.toBe(''); + if (key !== 'en') expect(text, `${key}.${field}`).not.toBe(en[field]); + } + } + }); + + it('each table tag and stored alias is served its own language; zh-TW never the Simplified entry', () => { + for (const tag of PLATFORM_LOCALE_TAGS) expect(mailDeliveryCopy(tag), tag).toBe(MAIL_DELIVERY_COPY[KEY_OF_TAG[tag]]); + for (const [code, key] of [ + ['zh-TW', 'zh-Hant'], ['zh-HK', 'zh-Hant'], ['zh-CN', 'zh-CN'], ['zh', 'zh-CN'], + ['ja-JP', 'ja-JP'], ['es-ES', 'es-ES'], ['pt', 'pt-BR'], ['en-US', 'en'], ['fr-CA', 'fr'], + ] as const) { + expect(mailDeliveryCopy(code), code).toBe(MAIL_DELIVERY_COPY[key]); + } + expect(mailDeliveryCopy('zh-TW')).not.toBe(MAIL_DELIVERY_COPY['zh-CN']); + }); + + it('a code naming no language here, or none at all, is served English', () => { + for (const code of ['xx', 'sw-KE', undefined]) expect(mailDeliveryCopy(code), String(code)).toBe(MAIL_DELIVERY_COPY.en); + }); +}); diff --git a/packages/plugins/plugin-email/src/live-transport-status.ts b/packages/plugins/plugin-email/src/live-transport-status.ts index 312bcf12edf..f9164a6db25 100644 --- a/packages/plugins/plugin-email/src/live-transport-status.ts +++ b/packages/plugins/plugin-email/src/live-transport-status.ts @@ -32,7 +32,7 @@ export interface LiveMailTransportSource { /** * The words Setup → Email uses for the live transport and the "Send test - * email" answer, one entry per language this plugin ships copy for. + * email" answer, one entry per language on the platform locale table. * * Kept here and not in a translation bundle: the sentences are composed per * request with the transport's name, the recipient and the message id, and the @@ -57,7 +57,17 @@ export interface MailDeliveryCopy { testLoggedForm(liveLabel: string): string; } -export const MAIL_DELIVERY_COPY: Record<'en' | 'zh-CN', MailDeliveryCopy> = { +/** + * The keys of {@link MAIL_DELIVERY_COPY}: one per language on the platform + * locale table (`@objectstack/spec/system` `PLATFORM_LOCALES`). `zh-CN`, + * `ja-JP` and `es-ES` keep the codes the settings bundle uses; the rest are the + * table's tags. Core's `resolveLocale` reads a caller's code through that + * table, so `zh-TW` finds `zh-Hant`, never the Simplified entry. + */ +export type MailDeliveryCopyLocale = + 'en' | 'zh-CN' | 'zh-Hant' | 'es-ES' | 'ar' | 'fr' | 'pt-BR' | 'ru' | 'de' | 'ja-JP' | 'ko'; + +export const MAIL_DELIVERY_COPY: Record = { en: { hostTransport: 'a transport supplied by the host application', noMailLabel: 'No mail leaves this machine', @@ -92,6 +102,169 @@ export const MAIL_DELIVERY_COPY: Record<'en' | 'zh-CN', MailDeliveryCopy> = { + '测试邮件只记入了日志并保存在邮件日志中,没有邮件发出本机。' + `本次测试没有使用当前的投递方式(${liveLabel});保存表单后将停止投递。`, }, + 'zh-Hant': { + hostTransport: '主應用程式提供的傳送方式', + noMailLabel: '目前不會寄出任何郵件', + noMailText: '目前的傳送方式是開發記錄:伺服器寄出的每封郵件(邀請、密碼重設、通知)都會記入郵件記錄並標示為已寄出,' + + '但不會送達任何人。要真正傳送郵件,請在下方選擇服務商,填好其設定並儲存。', + deliveredLabel: (name) => `郵件透過 ${name} 傳送`, + deliveredText: (name) => `目前的傳送方式是 ${name},伺服器寄出的郵件會交給它傳送。` + + '單次寄送仍可能失敗;下方的測試郵件按鈕會顯示服務商自己的回覆。', + testSent: (to, via, id) => `測試郵件已寄至 ${to}${via ? `,經由 ${via}` : ''}(id=${id})。`, + testFailed: (via, error) => `測試寄送失敗${via ? `(經由 ${via})` : ''}:${error}`, + testLoggedLive: '目前沒有可用的傳送方式:測試郵件只記入了記錄,並保存在郵件記錄中。' + + '請先設定 SMTP 主機(或 API 服務商)並儲存,再進行測試。', + testLoggedForm: (liveLabel) => '沒有傳送任何郵件:表單上選的服務商只記錄、不寄送,' + + '測試郵件只記入了記錄並保存在郵件記錄中,沒有郵件離開本機。' + + `本次測試沒有使用目前的傳送方式(${liveLabel});儲存表單後將停止傳送。`, + }, + 'ja-JP': { + hostTransport: 'ホストアプリケーションが提供する配信方法', + noMailLabel: 'このマシンからメールは送信されません', + noMailText: '現在の配信方法は開発用ログです。このサーバーが送信するすべてのメッセージ(招待、パスワードの再設定、通知)は' + + 'メールログに送信済みとして記録されますが、誰にも届きません。メールを配信するには、下でプロバイダーを選び、' + + '設定を入力して保存してください。', + deliveredLabel: (name) => `メールは ${name} 経由で配信されます`, + deliveredText: (name) => `現在の配信方法は ${name} です。このサーバーが送信するメッセージは配信のためにこれに渡されます。` + + '個々の送信が失敗することはあります。下の「テストメール送信」はプロバイダー自身の応答を表示します。', + testSent: (to, via, id) => `テストメールを ${to} に送信しました${via ? `(${via} 経由)` : ''}(id=${id})。`, + testFailed: (via, error) => `テスト送信に失敗しました${via ? `(${via} 経由)` : ''}: ${error}`, + testLoggedLive: '有効な配信方法がありません。メッセージはログに記録され、メールログに保存されただけです。' + + 'テストする前に SMTP ホスト(または API プロバイダー)を設定して保存してください。', + testLoggedForm: (liveLabel) => '何も配信されていません。フォームのプロバイダーが「送信しない(ログのみ — 実際には配信されません)」の' + + 'ため、テストメッセージはログに記録されてメールログに保存されただけで、このマシンからメールは送信されていません。' + + `現在の配信方法(${liveLabel})は使われていません。フォームを保存すると配信が止まります。`, + }, + 'es-ES': { + hostTransport: 'un transporte proporcionado por la aplicación anfitriona', + noMailLabel: 'No sale ningún correo de esta máquina', + noMailText: 'El transporte activo es el registro de desarrollo. Cada mensaje que envía este servidor ' + + '(invitaciones, restablecimientos de contraseña, notificaciones) se registra en el registro de correo como ' + + 'enviado, pero no se entrega a nadie. Para entregar correo, elija un proveedor abajo, complete sus ajustes ' + + 'y guarde.', + deliveredLabel: (name) => `El correo se entrega a través de ${name}`, + deliveredText: (name) => `El transporte activo es ${name}. Los mensajes que envía este servidor se le entregan ` + + 'para su envío. Un envío concreto aún puede fallar; «Enviar correo de prueba», abajo, muestra la respuesta ' + + 'del propio proveedor.', + testSent: (to, via, id) => `Correo de prueba enviado a ${to}${via ? ` a través de ${via}` : ''} (id=${id}).`, + testFailed: (via, error) => `El envío de prueba ha fallado${via ? ` a través de ${via}` : ''}: ${error}`, + testLoggedLive: 'No hay ningún transporte de entrega activo: el mensaje solo se registró y se guardó en el ' + + 'registro de correo. Configure un host SMTP (o un proveedor de API) y guarde antes de probar.', + testLoggedForm: (liveLabel) => 'No se ha entregado nada: el proveedor del formulario es «Ninguno (solo registro — ' + + 'sin entrega real)», así que el mensaje de prueba solo se registró y se guardó en el registro de correo; no ' + + `salió ningún correo de esta máquina. No se usó el transporte activo (${liveLabel}); guarde el formulario para ` + + 'detener la entrega.', + }, + ar: { + hostTransport: 'وسيلة نقل يوفّرها التطبيق المضيف', + noMailLabel: 'لا يخرج أي بريد من هذا الجهاز', + noMailText: 'وسيلة النقل الفعّالة هي سجل التطوير. كل رسالة يرسلها هذا الخادم (الدعوات، وإعادة تعيين كلمات المرور، ' + + 'والإشعارات) تُسجَّل في سجل البريد على أنها أُرسلت، لكنها لا تصل إلى أحد. لتسليم البريد، اختر موفّرًا أدناه ' + + 'وأكمل إعداداته واحفظ.', + deliveredLabel: (name) => `يُسلَّم البريد عبر ${name}`, + deliveredText: (name) => `وسيلة النقل الفعّالة هي ${name}. تُسلَّم إليها الرسائل التي يرسلها هذا الخادم لإيصالها. ` + + 'قد يفشل إرسال بعينه مع ذلك؛ ويعرض زر البريد التجريبي أدناه ردّ الموفّر نفسه.', + testSent: (to, via, id) => `أُرسل البريد التجريبي إلى ${to}${via ? ` عبر ${via}` : ''} (id=${id}).`, + testFailed: (via, error) => `فشل الإرسال التجريبي${via ? ` عبر ${via}` : ''}: ${error}`, + testLoggedLive: 'لا توجد وسيلة تسليم فعّالة، فقد سُجّلت الرسالة فقط وحُفظت في سجل البريد. ' + + 'اضبط مضيف SMTP (أو موفّر API) واحفظ قبل الاختبار.', + testLoggedForm: (liveLabel) => 'لم يُسلَّم أي شيء: الموفّر المختار في النموذج يكتفي بالتسجيل دون إرسال، لذا ' + + 'سُجّلت الرسالة التجريبية فقط وحُفظت في سجل البريد، ولم يخرج أي بريد من هذا الجهاز. لم تُستخدم وسيلة النقل ' + + `الفعّالة (${liveLabel})؛ احفظ النموذج لإيقاف التسليم.`, + }, + fr: { + hostTransport: 'un transport fourni par l’application hôte', + noMailLabel: 'Aucun e-mail ne quitte cette machine', + noMailText: 'Le transport actif est le journal de développement. Chaque message envoyé par ce serveur ' + + '(invitations, réinitialisations de mot de passe, notifications) est enregistré comme envoyé dans le journal ' + + 'des e-mails, mais n’est remis à personne. Pour remettre les e-mails, choisissez un fournisseur ci-dessous, ' + + 'complétez ses paramètres et enregistrez.', + deliveredLabel: (name) => `Les e-mails sont remis via ${name}`, + deliveredText: (name) => `Le transport actif est ${name}. Les messages envoyés par ce serveur lui sont confiés ` + + 'pour remise. Un envoi isolé peut encore échouer ; le bouton d’e-mail de test ci-dessous affiche la réponse ' + + 'du fournisseur lui-même.', + testSent: (to, via, id) => `E-mail de test envoyé à ${to}${via ? ` via ${via}` : ''} (id=${id}).`, + testFailed: (via, error) => `L’envoi de test a échoué${via ? ` via ${via}` : ''} : ${error}`, + testLoggedLive: 'Aucun transport de remise n’est actif : le message a seulement été journalisé et conservé dans ' + + 'le journal des e-mails. Configurez un hôte SMTP (ou un fournisseur d’API) et enregistrez avant de tester.', + testLoggedForm: (liveLabel) => 'Rien n’a été remis : le fournisseur choisi dans le formulaire se contente de ' + + 'journaliser, si bien que le message de test a seulement été journalisé et conservé dans le journal des ' + + `e-mails ; aucun e-mail n’a quitté cette machine. Le transport actif n’a pas été utilisé (${liveLabel}) ; ` + + 'enregistrez le formulaire pour arrêter la remise.', + }, + 'pt-BR': { + hostTransport: 'um transporte fornecido pelo aplicativo hospedeiro', + noMailLabel: 'Nenhum e-mail sai desta máquina', + noMailText: 'O transporte ativo é o log de desenvolvimento. Cada mensagem que este servidor envia (convites, ' + + 'redefinições de senha, notificações) é registrada no log de e-mails como enviada, mas não é entregue a ' + + 'ninguém. Para entregar e-mails, escolha um provedor abaixo, preencha as configurações dele e salve.', + deliveredLabel: (name) => `Os e-mails são entregues por meio de ${name}`, + deliveredText: (name) => `O transporte ativo é ${name}. As mensagens que este servidor envia são repassadas a ele ` + + 'para entrega. Um envio específico ainda pode falhar; o botão de e-mail de teste abaixo mostra a resposta do ' + + 'próprio provedor.', + testSent: (to, via, id) => `E-mail de teste enviado para ${to}${via ? ` por meio de ${via}` : ''} (id=${id}).`, + testFailed: (via, error) => `O envio de teste falhou${via ? ` por meio de ${via}` : ''}: ${error}`, + testLoggedLive: 'Nenhum transporte de entrega está ativo: a mensagem foi apenas registrada e mantida no log de ' + + 'e-mails. Configure um host SMTP (ou um provedor de API) e salve antes de testar.', + testLoggedForm: (liveLabel) => 'Nada foi entregue: o provedor escolhido no formulário apenas registra, então a ' + + 'mensagem de teste foi apenas registrada e mantida no log de e-mails; nenhum e-mail saiu desta máquina. O ' + + `transporte ativo não foi usado (${liveLabel}); salve o formulário para interromper a entrega.`, + }, + ru: { + hostTransport: 'транспорт, предоставленный приложением-хостом', + noMailLabel: 'Почта с этой машины не отправляется', + noMailText: 'Действующий транспорт — журнал разработки. Каждое сообщение, которое отправляет этот сервер ' + + '(приглашения, сброс пароля, уведомления), записывается в журнал почты как отправленное, но никому не ' + + 'доставляется. Чтобы доставлять почту, выберите поставщика ниже, заполните его настройки и сохраните.', + deliveredLabel: (name) => `Почта доставляется через ${name}`, + deliveredText: (name) => `Действующий транспорт — ${name}. Сообщения, которые отправляет этот сервер, передаются ` + + 'ему для доставки. Отдельная отправка всё равно может не удаться; кнопка тестового письма ниже показывает ' + + 'ответ самого поставщика.', + testSent: (to, via, id) => `Тестовое письмо отправлено на ${to}${via ? ` через ${via}` : ''} (id=${id}).`, + testFailed: (via, error) => `Тестовая отправка не удалась${via ? ` через ${via}` : ''}: ${error}`, + testLoggedLive: 'Нет действующего транспорта доставки: сообщение только записано в журнал и сохранено в журнале ' + + 'почты. Перед проверкой настройте SMTP-хост (или API-поставщика) и сохраните.', + testLoggedForm: (liveLabel) => 'Ничего не доставлено: выбранный в форме поставщик только ведёт журнал, поэтому ' + + 'тестовое сообщение лишь записано в журнал и сохранено в журнале почты; ни одно письмо не покинуло эту машину. ' + + `Действующий транспорт не использовался (${liveLabel}); сохраните форму, чтобы остановить доставку.`, + }, + de: { + hostTransport: 'ein von der Host-Anwendung bereitgestellter Transport', + noMailLabel: 'Keine E-Mail verlässt diesen Rechner', + noMailText: 'Der aktive Transport ist das Entwicklungsprotokoll. Jede Nachricht, die dieser Server sendet ' + + '(Einladungen, Passwortzurücksetzungen, Benachrichtigungen), wird im E-Mail-Protokoll als gesendet ' + + 'verzeichnet, aber niemandem zugestellt. Um E-Mails zuzustellen, wählen Sie unten einen Anbieter, vervollständigen ' + + 'Sie seine Einstellungen und speichern Sie.', + deliveredLabel: (name) => `E-Mails werden über ${name} zugestellt`, + deliveredText: (name) => `Der aktive Transport ist ${name}. Nachrichten, die dieser Server sendet, werden ihm zur ` + + 'Zustellung übergeben. Ein einzelner Versand kann trotzdem fehlschlagen; die Test-E-Mail-Schaltfläche unten ' + + 'zeigt die Antwort des Anbieters selbst.', + testSent: (to, via, id) => `Test-E-Mail an ${to} gesendet${via ? ` über ${via}` : ''} (id=${id}).`, + testFailed: (via, error) => `Testversand fehlgeschlagen${via ? ` über ${via}` : ''}: ${error}`, + testLoggedLive: 'Kein Zustelltransport ist aktiv: Die Nachricht wurde nur protokolliert und im E-Mail-Protokoll ' + + 'gespeichert. Richten Sie vor dem Testen einen SMTP-Host (oder einen API-Anbieter) ein und speichern Sie.', + testLoggedForm: (liveLabel) => 'Nichts wurde zugestellt: Der im Formular gewählte Anbieter protokolliert nur, ' + + 'daher wurde die Testnachricht nur protokolliert und im E-Mail-Protokoll gespeichert; keine E-Mail hat diesen ' + + `Rechner verlassen. Der aktive Transport wurde nicht verwendet (${liveLabel}); speichern Sie das Formular, um ` + + 'die Zustellung zu beenden.', + }, + ko: { + hostTransport: '호스트 애플리케이션이 제공하는 전송 방식', + noMailLabel: '이 머신에서 메일이 나가지 않습니다', + noMailText: '현재 전송 방식은 개발용 로그입니다. 이 서버가 보내는 모든 메시지(초대, 비밀번호 재설정, 알림)는 메일 ' + + '로그에 보냄으로 기록되지만 누구에게도 전달되지 않습니다. 메일을 전달하려면 아래에서 공급자를 선택하고 설정을 ' + + '입력한 뒤 저장하세요.', + deliveredLabel: (name) => `메일은 ${name}을(를) 통해 전달됩니다`, + deliveredText: (name) => `현재 전송 방식은 ${name}입니다. 이 서버가 보내는 메시지는 전달을 위해 여기에 넘겨집니다. ` + + '개별 발송은 여전히 실패할 수 있으며, 아래의 테스트 메일 버튼은 공급자 자신의 응답을 보여 줍니다.', + testSent: (to, via, id) => `테스트 메일을 ${to}(으)로 보냈습니다${via ? `(${via} 사용)` : ''} (id=${id}).`, + testFailed: (via, error) => `테스트 발송에 실패했습니다${via ? `(${via} 사용)` : ''}: ${error}`, + testLoggedLive: '활성화된 전달 방식이 없습니다. 메시지는 로그에 기록되어 메일 로그에 보관되기만 했습니다. ' + + '테스트하기 전에 SMTP 호스트(또는 API 공급자)를 설정하고 저장하세요.', + testLoggedForm: (liveLabel) => '아무것도 전달되지 않았습니다. 양식에서 선택한 공급자는 로그만 남기므로 테스트 ' + + '메시지는 로그에 기록되어 메일 로그에 보관되기만 했고, 이 머신에서 나간 메일은 없습니다. 현재 전송 ' + + `방식(${liveLabel})은 사용되지 않았습니다. 양식을 저장하면 전달이 중지됩니다.`, + }, }; const COPY_LOCALES = Object.keys(MAIL_DELIVERY_COPY); diff --git a/packages/plugins/plugin-email/src/mail-log-provider.pin.test.ts b/packages/plugins/plugin-email/src/mail-log-provider.pin.test.ts index 6e82dd925d7..b61b6192eeb 100644 --- a/packages/plugins/plugin-email/src/mail-log-provider.pin.test.ts +++ b/packages/plugins/plugin-email/src/mail-log-provider.pin.test.ts @@ -285,8 +285,10 @@ describe('Setup → Email answers in the caller\'s language (zh-CN)', () => { expect(answer.message).toContain('邮件通过 SMTP (smtp.saved.test:587) 投递'); }); - it('CONTROL — a language with no copy answers in English', async () => { + it('CONTROL — a language off the platform locale table answers in English', async () => { const h = await boot({}); - expect((await h.settings.statement('fr-FR'))?.label).toBe('No mail leaves this machine'); + // The copy carries every platform language (`fr-FR` is French now); Swahili is not on the table. + expect((await h.settings.statement('fr-FR'))?.label).toBe('Aucun e-mail ne quitte cette machine'); + expect((await h.settings.statement('sw-KE'))?.label).toBe('No mail leaves this machine'); }); }); diff --git a/packages/services/service-settings/src/localization-currency-lock.test.ts b/packages/services/service-settings/src/localization-currency-lock.test.ts index 2fef4ade1e3..26c273a8f9b 100644 --- a/packages/services/service-settings/src/localization-currency-lock.test.ts +++ b/packages/services/service-settings/src/localization-currency-lock.test.ts @@ -15,6 +15,7 @@ import { registerSettingsRoutes } from './settings-routes.js'; import { localizationSettingsManifest } from './manifests/localization.manifest.js'; import { mailSettingsManifest } from './manifests/mail.manifest.js'; import { settingsBuiltinTranslations } from './translations/index.js'; +import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { SETTINGS_LIVE_STATUS_BANNER_ID, SettingsValidationError } from './settings-service.types.js'; import { CURRENCY_LOCK_COPY, @@ -220,16 +221,20 @@ describe('readCurrencyUsage', () => { }); }); -describe('the copy — every locale the settings bundle ships', () => { - it('covers exactly the locales of settingsBuiltinTranslations', () => { - expect(Object.keys(CURRENCY_LOCK_COPY).sort()).toEqual(Object.keys(settingsBuiltinTranslations).sort()); +describe('the copy — every language on the platform locale table', () => { + it('covers the locales of settingsBuiltinTranslations, and one entry per platform locale table language', () => { + for (const locale of Object.keys(settingsBuiltinTranslations)) expect(CURRENCY_LOCK_COPY).toHaveProperty([locale]); + expect(Object.keys(CURRENCY_LOCK_COPY)).toHaveLength(PLATFORM_LOCALE_TAGS.length); }); it.each([ ['zh-CN', '默认币种已锁定为 USD:它已被 1 个对象(Invoice)和 2 条汇率使用。'], ['ja', '既定の通貨は USD でロックされています。1 件のオブジェクト(Invoice)と 2 件の為替レートで使用されているためです。'], ['es-MX', 'La moneda predeterminada está bloqueada en USD porque está en uso en 1 objeto (Invoice) y 2 tipos de cambio.'], - ['fr-FR', 'The default currency is locked at USD because it is in use by 1 object (Invoice) and 2 exchange rates.'], + ['fr-FR', 'La devise par défaut est verrouillée sur USD, car elle est utilisée par 1 objet (Invoice) et 2 taux de change.'], + ['zh-TW', '預設幣別已鎖定為 USD:它已被 1 個物件(Invoice)和 2 筆匯率使用。'], + ['ru', 'Валюта по умолчанию зафиксирована как USD, потому что она используется: 1 объект (Invoice) и 2 обменных курса.'], + ['sw-KE', 'The default currency is locked at USD because it is in use by 1 object (Invoice) and 2 exchange rates.'], ])('writes the refusal in %s', async (locale, opening) => { const { svc, rates, objects } = rig(); await svc.set('localization', 'currency', 'USD', ctx); diff --git a/packages/services/service-settings/src/localization-currency-lock.ts b/packages/services/service-settings/src/localization-currency-lock.ts index b9a116e1486..5475c6800ba 100644 --- a/packages/services/service-settings/src/localization-currency-lock.ts +++ b/packages/services/service-settings/src/localization-currency-lock.ts @@ -28,6 +28,7 @@ */ import { resolveLocale } from '@objectstack/core'; +import { arabicCount, russianCount, type SettingsCopyLocale } from './settings-copy-locale.js'; import type { SettingsLiveStatus, SettingsLiveStatusProvider, @@ -124,13 +125,13 @@ interface CurrencyLockCopy { } /** - * The lock's copy, one entry per locale the settings bundle ships - * (`settingsBuiltinTranslations`). Kept here and not in that bundle: the + * The lock's copy, one entry per language on the platform locale table + * ({@link SettingsCopyLocale}). Kept here and not in that bundle: the * sentences are computed per request with counts and names, and the console's * banner lookup would replace a computed live-status text with a fixed one * (`SETTINGS_LIVE_STATUS_BANNER_ID`). */ -export const CURRENCY_LOCK_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', CurrencyLockCopy> = { +export const CURRENCY_LOCK_COPY: Record = { en: { objects: (n, labels) => `${n} ${n === 1 ? 'object' : 'objects'} (${labels})`, rates: (n) => `${n} ${n === 1 ? 'exchange rate' : 'exchange rates'}`, @@ -195,6 +196,132 @@ export const CURRENCY_LOCK_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', Curr 'Los tipos de cambio y los importes en la moneda principal necesitan una moneda predeterminada, ' + `y no hay ninguna. En uso: ${usage}. Elíjala abajo; una vez guardada, no se podrá cambiar.`, }, + 'zh-Hant': { + objects: (n, labels) => `${n} 個物件(${labels})`, + rates: (n) => `${n} 筆匯率`, + and: '和 ', + listSeparator: '、', + refusal: (current, usage) => + `預設幣別已鎖定為 ${current}:它已被 ${usage}使用。修改它會讓所有已換算成該幣別的金額標上錯誤的幣別。` + + '只有在不再有任何匯率紀錄、也沒有任何物件宣告本位幣金額之後,才能修改。', + lockedLabel: (current) => `預設幣別已鎖定:${current}`, + lockedText: (usage) => `已被 ${usage}使用。換算後的金額都以該幣別儲存,因此不能再修改。`, + unsetLabel: '尚未設定預設幣別', + unsetText: (usage) => + `匯率和本位幣金額都需要預設幣別,但目前尚未設定。使用情況:${usage}。請在下方選擇;儲存後將無法修改。`, + }, + ar: { + objects: (n, labels) => + `${arabicCount(n, { one: 'كائن واحد', two: 'كائنان', few: 'كائنات', many: 'كائنًا', other: 'كائن' })} (${labels})`, + rates: (n) => + arabicCount(n, { one: 'سعر صرف واحد', two: 'سعرا صرف', few: 'أسعار صرف', many: 'سعر صرف', other: 'سعر صرف' }), + and: ' و', + listSeparator: '، ', + refusal: (current, usage) => + `العملة الافتراضية مقفلة على ${current} لأنها مستخدمة في ${usage}. تغييرها سيُعيد تسمية كل المبالغ التي ` + + 'حُوّلت إليها بالفعل. لا يمكن تغييرها مجددًا إلا عندما لا يبقى أي سعر صرف مسجّل ولا يعلن أي كائن عن مبلغ ' + + 'بالعملة الأساسية.', + lockedLabel: (current) => `العملة الافتراضية مقفلة: ${current}`, + lockedText: (usage) => + `مستخدمة في ${usage}. تُخزَّن المبالغ المحوّلة بهذه العملة، لذا لم يعد بالإمكان تغييرها.`, + unsetLabel: 'لم تُضبط عملة افتراضية', + unsetText: (usage) => + 'تحتاج أسعار الصرف والمبالغ بالعملة الأساسية إلى عملة افتراضية، ولم تُضبط أي عملة. ' + + `الاستخدام: ${usage}. اخترها أدناه؛ وبعد حفظها لا يمكن تغييرها.`, + }, + fr: { + objects: (n, labels) => `${n} ${n === 1 ? 'objet' : 'objets'} (${labels})`, + rates: (n) => `${n} taux de change`, + and: ' et ', + listSeparator: ', ', + refusal: (current, usage) => + `La devise par défaut est verrouillée sur ${current}, car elle est utilisée par ${usage}. ` + + 'La changer réétiquetterait tous les montants déjà convertis dans cette devise. Elle ne pourra changer à ' + + 'nouveau qu’une fois qu’aucun taux de change n’est enregistré et qu’aucun objet ne déclare de montant en ' + + 'devise principale.', + lockedLabel: (current) => `Devise par défaut verrouillée : ${current}`, + lockedText: (usage) => + `Utilisée par ${usage}. Les montants convertis sont stockés dans cette devise, elle ne peut donc plus être ` + + 'modifiée.', + unsetLabel: 'Aucune devise par défaut définie', + unsetText: (usage) => + 'Les taux de change et les montants en devise principale nécessitent une devise par défaut, et aucune ' + + `n’est définie. Utilisation : ${usage}. Choisissez-la ci-dessous ; une fois enregistrée, elle ne pourra plus ` + + 'être modifiée.', + }, + 'pt-BR': { + objects: (n, labels) => `${n} ${n === 1 ? 'objeto' : 'objetos'} (${labels})`, + rates: (n) => `${n} ${n === 1 ? 'taxa de câmbio' : 'taxas de câmbio'}`, + and: ' e ', + listSeparator: ', ', + refusal: (current, usage) => + `A moeda padrão está bloqueada em ${current} porque está em uso por ${usage}. ` + + 'Alterá-la trocaria a moeda de todos os valores já convertidos para ela. Ela só poderá ser alterada ' + + 'novamente quando não houver nenhuma taxa de câmbio registrada e nenhum objeto declarar um valor na moeda ' + + 'principal.', + lockedLabel: (current) => `Moeda padrão bloqueada: ${current}`, + lockedText: (usage) => + `Em uso por ${usage}. Os valores convertidos são armazenados nesta moeda, por isso ela não pode mais ser ` + + 'alterada.', + unsetLabel: 'Nenhuma moeda padrão definida', + unsetText: (usage) => + 'As taxas de câmbio e os valores na moeda principal precisam de uma moeda padrão, e nenhuma está ' + + `definida. Em uso: ${usage}. Escolha-a abaixo; depois de salva, ela não poderá ser alterada.`, + }, + ru: { + objects: (n, labels) => + `${russianCount(n, { one: 'объект', few: 'объекта', many: 'объектов' })} (${labels})`, + rates: (n) => russianCount(n, { one: 'обменный курс', few: 'обменных курса', many: 'обменных курсов' }), + and: ' и ', + listSeparator: ', ', + refusal: (current, usage) => + `Валюта по умолчанию зафиксирована как ${current}, потому что она используется: ${usage}. ` + + 'Её смена переименовала бы валюту всех сумм, уже пересчитанных в неё. Изменить её снова можно будет только ' + + 'тогда, когда не останется ни одного записанного обменного курса и ни один объект не будет объявлять сумму в ' + + 'базовой валюте.', + lockedLabel: (current) => `Валюта по умолчанию зафиксирована: ${current}`, + lockedText: (usage) => + `Используется: ${usage}. Пересчитанные суммы хранятся в этой валюте, поэтому её больше нельзя изменить.`, + unsetLabel: 'Валюта по умолчанию не задана', + unsetText: (usage) => + 'Обменным курсам и суммам в базовой валюте нужна валюта по умолчанию, а она не задана. ' + + `Используется: ${usage}. Выберите её ниже; после сохранения её нельзя будет изменить.`, + }, + de: { + objects: (n, labels) => `${n} ${n === 1 ? 'Objekt' : 'Objekte'} (${labels})`, + rates: (n) => `${n} ${n === 1 ? 'Wechselkurs' : 'Wechselkurse'}`, + and: ' und ', + listSeparator: ', ', + refusal: (current, usage) => + `Die Standardwährung ist auf ${current} festgelegt, weil sie in Gebrauch ist: ${usage}. ` + + 'Eine Änderung würde jedem bereits in sie umgerechneten Betrag eine andere Währung zuschreiben. Sie kann ' + + 'erst wieder geändert werden, wenn kein Wechselkurs mehr erfasst ist und kein Objekt einen Betrag in der ' + + 'Hauswährung deklariert.', + lockedLabel: (current) => `Standardwährung gesperrt: ${current}`, + lockedText: (usage) => + `In Gebrauch: ${usage}. Umgerechnete Beträge werden in dieser Währung gespeichert, daher kann sie nicht ` + + 'mehr geändert werden.', + unsetLabel: 'Keine Standardwährung festgelegt', + unsetText: (usage) => + 'Wechselkurse und Beträge in der Hauswährung benötigen eine Standardwährung, und es ist keine festgelegt. ' + + `In Gebrauch: ${usage}. Wählen Sie sie unten aus; nach dem Speichern kann sie nicht mehr geändert werden.`, + }, + ko: { + objects: (n, labels) => `오브젝트 ${n}개(${labels})`, + rates: (n) => `환율 ${n}개`, + and: ' 및 ', + listSeparator: ', ', + refusal: (current, usage) => + `기본 통화가 ${current}(으)로 잠겨 있습니다. ${usage}에서 사용 중이기 때문입니다. ` + + '변경하면 이미 이 통화로 환산된 모든 금액의 통화 표시가 바뀝니다. 기록된 환율이 하나도 없고 기본 통화 ' + + '금액을 선언하는 오브젝트도 없어야 다시 변경할 수 있습니다.', + lockedLabel: (current) => `기본 통화 잠김: ${current}`, + lockedText: (usage) => `${usage}에서 사용 중입니다. 환산된 금액이 이 통화로 저장되므로 더 이상 변경할 수 없습니다.`, + unsetLabel: '기본 통화가 설정되지 않았습니다', + unsetText: (usage) => + `환율과 기본 통화 금액에는 기본 통화가 필요하지만 설정되어 있지 않습니다. 사용 현황: ${usage}. ` + + '아래에서 선택하세요. 저장한 뒤에는 변경할 수 없습니다.', + }, }; const COPY_LOCALES = Object.keys(CURRENCY_LOCK_COPY); diff --git a/packages/services/service-settings/src/localization-locale-options.ts b/packages/services/service-settings/src/localization-locale-options.ts index aacaae9ad70..4922a13f633 100644 --- a/packages/services/service-settings/src/localization-locale-options.ts +++ b/packages/services/service-settings/src/localization-locale-options.ts @@ -45,6 +45,7 @@ */ import { resolveLocale, resolveOfferedLocale } from '@objectstack/core'; +import type { SettingsCopyLocale } from './settings-copy-locale.js'; import { PLATFORM_LOCALES, platformLocaleTagOf } from '@objectstack/spec/system'; import type { SettingsOptionSource, SettingsServedOption } from './settings-service.types.js'; @@ -78,12 +79,22 @@ export function localeSelfName(code: string): string { } } -/** "no longer offered", in the reader's language. */ -export const NOT_OFFERED_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', string> = { +/** + * "no longer offered", in the reader's language: one entry per language on the + * platform locale table ({@link SettingsCopyLocale}). + */ +export const NOT_OFFERED_COPY: Record = { en: 'no longer offered', 'zh-CN': '已不再提供', + 'zh-Hant': '已不再提供', 'ja-JP': '現在は提供されていません', 'es-ES': 'ya no disponible', + ar: 'لم تعد متاحة', + fr: 'plus disponible', + 'pt-BR': 'não está mais disponível', + ru: 'больше не предлагается', + de: 'nicht mehr angeboten', + ko: '더 이상 제공되지 않음', }; const COPY_LOCALES = Object.keys(NOT_OFFERED_COPY); diff --git a/packages/services/service-settings/src/settings-action-copy.ts b/packages/services/service-settings/src/settings-action-copy.ts new file mode 100644 index 00000000000..c3994aa557e --- /dev/null +++ b/packages/services/service-settings/src/settings-action-copy.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { resolveLocale } from '@objectstack/core'; +import { arabicCount, type SettingsCopyLocale } from './settings-copy-locale.js'; + +/** + * The answers `SettingsService.runAction` writes itself, in the caller's + * language: the built-in `reset` action's result, and the refusal for an + * action no plugin registered a handler for. Every other action answer is its + * handler's own. + * + * The console prints an action's `message` as the toast, bare, so the + * sentence is the administrator's whole answer. The settings action route + * hands `runAction` the caller's display language (`Accept-Language`, else + * `?locale`) as `display.locale`; an absent locale, or one naming no language + * here, is answered in English, the sentences every caller got before. + */ +export interface SettingsActionCopy { + /** The built-in reset cleared `count` (> 0) saved values. */ + resetCleared(count: number): string; + /** The built-in reset found no saved value to clear. */ + resetNothing: string; + /** No handler is registered for `actionId` in `namespace`. */ + noHandler(actionId: string, namespace: string): string; +} + +/** One entry per language on the platform locale table ({@link SettingsCopyLocale}). */ +export const SETTINGS_ACTION_COPY: Record = { + en: { + resetCleared: (n) => `Cleared ${n} saved value(s); environment/default configuration is back in effect.`, + resetNothing: 'No saved values to clear — already using environment/default configuration.', + noHandler: (actionId, namespace) => `No handler registered for action '${actionId}' in '${namespace}'.`, + }, + 'zh-CN': { + resetCleared: (n) => `已清除 ${n} 个已保存的值;环境变量/默认配置已重新生效。`, + resetNothing: '没有可清除的已保存值:当前已在使用环境变量/默认配置。', + noHandler: (actionId, namespace) => `“${namespace}”中的操作“${actionId}”没有注册处理程序。`, + }, + 'zh-Hant': { + resetCleared: (n) => `已清除 ${n} 個已儲存的值;環境變數/預設設定已重新生效。`, + resetNothing: '沒有可清除的已儲存值:目前已在使用環境變數/預設設定。', + noHandler: (actionId, namespace) => `「${namespace}」中的操作「${actionId}」沒有註冊處理常式。`, + }, + 'ja-JP': { + resetCleared: (n) => `保存済みの値を ${n} 件消去しました。環境変数/既定の設定が再び有効になっています。`, + resetNothing: '消去する保存済みの値はありません。すでに環境変数/既定の設定を使用しています。', + noHandler: (actionId, namespace) => `「${namespace}」のアクション「${actionId}」にはハンドラーが登録されていません。`, + }, + ko: { + resetCleared: (n) => `저장된 값 ${n}개를 지웠습니다. 환경 변수/기본 설정이 다시 적용됩니다.`, + resetNothing: '지울 저장된 값이 없습니다. 이미 환경 변수/기본 설정을 사용하고 있습니다.', + noHandler: (actionId, namespace) => `'${namespace}'의 '${actionId}' 작업에 등록된 핸들러가 없습니다.`, + }, + 'es-ES': { + resetCleared: (n) => + `Se ${n === 1 ? 'ha borrado 1 valor guardado' : `han borrado ${n} valores guardados`}; vuelve a aplicarse ` + + 'la configuración del entorno o la predeterminada.', + resetNothing: 'No hay valores guardados que borrar: ya se usa la configuración del entorno o la predeterminada.', + noHandler: (actionId, namespace) => + `No hay ningún controlador registrado para la acción «${actionId}» en «${namespace}».`, + }, + ar: { + resetCleared: (n) => + `تم مسح ${arabicCount(n, { + one: 'قيمة محفوظة واحدة', two: 'قيمتين محفوظتين', few: 'قيم محفوظة', many: 'قيمة محفوظة', other: 'قيمة محفوظة', + })}؛ وعاد إعداد البيئة أو الإعداد الافتراضي إلى العمل.`, + resetNothing: 'لا توجد قيم محفوظة لمسحها، فإعداد البيئة أو الإعداد الافتراضي مستخدم بالفعل.', + noHandler: (actionId, namespace) => `لا يوجد معالج مسجّل للإجراء «${actionId}» في «${namespace}».`, + }, + fr: { + resetCleared: (n) => + `${n === 1 ? '1 valeur enregistrée effacée' : `${n} valeurs enregistrées effacées`} ; la configuration de ` + + 'l’environnement ou par défaut s’applique de nouveau.', + resetNothing: 'Aucune valeur enregistrée à effacer : la configuration de l’environnement ou par défaut est déjà utilisée.', + noHandler: (actionId, namespace) => + `Aucun gestionnaire n’est enregistré pour l’action « ${actionId} » dans « ${namespace} ».`, + }, + 'pt-BR': { + resetCleared: (n) => + `${n === 1 ? '1 valor salvo foi apagado' : `${n} valores salvos foram apagados`}; a configuração do ambiente ` + + 'ou padrão voltou a valer.', + resetNothing: 'Não há valores salvos para apagar: a configuração do ambiente ou padrão já está em uso.', + noHandler: (actionId, namespace) => `Nenhum manipulador registrado para a ação "${actionId}" em "${namespace}".`, + }, + ru: { + resetCleared: (n) => + `Очищено сохранённых значений: ${n}; снова действуют настройки из окружения или по умолчанию.`, + resetNothing: 'Нет сохранённых значений для очистки: уже используются настройки из окружения или по умолчанию.', + noHandler: (actionId, namespace) => `Для действия «${actionId}» в «${namespace}» не зарегистрирован обработчик.`, + }, + de: { + resetCleared: (n) => + `${n === 1 ? '1 gespeicherter Wert gelöscht' : `${n} gespeicherte Werte gelöscht`}; die Konfiguration aus ` + + 'der Umgebung bzw. die Standardkonfiguration gilt wieder.', + resetNothing: + 'Keine gespeicherten Werte zu löschen: Es gilt bereits die Konfiguration aus der Umgebung bzw. die ' + + 'Standardkonfiguration.', + noHandler: (actionId, namespace) => `Für die Aktion „${actionId}“ in „${namespace}“ ist kein Handler registriert.`, + }, +}; + +const COPY_LOCALES = Object.keys(SETTINGS_ACTION_COPY); + +/** The copy for a caller's locale: through the platform locale table, else English. */ +export function settingsActionCopy(locale: string | undefined): SettingsActionCopy { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return SETTINGS_ACTION_COPY[(picked ?? 'en') as SettingsCopyLocale]; +} diff --git a/packages/services/service-settings/src/settings-copy-locale.test.ts b/packages/services/service-settings/src/settings-copy-locale.test.ts new file mode 100644 index 00000000000..5444e86ef7f --- /dev/null +++ b/packages/services/service-settings/src/settings-copy-locale.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Every module-local copy table this package writes carries every language on +// the platform locale table, and a caller's code finds its own language's +// entry: `zh-TW` the Traditional one (never the Simplified), a stored alias +// its table language, anything else English. + +import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS, type PlatformLocaleTag } from '@objectstack/spec/system'; +import type { SettingsCopyLocale } from './settings-copy-locale.js'; +import { CURRENCY_LOCK_COPY, currencyLockCopy } from './localization-currency-lock.js'; +import { NOT_OFFERED_COPY, notOfferedCopy } from './localization-locale-options.js'; +import { SETTINGS_ACTION_COPY, settingsActionCopy } from './settings-action-copy.js'; + +/** The key each platform table tag is served by. */ +const KEY_OF_TAG: Record = { + en: 'en', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-Hant', es: 'es-ES', ar: 'ar', fr: 'fr', + 'pt-BR': 'pt-BR', ru: 'ru', de: 'de', ja: 'ja-JP', ko: 'ko', +}; +const KEYS = PLATFORM_LOCALE_TAGS.map((tag) => KEY_OF_TAG[tag]); + +/** Sample arguments per field, so a function field renders a sentence. */ +const SAMPLE_ARGS: Record = { + objects: [2, 'Invoice, Order'], + rates: [14], + refusal: ['USD', '2 objects'], + lockedLabel: ['USD'], + lockedText: ['2 objects'], + unsetText: ['2 objects'], + resetCleared: [3], + noHandler: ['probe', 'branding'], +}; + +/** An entry's sentences, functions called with sample arguments. */ +function sentences(entry: unknown): Record { + if (typeof entry === 'string') return { text: entry }; + const out: Record = {}; + for (const [field, value] of Object.entries(entry as Record)) { + out[field] = typeof value === 'function' ? value(...(SAMPLE_ARGS[field] ?? [])) : String(value); + } + return out; +} + +const TABLES = [ + { name: 'currency lock', table: CURRENCY_LOCK_COPY as Record, lookup: currencyLockCopy as (l?: string) => unknown }, + { name: 'no longer offered', table: NOT_OFFERED_COPY, lookup: notOfferedCopy }, + { name: 'built-in action answers', table: SETTINGS_ACTION_COPY, lookup: settingsActionCopy }, +]; + +/** Separators that are legitimately the same in English and another language. */ +const SHARED_PUNCTUATION = new Set(['and', 'listSeparator']); + +describe.each(TABLES)('$name copy carries the eleven platform languages', ({ table, lookup }) => { + it('has exactly one entry per platform locale table language', () => { + expect(Object.keys(table).sort()).toEqual([...KEYS].sort()); + expect(KEYS).toHaveLength(11); + }); + + it('every entry carries every sentence, written in its own language (no English fill)', () => { + const en = sentences(table.en); + for (const key of KEYS) { + const own = sentences(table[key]); + expect(Object.keys(own).sort(), key).toEqual(Object.keys(en).sort()); + for (const [field, text] of Object.entries(own)) { + if (SHARED_PUNCTUATION.has(field)) continue; + expect(text.trim(), `${key}.${field}`).not.toBe(''); + if (key !== 'en') expect(text, `${key}.${field}`).not.toBe(en[field]); + } + } + }); + + it('each table tag and stored alias is served its own language; zh-TW never the Simplified entry', () => { + for (const tag of PLATFORM_LOCALE_TAGS) expect(lookup(tag), tag).toBe(table[KEY_OF_TAG[tag]]); + for (const [code, key] of [ + ['zh-TW', 'zh-Hant'], ['zh-HK', 'zh-Hant'], ['zh-CN', 'zh-CN'], ['zh', 'zh-CN'], + ['ja-JP', 'ja-JP'], ['es-ES', 'es-ES'], ['pt', 'pt-BR'], ['en-US', 'en'], ['fr-CA', 'fr'], ['de-AT', 'de'], + ] as const) { + expect(lookup(code), code).toBe(table[key]); + } + }); + + it('a code naming no language here, or none at all, is served English', () => { + for (const code of ['xx', 'sw-KE', undefined]) expect(lookup(code), String(code)).toBe(table.en); + }); +}); diff --git a/packages/services/service-settings/src/settings-copy-locale.ts b/packages/services/service-settings/src/settings-copy-locale.ts new file mode 100644 index 00000000000..16922dd300e --- /dev/null +++ b/packages/services/service-settings/src/settings-copy-locale.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The keys of this package's module-local copy tables: sentences composed per + * request (with counts, names or a namespace) in the caller's language, which + * the console's translation bundle cannot carry. The currency lock, the "no + * longer offered" locale option, and the built-in settings action answers. + * + * One key per language on the platform locale table + * (`@objectstack/spec/system` `PLATFORM_LOCALES`): every surface carries every + * language on it. The four languages these tables served first keep the codes + * the settings bundle uses (`zh-CN`, `ja-JP`, `es-ES`); the rest use the + * table's tags. A table looks a caller's code up with core's `resolveLocale`, + * which reads it through the platform table's read aliases, so `zh-TW` finds + * `zh-Hant` and never the Simplified entry, `pt` finds `pt-BR`, and a code + * naming no language here answers `undefined`, which every table serves in + * English. + */ +export type SettingsCopyLocale = + 'en' | 'zh-CN' | 'zh-Hant' | 'es-ES' | 'ar' | 'fr' | 'pt-BR' | 'ru' | 'de' | 'ja-JP' | 'ko'; + +/** A count of `n` in Arabic, given the noun's five forms (1, 2, 3–10, 11–99, else). */ +export function arabicCount( + n: number, + forms: { one: string; two: string; few: string; many: string; other: string }, +): string { + const rest = n % 100; + if (n === 1) return forms.one; + if (n === 2) return forms.two; + if (rest >= 3 && rest <= 10) return `${n} ${forms.few}`; + if (rest >= 11 && rest <= 99) return `${n} ${forms.many}`; + return `${n} ${forms.other}`; +} + +/** A count of `n` in Russian, given the noun's three forms (1, 2–4, 5–20 and the rest). */ +export function russianCount(n: number, forms: { one: string; few: string; many: string }): string { + const rest10 = n % 10; + const rest100 = n % 100; + if (rest10 === 1 && rest100 !== 11) return `${n} ${forms.one}`; + if (rest10 >= 2 && rest10 <= 4 && (rest100 < 12 || rest100 > 14)) return `${n} ${forms.few}`; + return `${n} ${forms.many}`; +} diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index b3e1b7349bb..522629816db 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -407,6 +407,37 @@ describe('SettingsService — resetNamespace / built-in reset action', () => { }); }); +describe('SettingsService — the built-in action answers are written in the caller\'s language', () => { + it('the built-in reset answers in display.locale: zh-TW in Traditional, ru in Russian', async () => { + const svc = new SettingsService({ env: {} }); + svc.registerManifest(brandingSettingsManifest); + await svc.set('branding', 'workspace_name', 'Acme'); + const cleared = await svc.runAction('branding', 'reset', null, {}, { locale: 'zh-TW' }); + expect(cleared).toMatchObject({ ok: true, severity: 'info', message: '已清除 1 個已儲存的值;環境變數/預設設定已重新生效。' }); + expect((await svc.get('branding', 'workspace_name')).source).toBe('default'); + + const nothing = await svc.runAction('branding', 'reset', null, {}, { locale: 'ru' }); + expect(nothing).toMatchObject({ + ok: true, + message: 'Нет сохранённых значений для очистки: уже используются настройки из окружения или по умолчанию.', + }); + }); + + it('an action with no handler is refused in display.locale; no locale, or one off the table, keeps English', async () => { + const svc = new SettingsService(); + svc.registerManifest(mailSettingsManifest); + expect(await svc.runAction('mail', 'nope', null, {}, { locale: 'de-DE' })).toMatchObject({ + ok: false, + severity: 'error', + message: 'Für die Aktion „nope“ in „mail“ ist kein Handler registriert.', + }); + for (const display of [{}, { locale: 'sw-KE' }]) { + expect((await svc.runAction('mail', 'nope', null, {}, display)).message) + .toBe("No handler registered for action 'nope' in 'mail'."); + } + }); +}); + describe('SettingsService — save-time validation (required/visible/pattern)', () => { function aiService(): SettingsService { // #8026 — same reason as `mailService` below: the provider configs these diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index e015a681c00..a7bf275a7eb 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -64,6 +64,7 @@ import { } from '@objectstack/core'; import { SETTINGS_SECRET_MASK } from './settings-secret-redaction.js'; import { USER_OBJECT, assertUserReferenceResolves, registeredLabel } from './actor-reference.js'; +import { settingsActionCopy } from './settings-action-copy.js'; import { firstRejectedDomainMember, knownValueDomain, @@ -3365,20 +3366,22 @@ export class SettingsService { // clears persisted rows (back to env/defaults). Plugins may // override it via registerAction for richer behaviour (e.g. the // AI plugin re-runs env adapter detection after the clear). + // + // Both answers are written in the caller's language (`display.locale`, + // `settingsActionCopy`), as a handler's own answer is. + const copy = settingsActionCopy(display.locale); if (actionId === 'reset') { const cleared = await this.resetNamespace(namespace, ctx); return { ok: true, severity: 'info', - message: cleared > 0 - ? `Cleared ${cleared} saved value(s); environment/default configuration is back in effect.` - : 'No saved values to clear — already using environment/default configuration.', + message: cleared > 0 ? copy.resetCleared(cleared) : copy.resetNothing, }; } return { ok: false, severity: 'error', - message: `No handler registered for action '${actionId}' in '${namespace}'.`, + message: copy.noHandler(actionId, namespace), }; } const values: Record = {}; diff --git a/packages/services/service-sms/src/sms-plugin.ts b/packages/services/service-sms/src/sms-plugin.ts index 58d2658b48f..35dba2d42f8 100644 --- a/packages/services/service-sms/src/sms-plugin.ts +++ b/packages/services/service-sms/src/sms-plugin.ts @@ -6,6 +6,7 @@ import { createLazyCounterStore } from '@objectstack/plugin-auth/rate-limit-stor import { resolveKernelCache } from './kernel-cache.js'; import { SmsService, LogSmsTransport, maskPhoneNumber, normalizeSmsRecipient } from './sms-service.js'; import { SmsDailyQuota } from './sms-daily-quota.js'; +import { smsTestCopy, type SmsTestCopy } from './sms-test-copy.js'; import { makeSmsTransport, smsProviderOptionsFrom, type SmsProviderTag } from './transports/index.js'; /** @@ -83,32 +84,26 @@ const SYSTEM_CTX = { isSystem: true } as const; * Every refusal is admin copy the settings page shows: it names what the admin * does (press Send test SMS signed in as an admin whose account has a number), * never a request field or an endpoint. The `to` override is for API callers - * and is documented here, not in the refusal. + * and is documented here, not in the refusal. Each is written in the caller's + * language (`copy`, {@link smsTestCopy}). */ async function resolveSmsTestRecipient( payload: unknown, userId: unknown, getEngine: () => IDataEngine | undefined, + copy: SmsTestCopy, ): Promise<{ to: string } | { refusal: string }> { const named = payload && typeof payload === 'object' ? (payload as { to?: unknown }).to : undefined; if (typeof named === 'string' && named.trim() !== '') { const to = normalizeSmsRecipient(named); - return to ? { to } : { refusal: 'Provide a valid "to" phone number (E.164 recommended).' }; + return to ? { to } : { refusal: copy.invalidTo }; } if (typeof userId !== 'string' || userId === '') { - return { - refusal: 'No recipient — nothing was sent. The test SMS goes to the signed-in admin\'s own phone number, ' - + 'and no one is signed in on this call. Sign in as an admin whose account has a phone number, then ' - + 'press Send test SMS again.', - }; + return { refusal: copy.noSignedIn }; } const engine = getEngine(); if (!engine) { - return { - refusal: 'No data engine is available to read your account\'s phone number — nothing was sent. ' - + 'The test SMS goes to the signed-in admin\'s own number, so it can be sent only once that number ' - + 'can be read; press Send test SMS again when the data service is up.', - }; + return { refusal: copy.noEngine }; } let row: Record | null; try { @@ -118,20 +113,12 @@ async function resolveSmsTestRecipient( { context: SYSTEM_CTX }, ); } catch (err: any) { - return { - refusal: `Could not read your account's phone number to send the test SMS to (${err?.message ?? String(err)}) ` - + '— nothing was sent. The test SMS goes to the signed-in admin\'s own number; press Send test SMS ' - + 'again once your account can be read.', - }; + return { refusal: copy.readFailed(err?.message ?? String(err)) }; } const raw = row?.phone_number; const to = typeof raw === 'string' ? normalizeSmsRecipient(raw) : undefined; if (!to) { - return { - refusal: 'Your account has no phone number, and the test SMS is sent to the signed-in admin\'s own number ' - + '— nothing was sent. A number is on an account only where phone sign-in is enabled: press Send test SMS ' - + 'while signed in as an admin whose account has a phone number.', - }; + return { refusal: copy.noNumber }; } return { to }; } @@ -326,12 +313,16 @@ export class SmsServicePlugin implements Plugin { // number — see `resolveSmsTestRecipient`. if (!this.options.transport && typeof settings.registerAction === 'function') { const svc = this.service; - settings.registerAction('sms', 'test', async ({ values, payload, ctx: actionCtx }: any) => { + settings.registerAction('sms', 'test', async ({ values, payload, ctx: actionCtx, locale }: any) => { + // The answer is the toast the administrator reads: written in the + // caller's display language (English when none is given), as the + // `mail/test` and `storage/test` answers are. + const copy = smsTestCopy(typeof locale === 'string' ? locale : undefined); const overrides = (payload && typeof payload === 'object' && payload.values && typeof payload.values === 'object') ? payload.values : (payload ?? {}); const merged: Record = { ...(values ?? {}), ...overrides }; - const recipient = await resolveSmsTestRecipient(payload, actionCtx?.userId, () => resolveDataEngine(ctx)); + const recipient = await resolveSmsTestRecipient(payload, actionCtx?.userId, () => resolveDataEngine(ctx), copy); if ('refusal' in recipient) { return { ok: false, severity: 'error', message: recipient.refusal }; } @@ -339,7 +330,7 @@ export class SmsServicePlugin implements Plugin { const resolved = providerFromSettings(merged); if (resolved.missing) { - return { ok: false, severity: 'error', message: `${resolved.provider}: missing ${resolved.missing}.` }; + return { ok: false, severity: 'error', message: copy.missing(resolved.provider, resolved.missing) }; } let target: SmsService = svc; @@ -348,7 +339,7 @@ export class SmsServicePlugin implements Plugin { const transport = makeSmsTransport({ provider: resolved.provider, options: resolved.options, logger: ctx.logger }); target = new SmsService({ transport, configured: true, logger: ctx.logger }); } catch (err: any) { - return { ok: false, severity: 'error', message: `Failed to build ${resolved.provider} transport: ${err?.message ?? String(err)}` }; + return { ok: false, severity: 'error', message: copy.buildFailed(resolved.provider, err?.message ?? String(err)) }; } } // A LogSmsTransport "send" is not a delivery — say so instead of @@ -359,8 +350,7 @@ export class SmsServicePlugin implements Plugin { return { ok: false, severity: 'warning', - message: 'No SMS delivery transport is active (provider: None — log only), so the test SMS was NOT sent. ' - + 'Pick a provider, fill in its credentials and save before testing.', + message: copy.logOnly, }; } @@ -371,19 +361,19 @@ export class SmsServicePlugin implements Plugin { templateParams: { content: 'ObjectStack SMS test message.' }, }); if (result.status === 'failed') { - return { ok: false, severity: 'error', message: result.error ?? 'Send failed.' }; + return { ok: false, severity: 'error', message: copy.failed(result.error) }; } // Name the transport that actually carried it. When the form // names no delivering provider, the live service did — the one // the deployment configured at boot, never `log` (refused above). - const via = target === svc ? 'the deployment\'s configured transport' : resolved.provider; + const via = target === svc ? undefined : resolved.provider; return { ok: true, severity: 'info', - message: `Sent test SMS to ${maskPhoneNumber(to)} via ${via} (id=${result.messageId ?? result.id}).`, + message: copy.sent(maskPhoneNumber(to), via, String(result.messageId ?? result.id)), }; } catch (err: any) { - return { ok: false, severity: 'error', message: err?.message ?? String(err) }; + return { ok: false, severity: 'error', message: copy.failed(err?.message ?? String(err)) }; } }); } diff --git a/packages/services/service-sms/src/sms-test-copy.test.ts b/packages/services/service-sms/src/sms-test-copy.test.ts new file mode 100644 index 00000000000..bc32b2d8dbe --- /dev/null +++ b/packages/services/service-sms/src/sms-test-copy.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The `sms/test` answer carries every language on the platform locale table, +// and a caller's code finds its own language's entry: `zh-TW` the Traditional +// one (never the Simplified), a stored alias its table language, anything else +// English. + +import { describe, it, expect } from 'vitest'; +import { PLATFORM_LOCALE_TAGS, type PlatformLocaleTag } from '@objectstack/spec/system'; +import { SMS_TEST_COPY, smsTestCopy, type SmsTestCopy, type SmsTestCopyLocale } from './sms-test-copy.js'; + +const KEY_OF_TAG: Record = { + en: 'en', 'zh-Hans': 'zh-CN', 'zh-Hant': 'zh-Hant', es: 'es-ES', ar: 'ar', fr: 'fr', + 'pt-BR': 'pt-BR', ru: 'ru', de: 'de', ja: 'ja-JP', ko: 'ko', +}; +const KEYS = PLATFORM_LOCALE_TAGS.map((tag) => KEY_OF_TAG[tag]); + +/** Every sentence of an entry, its functions called with sample arguments. */ +const sentences = (copy: SmsTestCopy): Record => ({ + invalidTo: copy.invalidTo, + noSignedIn: copy.noSignedIn, + noEngine: copy.noEngine, + readFailed: copy.readFailed('sys_user is unavailable'), + noNumber: copy.noNumber, + missing: copy.missing('twilio', 'twilio_auth_token'), + buildFailed: copy.buildFailed('aliyun', 'bad endpoint'), + logOnly: copy.logOnly, + sentVia: copy.sent('+86 ••• 0001', 'twilio', 'SM_1'), + sentConfigured: copy.sent('+86 ••• 0001', undefined, 'SM_1'), + failedWithReason: copy.failed('Twilio said no'), + failedBare: copy.failed(undefined), +}); + +describe('sms/test copy carries the eleven platform languages', () => { + it('has exactly one entry per platform locale table language', () => { + expect(Object.keys(SMS_TEST_COPY).sort()).toEqual([...KEYS].sort()); + expect(KEYS).toHaveLength(11); + }); + + it('every entry carries every sentence, written in its own language (no English fill)', () => { + const en = sentences(SMS_TEST_COPY.en); + for (const key of KEYS) { + for (const [field, text] of Object.entries(sentences(SMS_TEST_COPY[key]))) { + expect(text.trim(), `${key}.${field}`).not.toBe(''); + if (key !== 'en') expect(text, `${key}.${field}`).not.toBe(en[field]); + } + } + }); + + it('each table tag and stored alias is served its own language; zh-TW never the Simplified entry', () => { + for (const tag of PLATFORM_LOCALE_TAGS) expect(smsTestCopy(tag), tag).toBe(SMS_TEST_COPY[KEY_OF_TAG[tag]]); + for (const [code, key] of [ + ['zh-TW', 'zh-Hant'], ['zh-HK', 'zh-Hant'], ['zh-CN', 'zh-CN'], ['zh', 'zh-CN'], + ['ja-JP', 'ja-JP'], ['es-ES', 'es-ES'], ['pt', 'pt-BR'], ['en-US', 'en'], ['fr-CA', 'fr'], + ] as const) { + expect(smsTestCopy(code), code).toBe(SMS_TEST_COPY[key]); + } + expect(smsTestCopy('zh-TW')).not.toBe(SMS_TEST_COPY['zh-CN']); + }); + + it('a code naming no language here, or none at all, is served English', () => { + for (const code of ['xx', 'sw-KE', undefined]) expect(smsTestCopy(code), String(code)).toBe(SMS_TEST_COPY.en); + }); + + it('the English sentences are the ones every caller read before', () => { + const en = SMS_TEST_COPY.en; + expect(en.missing('twilio', 'twilio_auth_token')).toBe('twilio: missing twilio_auth_token.'); + expect(en.sent('+86 ••• 0001', undefined, 'SM_1')) + .toBe('Sent test SMS to +86 ••• 0001 via the deployment\'s configured transport (id=SM_1).'); + expect(en.failed(undefined)).toBe('Send failed.'); + expect(en.failed('Twilio said no')).toBe('Twilio said no'); + }); +}); diff --git a/packages/services/service-sms/src/sms-test-copy.ts b/packages/services/service-sms/src/sms-test-copy.ts new file mode 100644 index 00000000000..1ca72f39e79 --- /dev/null +++ b/packages/services/service-sms/src/sms-test-copy.ts @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { resolveLocale } from '@objectstack/core'; + +/** + * The sentences Setup → SMS → "Send test SMS" (`sms/test`) answers with, one + * entry per language on the platform locale table + * (`@objectstack/spec/system` `PLATFORM_LOCALES`). + * + * The console prints the action's `message` as the toast, bare, so the + * sentence is the administrator's whole answer. The settings action route + * hands the handler the caller's display language (`Accept-Language`, else + * `?locale`) as the input's `locale`; an absent locale, or one naming no + * language here, is answered in English, the sentence every caller got before. + * The four languages the settings bundle ships keep its codes (`zh-CN`, + * `ja-JP`, `es-ES`); the rest use the table's tags. Core's `resolveLocale` + * reads a caller's code through that table, so `zh-TW` finds `zh-Hant`, never + * the Simplified entry. + * + * Only the sentences the product writes live here. A provider's or the data + * engine's own error text is passed through verbatim: on its own in English, + * as it always was; in another language behind a lead-in that says what + * failed. The operator log lines stay English. + */ +export interface SmsTestCopy { + /** An API caller's `to` is not a phone number. */ + invalidTo: string; + /** No one is signed in on the call, so there is no admin number to send to. */ + noSignedIn: string; + /** No data engine is up to read the caller's number. */ + noEngine: string; + /** Reading the caller's number failed; `reason` is the engine's own text. */ + readFailed(reason: string): string; + /** The caller's account carries no phone number. */ + noNumber: string; + /** The form's provider lacks a credential (`key`, the settings key). */ + missing(provider: string, key: string): string; + /** The provider's transport could not be built; `reason` is its own text. */ + buildFailed(provider: string, reason: string): string; + /** The live transport is the log, so nothing was sent. */ + logOnly: string; + /** Sent to the masked number; `via` the form's provider, absent for the deployment's configured transport. */ + sent(to: string, via: string | undefined, id: string): string; + /** The send failed; `reason` is the provider's own text, when it gave one. */ + failed(reason: string | undefined): string; +} + +/** The table's keys: the platform locale table's languages, three under the settings bundle's codes. */ +export type SmsTestCopyLocale = + 'en' | 'zh-CN' | 'zh-Hant' | 'es-ES' | 'ar' | 'fr' | 'pt-BR' | 'ru' | 'de' | 'ja-JP' | 'ko'; + +export const SMS_TEST_COPY: Record = { + en: { + invalidTo: 'Provide a valid "to" phone number (E.164 recommended).', + noSignedIn: 'No recipient — nothing was sent. The test SMS goes to the signed-in admin\'s own phone number, ' + + 'and no one is signed in on this call. Sign in as an admin whose account has a phone number, then ' + + 'press Send test SMS again.', + noEngine: 'No data engine is available to read your account\'s phone number — nothing was sent. ' + + 'The test SMS goes to the signed-in admin\'s own number, so it can be sent only once that number ' + + 'can be read; press Send test SMS again when the data service is up.', + readFailed: (reason) => `Could not read your account's phone number to send the test SMS to (${reason}) ` + + '— nothing was sent. The test SMS goes to the signed-in admin\'s own number; press Send test SMS ' + + 'again once your account can be read.', + noNumber: 'Your account has no phone number, and the test SMS is sent to the signed-in admin\'s own number ' + + '— nothing was sent. A number is on an account only where phone sign-in is enabled: press Send test SMS ' + + 'while signed in as an admin whose account has a phone number.', + missing: (provider, key) => `${provider}: missing ${key}.`, + buildFailed: (provider, reason) => `Failed to build ${provider} transport: ${reason}`, + logOnly: 'No SMS delivery transport is active (provider: None — log only), so the test SMS was NOT sent. ' + + 'Pick a provider, fill in its credentials and save before testing.', + sent: (to, via, id) => `Sent test SMS to ${to} via ${via ?? 'the deployment\'s configured transport'} (id=${id}).`, + failed: (reason) => reason ?? 'Send failed.', + }, + 'zh-CN': { + invalidTo: '请提供有效的 "to" 手机号码(建议使用 E.164 格式)。', + noSignedIn: '没有收件人,未发送任何短信。测试短信会发到当前登录管理员自己的手机号,而这次调用没有登录用户。' + + '请以账户里有手机号的管理员身份登录,再点击「发送测试短信」。', + noEngine: '没有可用的数据引擎来读取你账户的手机号,未发送任何短信。测试短信会发到当前登录管理员自己的手机号,' + + '只有能读到该号码时才能发送;请在数据服务可用后再点击「发送测试短信」。', + readFailed: (reason) => `无法读取你账户的手机号来发送测试短信(${reason}),未发送任何短信。` + + '测试短信会发到当前登录管理员自己的手机号;请在能读取你的账户后再点击「发送测试短信」。', + noNumber: '你的账户没有手机号,而测试短信会发到当前登录管理员自己的手机号,因此未发送任何短信。' + + '只有启用了手机号登录,账户上才会有号码:请以账户里有手机号的管理员身份登录后再点击「发送测试短信」。', + missing: (provider, key) => `${provider}:缺少 ${key}。`, + buildFailed: (provider, reason) => `无法创建 ${provider} 投递方式:${reason}`, + logOnly: '当前没有可用的短信投递方式(服务商:无,仅记录日志),因此测试短信没有发送。' + + '请先选择服务商、填写其凭据并保存,再进行测试。', + sent: (to, via, id) => `测试短信已发送至 ${to},经由${via ? ` ${via}` : '部署配置的投递方式'}(id=${id})。`, + failed: (reason) => (reason ? `测试短信发送失败:${reason}` : '测试短信发送失败。'), + }, + 'zh-Hant': { + invalidTo: '請提供有效的 "to" 手機號碼(建議使用 E.164 格式)。', + noSignedIn: '沒有收件人,未傳送任何簡訊。測試簡訊會傳到目前登入管理員自己的手機號碼,而這次呼叫沒有登入使用者。' + + '請以帳戶中有手機號碼的管理員身分登入,再按一次傳送測試簡訊。', + noEngine: '沒有可用的資料引擎來讀取你帳戶的手機號碼,未傳送任何簡訊。測試簡訊會傳到目前登入管理員自己的手機號碼,' + + '只有能讀到該號碼時才能傳送;請在資料服務可用後再按一次傳送測試簡訊。', + readFailed: (reason) => `無法讀取你帳戶的手機號碼來傳送測試簡訊(${reason}),未傳送任何簡訊。` + + '測試簡訊會傳到目前登入管理員自己的手機號碼;請在能讀取你的帳戶後再按一次傳送測試簡訊。', + noNumber: '你的帳戶沒有手機號碼,而測試簡訊會傳到目前登入管理員自己的手機號碼,因此未傳送任何簡訊。' + + '只有啟用了手機號碼登入,帳戶上才會有號碼:請以帳戶中有手機號碼的管理員身分登入後再按傳送測試簡訊。', + missing: (provider, key) => `${provider}:缺少 ${key}。`, + buildFailed: (provider, reason) => `無法建立 ${provider} 傳送方式:${reason}`, + logOnly: '目前沒有可用的簡訊傳送方式(服務商:無,僅記錄),因此測試簡訊沒有傳送。' + + '請先選擇服務商、填寫其憑證並儲存,再進行測試。', + sent: (to, via, id) => `測試簡訊已傳送至 ${to},經由${via ? ` ${via}` : '部署設定的傳送方式'}(id=${id})。`, + failed: (reason) => (reason ? `測試簡訊傳送失敗:${reason}` : '測試簡訊傳送失敗。'), + }, + 'ja-JP': { + invalidTo: '有効な "to" 電話番号を指定してください(E.164 形式を推奨)。', + noSignedIn: '宛先がないため、何も送信されていません。テスト SMS はサインイン中の管理者自身の電話番号に送られますが、' + + 'この呼び出しには誰もサインインしていません。電話番号が登録されたアカウントの管理者としてサインインし、' + + 'もう一度「テスト SMS を送信」を押してください。', + noEngine: 'アカウントの電話番号を読み取るデータエンジンがないため、何も送信されていません。テスト SMS はサインイン中の' + + '管理者自身の番号に送られるため、その番号を読み取れるようになるまで送信できません。データサービスが起動してから、' + + 'もう一度「テスト SMS を送信」を押してください。', + readFailed: (reason) => `テスト SMS の送信先としてアカウントの電話番号を読み取れませんでした(${reason})。` + + '何も送信されていません。テスト SMS はサインイン中の管理者自身の番号に送られます。アカウントを読み取れるように' + + 'なってから、もう一度「テスト SMS を送信」を押してください。', + noNumber: 'アカウントに電話番号がなく、テスト SMS はサインイン中の管理者自身の番号に送られるため、何も送信されていません。' + + '電話番号ログインが有効な場合にのみアカウントに番号が登録されます。電話番号が登録されたアカウントの管理者として' + + 'サインインした状態で「テスト SMS を送信」を押してください。', + missing: (provider, key) => `${provider}: ${key} が指定されていません。`, + buildFailed: (provider, reason) => `${provider} の配信方法を作成できませんでした: ${reason}`, + logOnly: '有効な SMS 配信方法がない(プロバイダー: なし — ログのみ)ため、テスト SMS は送信されていません。' + + 'テストする前にプロバイダーを選び、認証情報を入力して保存してください。', + sent: (to, via, id) => `テスト SMS を ${to} に送信しました(${via ?? 'デプロイで設定された配信方法'} 経由、id=${id})。`, + failed: (reason) => (reason ? `テスト SMS の送信に失敗しました: ${reason}` : 'テスト SMS の送信に失敗しました。'), + }, + ko: { + invalidTo: '유효한 "to" 전화번호를 입력하세요(E.164 형식 권장).', + noSignedIn: '수신자가 없어 아무것도 보내지 않았습니다. 테스트 SMS는 로그인한 관리자 본인의 전화번호로 보내지는데, ' + + '이 호출에는 로그인한 사용자가 없습니다. 계정에 전화번호가 있는 관리자로 로그인한 뒤 테스트 SMS 보내기 버튼을 ' + + '다시 누르세요.', + noEngine: '계정의 전화번호를 읽을 데이터 엔진이 없어 아무것도 보내지 않았습니다. 테스트 SMS는 로그인한 관리자 본인의 ' + + '번호로 보내지므로, 그 번호를 읽을 수 있어야 보낼 수 있습니다. 데이터 서비스가 실행되면 테스트 SMS 보내기 버튼을 ' + + '다시 누르세요.', + readFailed: (reason) => `테스트 SMS를 보낼 계정 전화번호를 읽을 수 없어(${reason}) 아무것도 보내지 않았습니다. ` + + '테스트 SMS는 로그인한 관리자 본인의 번호로 보내집니다. 계정을 읽을 수 있게 되면 테스트 SMS 보내기 버튼을 다시 ' + + '누르세요.', + noNumber: '계정에 전화번호가 없고 테스트 SMS는 로그인한 관리자 본인의 번호로 보내지므로 아무것도 보내지 않았습니다. ' + + '전화번호 로그인이 켜져 있는 곳에서만 계정에 번호가 있습니다. 계정에 전화번호가 있는 관리자로 로그인한 상태에서 ' + + '테스트 SMS 보내기 버튼을 누르세요.', + missing: (provider, key) => `${provider}: ${key}이(가) 없습니다.`, + buildFailed: (provider, reason) => `${provider} 전송 방식을 만들지 못했습니다: ${reason}`, + logOnly: '활성화된 SMS 전달 방식이 없어(공급자: 없음 — 로그만) 테스트 SMS를 보내지 않았습니다. 테스트하기 전에 ' + + '공급자를 선택하고 자격 증명을 입력한 뒤 저장하세요.', + sent: (to, via, id) => `테스트 SMS를 ${to}(으)로 보냈습니다(${via ?? '배포에 설정된 전송 방식'} 사용, id=${id}).`, + failed: (reason) => (reason ? `테스트 SMS 발송에 실패했습니다: ${reason}` : '테스트 SMS 발송에 실패했습니다.'), + }, + 'es-ES': { + invalidTo: 'Indique un número de teléfono "to" válido (se recomienda E.164).', + noSignedIn: 'No hay destinatario: no se ha enviado nada. El SMS de prueba se envía al número de teléfono del propio ' + + 'administrador que ha iniciado sesión, y en esta llamada no hay nadie con la sesión iniciada. Inicie sesión como ' + + 'un administrador cuya cuenta tenga número de teléfono y vuelva a pulsar «Enviar SMS de prueba».', + noEngine: 'No hay ningún motor de datos disponible para leer el número de teléfono de su cuenta: no se ha enviado ' + + 'nada. El SMS de prueba se envía al número del propio administrador que ha iniciado sesión, así que solo puede ' + + 'enviarse cuando ese número se pueda leer; vuelva a pulsar «Enviar SMS de prueba» cuando el servicio de datos ' + + 'esté disponible.', + readFailed: (reason) => 'No se pudo leer el número de teléfono de su cuenta para enviarle el SMS de prueba ' + + `(${reason}): no se ha enviado nada. El SMS de prueba se envía al número del propio administrador que ha ` + + 'iniciado sesión; vuelva a pulsar «Enviar SMS de prueba» cuando se pueda leer su cuenta.', + noNumber: 'Su cuenta no tiene número de teléfono y el SMS de prueba se envía al número del propio administrador ' + + 'que ha iniciado sesión: no se ha enviado nada. Una cuenta solo tiene número donde está habilitado el inicio de ' + + 'sesión por teléfono: pulse «Enviar SMS de prueba» con la sesión iniciada como un administrador cuya cuenta ' + + 'tenga número de teléfono.', + missing: (provider, key) => `${provider}: falta ${key}.`, + buildFailed: (provider, reason) => `No se pudo crear el transporte ${provider}: ${reason}`, + logOnly: 'No hay ningún transporte de entrega de SMS activo (proveedor: Ninguno — solo registro), así que el SMS de ' + + 'prueba NO se ha enviado. Elija un proveedor, complete sus credenciales y guarde antes de probar.', + sent: (to, via, id) => + `SMS de prueba enviado a ${to} ${via ? `a través de ${via}` : 'a través del transporte configurado en la implementación'} (id=${id}).`, + failed: (reason) => (reason ? `El envío del SMS de prueba ha fallado: ${reason}` : 'El envío del SMS de prueba ha fallado.'), + }, + ar: { + invalidTo: 'أدخل رقم هاتف صالحًا في "to" (يُوصى بصيغة E.164).', + noSignedIn: 'لا يوجد مستلم، ولم يُرسل أي شيء. تُرسل رسالة SMS التجريبية إلى رقم هاتف المسؤول المسجّل دخوله نفسه، ' + + 'ولا يوجد مستخدم مسجّل دخوله في هذا الطلب. سجّل الدخول بحساب مسؤول يحتوي على رقم هاتف، ثم اضغط زر إرسال رسالة ' + + 'SMS التجريبية مرة أخرى.', + noEngine: 'لا يتوفر محرك بيانات لقراءة رقم هاتف حسابك، ولم يُرسل أي شيء. تُرسل رسالة SMS التجريبية إلى رقم المسؤول ' + + 'المسجّل دخوله نفسه، لذا لا يمكن إرسالها إلا بعد أن تصبح قراءة هذا الرقم ممكنة؛ اضغط زر إرسال رسالة SMS ' + + 'التجريبية مرة أخرى عندما تعمل خدمة البيانات.', + readFailed: (reason) => `تعذّرت قراءة رقم هاتف حسابك لإرسال رسالة SMS التجريبية إليه (${reason})، ولم يُرسل أي ` + + 'شيء. تُرسل رسالة SMS التجريبية إلى رقم المسؤول المسجّل دخوله نفسه؛ اضغط زر إرسال رسالة SMS التجريبية مرة ' + + 'أخرى عندما تصبح قراءة حسابك ممكنة.', + noNumber: 'لا يحتوي حسابك على رقم هاتف، وتُرسل رسالة SMS التجريبية إلى رقم المسؤول المسجّل دخوله نفسه، لذا لم ' + + 'يُرسل أي شيء. لا يحمل الحساب رقمًا إلا حيث يكون تسجيل الدخول برقم الهاتف مفعّلًا: اضغط زر إرسال رسالة SMS ' + + 'التجريبية وأنت مسجّل الدخول بحساب مسؤول يحتوي على رقم هاتف.', + missing: (provider, key) => `${provider}: ${key} مفقود.`, + buildFailed: (provider, reason) => `تعذّر إنشاء وسيلة النقل ${provider}: ${reason}`, + logOnly: 'لا توجد وسيلة فعّالة لتسليم رسائل SMS (الموفّر: لا شيء، التسجيل فقط)، لذا لم تُرسل رسالة SMS ' + + 'التجريبية. اختر موفّرًا واملأ بيانات اعتماده واحفظ قبل الاختبار.', + sent: (to, via, id) => `أُرسلت رسالة SMS التجريبية إلى ${to} عبر ${via ?? 'وسيلة النقل المضبوطة في النشر'} (id=${id}).`, + failed: (reason) => (reason ? `فشل إرسال رسالة SMS التجريبية: ${reason}` : 'فشل إرسال رسالة SMS التجريبية.'), + }, + fr: { + invalidTo: 'Indiquez un numéro de téléphone « to » valide (format E.164 recommandé).', + noSignedIn: 'Aucun destinataire : rien n’a été envoyé. Le SMS de test est envoyé au numéro de téléphone de ' + + 'l’administrateur connecté lui-même, et personne n’est connecté pour cet appel. Connectez-vous en tant ' + + 'qu’administrateur dont le compte a un numéro de téléphone, puis appuyez de nouveau sur le bouton d’envoi du ' + + 'SMS de test.', + noEngine: 'Aucun moteur de données n’est disponible pour lire le numéro de téléphone de votre compte : rien n’a été ' + + 'envoyé. Le SMS de test est envoyé au numéro de l’administrateur connecté lui-même ; il ne peut donc partir ' + + 'qu’une fois ce numéro lisible. Appuyez de nouveau sur le bouton d’envoi du SMS de test quand le service de ' + + 'données sera disponible.', + readFailed: (reason) => 'Impossible de lire le numéro de téléphone de votre compte pour y envoyer le SMS de test ' + + `(${reason}) : rien n’a été envoyé. Le SMS de test est envoyé au numéro de l’administrateur connecté lui-même ; ` + + 'appuyez de nouveau sur le bouton d’envoi du SMS de test une fois votre compte lisible.', + noNumber: 'Votre compte n’a pas de numéro de téléphone, et le SMS de test est envoyé au numéro de l’administrateur ' + + 'connecté lui-même : rien n’a été envoyé. Un compte n’a de numéro que là où la connexion par téléphone est ' + + 'activée : appuyez sur le bouton d’envoi du SMS de test en étant connecté en tant qu’administrateur dont le ' + + 'compte a un numéro de téléphone.', + missing: (provider, key) => `${provider} : ${key} manquant.`, + buildFailed: (provider, reason) => `Impossible de créer le transport ${provider} : ${reason}`, + logOnly: 'Aucun transport de remise de SMS n’est actif (fournisseur : aucun, journal uniquement), le SMS de test ' + + 'n’a donc PAS été envoyé. Choisissez un fournisseur, renseignez ses identifiants et enregistrez avant de tester.', + sent: (to, via, id) => + `SMS de test envoyé à ${to} ${via ? `via ${via}` : 'par le transport configuré pour le déploiement'} (id=${id}).`, + failed: (reason) => (reason ? `L’envoi du SMS de test a échoué : ${reason}` : 'L’envoi du SMS de test a échoué.'), + }, + 'pt-BR': { + invalidTo: 'Informe um número de telefone "to" válido (recomenda-se o formato E.164).', + noSignedIn: 'Nenhum destinatário: nada foi enviado. O SMS de teste vai para o número de telefone do próprio ' + + 'administrador conectado, e ninguém está conectado nesta chamada. Entre como um administrador cuja conta tenha ' + + 'número de telefone e clique novamente no botão de envio do SMS de teste.', + noEngine: 'Nenhum mecanismo de dados está disponível para ler o número de telefone da sua conta: nada foi enviado. ' + + 'O SMS de teste vai para o número do próprio administrador conectado, então só pode ser enviado quando esse ' + + 'número puder ser lido; clique novamente no botão de envio do SMS de teste quando o serviço de dados estiver ' + + 'disponível.', + readFailed: (reason) => `Não foi possível ler o número de telefone da sua conta para enviar o SMS de teste (${reason}): ` + + 'nada foi enviado. O SMS de teste vai para o número do próprio administrador conectado; clique novamente no ' + + 'botão de envio do SMS de teste quando a sua conta puder ser lida.', + noNumber: 'A sua conta não tem número de telefone, e o SMS de teste vai para o número do próprio administrador ' + + 'conectado: nada foi enviado. Uma conta só tem número onde o login por telefone está ativado: clique no botão ' + + 'de envio do SMS de teste conectado como um administrador cuja conta tenha número de telefone.', + missing: (provider, key) => `${provider}: falta ${key}.`, + buildFailed: (provider, reason) => `Não foi possível criar o transporte ${provider}: ${reason}`, + logOnly: 'Nenhum transporte de entrega de SMS está ativo (provedor: nenhum, apenas registro), então o SMS de teste ' + + 'NÃO foi enviado. Escolha um provedor, preencha as credenciais dele e salve antes de testar.', + sent: (to, via, id) => + `SMS de teste enviado para ${to} ${via ? `por meio de ${via}` : 'por meio do transporte configurado na implantação'} (id=${id}).`, + failed: (reason) => (reason ? `O envio do SMS de teste falhou: ${reason}` : 'O envio do SMS de teste falhou.'), + }, + ru: { + invalidTo: 'Укажите действительный номер телефона в поле "to" (рекомендуется формат E.164).', + noSignedIn: 'Нет получателя — ничего не отправлено. Тестовое SMS отправляется на номер телефона самого вошедшего ' + + 'администратора, а в этом вызове никто не вошёл в систему. Войдите как администратор, у учётной записи которого ' + + 'есть номер телефона, и снова нажмите кнопку отправки тестового SMS.', + noEngine: 'Нет механизма данных, чтобы прочитать номер телефона вашей учётной записи, — ничего не отправлено. ' + + 'Тестовое SMS отправляется на номер самого вошедшего администратора, поэтому его можно отправить, только когда ' + + 'этот номер удастся прочитать; снова нажмите кнопку отправки тестового SMS, когда служба данных заработает.', + readFailed: (reason) => 'Не удалось прочитать номер телефона вашей учётной записи, чтобы отправить на него ' + + `тестовое SMS (${reason}), — ничего не отправлено. Тестовое SMS отправляется на номер самого вошедшего ` + + 'администратора; снова нажмите кнопку отправки тестового SMS, когда вашу учётную запись удастся прочитать.', + noNumber: 'У вашей учётной записи нет номера телефона, а тестовое SMS отправляется на номер самого вошедшего ' + + 'администратора, — ничего не отправлено. Номер есть у учётной записи только там, где включён вход по номеру ' + + 'телефона: нажмите кнопку отправки тестового SMS, войдя как администратор, у учётной записи которого есть номер ' + + 'телефона.', + missing: (provider, key) => `${provider}: не указано ${key}.`, + buildFailed: (provider, reason) => `Не удалось создать транспорт ${provider}: ${reason}`, + logOnly: 'Нет действующего транспорта доставки SMS (поставщик: нет — только журнал), поэтому тестовое SMS НЕ ' + + 'отправлено. Перед проверкой выберите поставщика, заполните его учётные данные и сохраните.', + sent: (to, via, id) => `Тестовое SMS отправлено на ${to} через ${via ?? 'транспорт, настроенный в развёртывании'} (id=${id}).`, + failed: (reason) => (reason ? `Не удалось отправить тестовое SMS: ${reason}` : 'Не удалось отправить тестовое SMS.'), + }, + de: { + invalidTo: 'Geben Sie eine gültige "to"-Telefonnummer an (E.164 empfohlen).', + noSignedIn: 'Kein Empfänger – es wurde nichts gesendet. Die Test-SMS geht an die eigene Telefonnummer des ' + + 'angemeldeten Administrators, und bei diesem Aufruf ist niemand angemeldet. Melden Sie sich als Administrator ' + + 'an, dessen Konto eine Telefonnummer hat, und klicken Sie dann erneut auf die Schaltfläche zum Senden der ' + + 'Test-SMS.', + noEngine: 'Es ist keine Daten-Engine verfügbar, um die Telefonnummer Ihres Kontos zu lesen – es wurde nichts ' + + 'gesendet. Die Test-SMS geht an die eigene Nummer des angemeldeten Administrators und kann daher erst gesendet ' + + 'werden, wenn diese Nummer gelesen werden kann; klicken Sie erneut auf die Schaltfläche zum Senden der Test-SMS, ' + + 'sobald der Datendienst läuft.', + readFailed: (reason) => 'Die Telefonnummer Ihres Kontos konnte nicht gelesen werden, um die Test-SMS an sie zu ' + + `senden (${reason}) – es wurde nichts gesendet. Die Test-SMS geht an die eigene Nummer des angemeldeten ` + + 'Administrators; klicken Sie erneut auf die Schaltfläche zum Senden der Test-SMS, sobald Ihr Konto gelesen ' + + 'werden kann.', + noNumber: 'Ihr Konto hat keine Telefonnummer, und die Test-SMS geht an die eigene Nummer des angemeldeten ' + + 'Administrators – es wurde nichts gesendet. Ein Konto hat nur dort eine Nummer, wo die Anmeldung per ' + + 'Telefonnummer aktiviert ist: Klicken Sie auf die Schaltfläche zum Senden der Test-SMS, während Sie als ' + + 'Administrator angemeldet sind, dessen Konto eine Telefonnummer hat.', + missing: (provider, key) => `${provider}: ${key} fehlt.`, + buildFailed: (provider, reason) => `Der ${provider}-Transport konnte nicht erstellt werden: ${reason}`, + logOnly: 'Kein SMS-Zustelltransport ist aktiv (Anbieter: keiner – nur Protokoll), daher wurde die Test-SMS NICHT ' + + 'gesendet. Wählen Sie vor dem Testen einen Anbieter, tragen Sie seine Zugangsdaten ein und speichern Sie.', + sent: (to, via, id) => + `Test-SMS an ${to} gesendet über ${via ?? 'den in der Bereitstellung konfigurierten Transport'} (id=${id}).`, + failed: (reason) => (reason ? `Senden der Test-SMS fehlgeschlagen: ${reason}` : 'Senden der Test-SMS fehlgeschlagen.'), + }, +}; + +const COPY_LOCALES = Object.keys(SMS_TEST_COPY); + +/** The copy for a caller's display language: through the platform locale table, else English. */ +export function smsTestCopy(locale: string | undefined): SmsTestCopy { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return SMS_TEST_COPY[(picked ?? 'en') as SmsTestCopyLocale]; +} diff --git a/packages/services/service-sms/src/sms-test-recipient.pin.test.ts b/packages/services/service-sms/src/sms-test-recipient.pin.test.ts index 0bc3a387563..7a0db796fe6 100644 --- a/packages/services/service-sms/src/sms-test-recipient.pin.test.ts +++ b/packages/services/service-sms/src/sms-test-recipient.pin.test.ts @@ -91,7 +91,7 @@ async function serve(users: UserRow[], opts: { userReadFails?: boolean; noEngine vi.stubGlobal('fetch', twilio); /** POST /api/settings/sms/test with the body the console sends. */ - const pressSendTest = async (body: unknown) => { + const pressSendTest = async (body: unknown, headers: Record = {}) => { const handler = http.routes.get('POST /api/settings/:namespace/:actionId'); if (!handler) throw new Error('the settings action route is not mounted'); const captured: { status: number; body: any } = { status: 200, body: undefined }; @@ -105,7 +105,7 @@ async function serve(users: UserRow[], opts: { userReadFails?: boolean; noEngine params: { namespace: 'sms', actionId: 'test' }, query: {}, body, - headers: {}, + headers, method: 'POST', path: '/api/settings/sms/test', }; @@ -226,3 +226,43 @@ describe('served sms/test — the recipient refusals are admin copy', () => { adminCopy(result.message); }); }); + +// The answer is the toast the administrator reads, so it is written in the +// caller's display language (`Accept-Language`, else `?locale`), the way the +// `mail/test` and `storage/test` answers are. Only the provider's own text +// passes through untranslated. +describe('served sms/test — answers in the caller\'s language', () => { + it('a sent test is reported in the Accept-Language language: zh-TW in Traditional Chinese', async () => { + const { twilio, pressSendTest } = await serve([{ id: ADMIN_ID, phone_number: ADMIN_PHONE }]); + + const answer = await pressSendTest(TWILIO_DRAFT, { 'accept-language': 'zh-TW,zh;q=0.9' }); + + expect(answer.status).toBe(200); + expect(twilio).toHaveBeenCalledTimes(1); + expect(answer.body?.data).toMatchObject({ ok: true, severity: 'info' }); + expect(answer.body?.data?.message).toMatch(/^測試簡訊已傳送至 \S+,經由 twilio(id=SM_test)。$/); + }); + + it.each([ + ['fr', 'Votre compte n’a pas de numéro de téléphone'], + ['de-DE', 'Ihr Konto hat keine Telefonnummer'], + ['ko', '계정에 전화번호가 없고'], + ])('a refusal is written in %s, and nothing is sent', async (locale, opening) => { + const { twilio, pressSendTest } = await serve([{ id: ADMIN_ID, phone_number: null }]); + + const answer = await pressSendTest(TWILIO_DRAFT, { 'accept-language': locale }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('SETTINGS_ACTION_FAILED'); + expect(String(answer.body?.error?.details?.message).startsWith(opening)).toBe(true); + expect(twilio).not.toHaveBeenCalled(); + }); + + it('control: with no language, or one off the platform locale table, the answer stays English', async () => { + for (const headers of [{}, { 'accept-language': 'sw-KE' }] as Array>) { + const { pressSendTest } = await serve([{ id: ADMIN_ID, phone_number: null }]); + const answer = await pressSendTest(TWILIO_DRAFT, headers); + expect(String(answer.body?.error?.details?.message).startsWith('Your account has no phone number')).toBe(true); + } + }); +}); diff --git a/packages/services/service-sms/vitest.config.ts b/packages/services/service-sms/vitest.config.ts index 05e58ecabbb..ff370f3b395 100644 --- a/packages/services/service-sms/vitest.config.ts +++ b/packages/services/service-sms/vitest.config.ts @@ -4,8 +4,9 @@ import path from 'path'; // Source aliases for the two workspace packages this package's tests read by // VALUE. `src/kernel-cache.ts` reads a service's self-description from -// `@objectstack/spec/api`, and the daily-quota cache pin boots a real -// `ObjectKernel` from `@objectstack/core`. Unaliased, both would follow the +// `@objectstack/spec/api`, the daily-quota cache pin boots a real +// `ObjectKernel` from `@objectstack/core`, and the `sms/test` copy pin reads +// the platform locale table from `@objectstack/spec/system`. Unaliased, both would follow the // workspace link to the package's last build, so a pin about the source next // to it would report on build state instead (`check:test-source-alias` is the // gate; aliasing is its intended repair). @@ -24,6 +25,7 @@ export default { alias: [ { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, { find: /^@objectstack\/spec\/api$/, replacement: path.resolve(__dirname, '../../spec/src/api/index.ts') }, + { find: /^@objectstack\/spec\/system$/, replacement: path.resolve(__dirname, '../../spec/src/system/index.ts') }, ], }, };