Skip to content

Commit 9f8acb6

Browse files
baozhoutaoclaude
andcommitted
feat(i18n): make action resultDialog copy translatable (schema slot + resolver + platform bundles)
The one-shot secret-reveal dialogs (create-user temporary password, 2FA backup codes, OAuth client secrets) always rendered hardcoded English: the translation protocol had no slot for Action.resultDialog, so neither the bundles nor any resolver could localize the dialog's title, description, acknowledge button, or field labels. - spec: add ActionResultDialogTranslationSchema and hang it off object `_actions`, `globalActions`, and the object-first `_actions` node. `fields` is keyed by the LITERAL result-field path ("user.email") — keys may contain dots, resolvers index the record directly. New resolveActionResultDialog overlay resolver, wired into translateAction. - cli: `os i18n extract` emits resultDialog.title/description/acknowledge and resultDialog.fields.<path> entries (dotted paths stay one segment). - platform-objects: fill the en / zh-CN / ja-JP / es-ES bundles for all six shipped resultDialogs (sys_user create_user + set_user_password, sys_two_factor enable + regenerate backup codes, sys_oauth_application register + rotate secret). Client-side rendering lands in objectui (@object-ui/i18n actionResultDialog + result-dialog handlers). Verified end-to-end against a live showcase instance: the create-user and set-password dialogs render fully localized in zh-CN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8efa395 commit 9f8acb6

11 files changed

Lines changed: 523 additions & 7 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
"@objectstack/platform-objects": patch
5+
---
6+
7+
feat(i18n): translation slot for action `resultDialog` copy — the one-shot secret-reveal dialogs are now localizable
8+
9+
The post-success `resultDialog` (temporary passwords, 2FA backup codes, OAuth
10+
client secrets) had no slot in the translation protocol, so its title /
11+
description / acknowledge button / field labels always rendered the hardcoded
12+
English metadata literals even on fully-translated locales.
13+
14+
- **spec.** `_actions.<action>` (object + object-first node) and
15+
`globalActions.<action>` gain an optional `resultDialog` translation node
16+
(`ActionResultDialogTranslationSchema`): `title`, `description`,
17+
`acknowledge`, and `fields` keyed by the **literal** result-field path
18+
(e.g. `"user.email"` — keys may contain dots; resolvers index the record
19+
directly, never split on `.`). New `resolveActionResultDialog` overlay
20+
resolver, wired into `translateAction` for API-boundary translation.
21+
- **cli.** `os i18n extract` emits the new `resultDialog.*` keys (title /
22+
description / acknowledge / `fields.<path>` for labelled fields), so
23+
coverage and skeleton generation see them.
24+
- **platform-objects.** en / zh-CN / ja-JP / es-ES bundles ship the
25+
resultDialog copy for all six shipped dialogs: `sys_user.create_user`,
26+
`sys_user.set_user_password`, `sys_two_factor.enable_two_factor`,
27+
`sys_two_factor.regenerate_backup_codes`,
28+
`sys_oauth_application.create_oauth_application`, and
29+
`sys_oauth_application.rotate_client_secret`.
30+
31+
Client-side rendering lands in objectui (`actionResultDialog` resolver in
32+
`@object-ui/i18n` + result-dialog handlers). Purely additive — untranslated
33+
locales keep falling back to the metadata literals.

packages/cli/src/utils/i18n-extract.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@
2727
* objects.<name>._actions.<action>.successMessage
2828
* objects.<name>._actions.<action>.params.<param>.label / .helpText / .placeholder
2929
* objects.<name>._actions.<action>.params.<param>.options.<value>
30+
* objects.<name>._actions.<action>.resultDialog.title / .description / .acknowledge
31+
* objects.<name>._actions.<action>.resultDialog.fields.<path>
3032
* globalActions.<action>.label / .confirmText / .successMessage
31-
* globalActions.<action>.params.<param>.* (same shape as object actions)
33+
* globalActions.<action>.params.<param>.* / .resultDialog.* (same shape as object actions)
3234
* apps.<app>.label / .description
3335
* apps.<app>.navigation.<id>.label
3436
* dashboards.<dash>.label / .description
@@ -199,6 +201,43 @@ function pushActionParams(
199201
}
200202
}
201203

204+
/**
205+
* Emit `resultDialog.{title,description,acknowledge}` and
206+
* `resultDialog.fields.<path>` entries under an action's translation root.
207+
* Mirrors `ActionResultDialogTranslationSchema` in @objectstack/spec and the
208+
* client-side `actionResultDialog` resolver. Field entries are keyed by the
209+
* LITERAL result-field path (`"user.email"`) — the dot stays inside a single
210+
* path segment, matching how resolvers index the record without splitting.
211+
*/
212+
function pushActionResultDialog(
213+
out: ExpectedEntry[],
214+
actionRoot: string[],
215+
action: any,
216+
kind: ExpectedEntry['source'],
217+
objectName?: string,
218+
): void {
219+
const dialog = action?.resultDialog;
220+
if (!dialog || typeof dialog !== 'object') return;
221+
const base = [...actionRoot, 'resultDialog'];
222+
if (typeof dialog.title === 'string' && dialog.title.length > 0) {
223+
pushEntry(out, [...base, 'title'], dialog.title, kind, { objectName });
224+
}
225+
if (typeof dialog.description === 'string' && dialog.description.length > 0) {
226+
pushEntry(out, [...base, 'description'], dialog.description, kind, { objectName });
227+
}
228+
if (typeof dialog.acknowledge === 'string' && dialog.acknowledge.length > 0) {
229+
pushEntry(out, [...base, 'acknowledge'], dialog.acknowledge, kind, { objectName });
230+
}
231+
if (Array.isArray(dialog.fields)) {
232+
for (const field of dialog.fields) {
233+
if (!field || typeof field !== 'object') continue;
234+
if (typeof field.path !== 'string' || field.path.length === 0) continue;
235+
if (typeof field.label !== 'string' || field.label.length === 0) continue;
236+
pushEntry(out, [...base, 'fields', field.path], field.label, kind, { objectName });
237+
}
238+
}
239+
}
240+
202241
/** Collect every translatable entry from a normalized stack config. */
203242
export function collectExpectedEntries(config: any): ExpectedEntry[] {
204243
const out: ExpectedEntry[] = [];
@@ -286,6 +325,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
286325
pushEntry(out, ['objects', objectName, '_actions', aname, 'successMessage'], action.successMessage, 'action', { objectName });
287326
}
288327
pushActionParams(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
328+
pushActionResultDialog(out, ['objects', objectName, '_actions', aname], action, 'action', objectName);
289329
}
290330
}
291331
}
@@ -320,6 +360,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
320360
pushEntry(out, [...root, 'successMessage'], action.successMessage, kind, { objectName });
321361
}
322362
pushActionParams(out, root, action, kind, objectName);
363+
pushActionResultDialog(out, root, action, kind, objectName);
323364
}
324365

325366
// ── Apps + navigation ────────────────────────────────────────────
1.47 KB
Binary file not shown.

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
168168
mustChangePassword: {
169169
label: "Require Password Change On First Login"
170170
}
171+
},
172+
resultDialog: {
173+
title: "User Created",
174+
description: "Copy the temporary password now — it is shown only once and never stored.",
175+
acknowledge: "I have saved this password",
176+
fields: {
177+
"user.email": "Email",
178+
"user.phoneNumber": "Phone Number",
179+
temporaryPassword: "Temporary Password"
180+
}
171181
}
172182
},
173183
set_user_password: {
@@ -183,6 +193,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
183193
mustChangePassword: {
184194
label: "Require Password Change On Next Login"
185195
}
196+
},
197+
resultDialog: {
198+
title: "Password Updated",
199+
description: "If a temporary password was generated, copy it now — it is shown only once and never stored.",
200+
acknowledge: "Done",
201+
fields: {
202+
temporaryPassword: "Temporary Password"
203+
}
186204
}
187205
},
188206
set_user_role: {
@@ -988,6 +1006,15 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
9881006
password: {
9891007
label: "Current Password"
9901008
}
1009+
},
1010+
resultDialog: {
1011+
title: "Two-factor authentication enabled",
1012+
description: "Scan the QR code with your authenticator app, then save the backup codes somewhere safe. The backup codes are shown only once.",
1013+
acknowledge: "I have saved my backup codes",
1014+
fields: {
1015+
totpURI: "Authenticator URI",
1016+
backupCodes: "Backup Codes"
1017+
}
9911018
}
9921019
},
9931020
disable_two_factor: {
@@ -1007,6 +1034,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
10071034
password: {
10081035
label: "Current Password"
10091036
}
1037+
},
1038+
resultDialog: {
1039+
title: "New backup codes generated",
1040+
description: "Previous backup codes are now invalid. Save these new codes somewhere safe — they are shown only once.",
1041+
acknowledge: "I have saved the new codes",
1042+
fields: {
1043+
backupCodes: "Backup Codes"
1044+
}
10101045
}
10111046
}
10121047
}
@@ -1287,11 +1322,28 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
12871322
public: "Public"
12881323
}
12891324
}
1325+
},
1326+
resultDialog: {
1327+
title: "OAuth application registered",
1328+
description: "Save the client_secret now — it is shown only once and cannot be recovered. You can rotate it later if it leaks.",
1329+
acknowledge: "I have saved the client secret",
1330+
fields: {
1331+
"client.client_id": "Client ID",
1332+
"client.client_secret": "Client Secret"
1333+
}
12901334
}
12911335
},
12921336
rotate_client_secret: {
12931337
label: "Rotate Client Secret",
1294-
confirmText: "Rotate this OAuth client's secret? The previous secret will stop working immediately and any integrations using it will break until they are updated with the new secret. The new secret is shown only once."
1338+
confirmText: "Rotate this OAuth client's secret? The previous secret will stop working immediately and any integrations using it will break until they are updated with the new secret. The new secret is shown only once.",
1339+
resultDialog: {
1340+
title: "Client secret rotated",
1341+
description: "Save the new secret now — it is shown only once. Update every integration before the previous secret's grace period ends.",
1342+
acknowledge: "I have updated my integrations",
1343+
fields: {
1344+
client_secret: "New Client Secret"
1345+
}
1346+
}
12951347
},
12961348
delete_oauth_application: {
12971349
label: "Delete OAuth Application",

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
168168
mustChangePassword: {
169169
label: "Exigir cambio de contraseña en el primer inicio de sesión"
170170
}
171+
},
172+
resultDialog: {
173+
title: "Usuario creado",
174+
description: "Copie la contraseña temporal ahora: se muestra una sola vez y no se almacena.",
175+
acknowledge: "He guardado esta contraseña",
176+
fields: {
177+
"user.email": "Correo electrónico",
178+
"user.phoneNumber": "Número de teléfono",
179+
temporaryPassword: "Contraseña temporal"
180+
}
171181
}
172182
},
173183
set_user_password: {
@@ -183,6 +193,14 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
183193
mustChangePassword: {
184194
label: "Exigir cambio de contraseña en el próximo inicio de sesión"
185195
}
196+
},
197+
resultDialog: {
198+
title: "Contraseña actualizada",
199+
description: "Si se generó una contraseña temporal, cópiela ahora: se muestra una sola vez y no se almacena.",
200+
acknowledge: "Hecho",
201+
fields: {
202+
temporaryPassword: "Contraseña temporal"
203+
}
186204
}
187205
},
188206
set_user_role: {
@@ -988,6 +1006,15 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
9881006
password: {
9891007
label: "Contraseña actual"
9901008
}
1009+
},
1010+
resultDialog: {
1011+
title: "Autenticación de doble factor habilitada",
1012+
description: "Escanee el código QR con su aplicación de autenticación y guarde los códigos de respaldo en un lugar seguro. Los códigos de respaldo se muestran una sola vez.",
1013+
acknowledge: "He guardado mis códigos de respaldo",
1014+
fields: {
1015+
totpURI: "URI del autenticador",
1016+
backupCodes: "Códigos de respaldo"
1017+
}
9911018
}
9921019
},
9931020
disable_two_factor: {
@@ -1007,6 +1034,14 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
10071034
password: {
10081035
label: "Contraseña actual"
10091036
}
1037+
},
1038+
resultDialog: {
1039+
title: "Nuevos códigos de respaldo generados",
1040+
description: "Los códigos de respaldo anteriores ya no son válidos. Guarde estos nuevos códigos en un lugar seguro: se muestran una sola vez.",
1041+
acknowledge: "He guardado los nuevos códigos",
1042+
fields: {
1043+
backupCodes: "Códigos de respaldo"
1044+
}
10101045
}
10111046
}
10121047
}
@@ -1287,11 +1322,28 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
12871322
public: "Pública"
12881323
}
12891324
}
1325+
},
1326+
resultDialog: {
1327+
title: "Aplicación OAuth registrada",
1328+
description: "Guarde el client_secret ahora: se muestra una sola vez y no se puede recuperar. Puede rotarlo más adelante si se filtra.",
1329+
acknowledge: "He guardado el secreto de cliente",
1330+
fields: {
1331+
"client.client_id": "ID de cliente",
1332+
"client.client_secret": "Secreto de cliente"
1333+
}
12901334
}
12911335
},
12921336
rotate_client_secret: {
12931337
label: "Rotar Client Secret",
1294-
confirmText: "¿Rotar el secreto de este cliente OAuth? El secreto anterior dejará de funcionar de inmediato y cualquier integración que lo utilice fallará hasta que se actualice con el nuevo secreto. El nuevo secreto se muestra una sola vez."
1338+
confirmText: "¿Rotar el secreto de este cliente OAuth? El secreto anterior dejará de funcionar de inmediato y cualquier integración que lo utilice fallará hasta que se actualice con el nuevo secreto. El nuevo secreto se muestra una sola vez.",
1339+
resultDialog: {
1340+
title: "Secreto de cliente rotado",
1341+
description: "Guarde el nuevo secreto ahora: se muestra una sola vez. Actualice todas las integraciones antes de que termine el periodo de gracia del secreto anterior.",
1342+
acknowledge: "He actualizado mis integraciones",
1343+
fields: {
1344+
client_secret: "Nuevo secreto de cliente"
1345+
}
1346+
}
12951347
},
12961348
delete_oauth_application: {
12971349
label: "Eliminar aplicación OAuth",

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
168168
mustChangePassword: {
169169
label: "初回サインイン時にパスワード変更を必須にする"
170170
}
171+
},
172+
resultDialog: {
173+
title: "ユーザーを作成しました",
174+
description: "一時パスワードを今すぐコピーしてください。表示は一度きりで、保存されません。",
175+
acknowledge: "パスワードを保存しました",
176+
fields: {
177+
"user.email": "メールアドレス",
178+
"user.phoneNumber": "電話番号",
179+
temporaryPassword: "一時パスワード"
180+
}
171181
}
172182
},
173183
set_user_password: {
@@ -183,6 +193,14 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
183193
mustChangePassword: {
184194
label: "次回サインイン時にパスワード変更を必須にする"
185195
}
196+
},
197+
resultDialog: {
198+
title: "パスワードを更新しました",
199+
description: "一時パスワードが生成された場合は今すぐコピーしてください。表示は一度きりで、保存されません。",
200+
acknowledge: "完了",
201+
fields: {
202+
temporaryPassword: "一時パスワード"
203+
}
186204
}
187205
},
188206
set_user_role: {
@@ -988,6 +1006,15 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
9881006
password: {
9891007
label: "現在のパスワード"
9901008
}
1009+
},
1010+
resultDialog: {
1011+
title: "二要素認証を有効化しました",
1012+
description: "認証アプリで QR コードをスキャンし、バックアップコードを安全な場所に保存してください。バックアップコードの表示は一度きりです。",
1013+
acknowledge: "バックアップコードを保存しました",
1014+
fields: {
1015+
totpURI: "認証アプリ URI",
1016+
backupCodes: "バックアップコード"
1017+
}
9911018
}
9921019
},
9931020
disable_two_factor: {
@@ -1007,6 +1034,14 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
10071034
password: {
10081035
label: "現在のパスワード"
10091036
}
1037+
},
1038+
resultDialog: {
1039+
title: "新しいバックアップコードを生成しました",
1040+
description: "以前のバックアップコードは無効になりました。新しいコードを安全な場所に保存してください。表示は一度きりです。",
1041+
acknowledge: "新しいコードを保存しました",
1042+
fields: {
1043+
backupCodes: "バックアップコード"
1044+
}
10101045
}
10111046
}
10121047
}
@@ -1287,11 +1322,28 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
12871322
public: "パブリック"
12881323
}
12891324
}
1325+
},
1326+
resultDialog: {
1327+
title: "OAuthアプリケーションを登録しました",
1328+
description: "client_secret を今すぐ保存してください。表示は一度きりで、復元できません。漏えいした場合は後でローテーションできます。",
1329+
acknowledge: "クライアントシークレットを保存しました",
1330+
fields: {
1331+
"client.client_id": "クライアント ID",
1332+
"client.client_secret": "クライアントシークレット"
1333+
}
12901334
}
12911335
},
12921336
rotate_client_secret: {
12931337
label: "クライアントシークレット更新",
1294-
confirmText: "このOAuthクライアントのシークレットをローテーションしますか?以前のシークレットは直ちに使用できなくなり、それを使用している連携は新しいシークレットに更新されるまで動作しなくなります。新しいシークレットは一度しか表示されません。"
1338+
confirmText: "このOAuthクライアントのシークレットをローテーションしますか?以前のシークレットは直ちに使用できなくなり、それを使用している連携は新しいシークレットに更新されるまで動作しなくなります。新しいシークレットは一度しか表示されません。",
1339+
resultDialog: {
1340+
title: "クライアントシークレットをローテーションしました",
1341+
description: "新しいシークレットを今すぐ保存してください。表示は一度きりです。旧シークレットの猶予期間が終わる前にすべての連携を更新してください。",
1342+
acknowledge: "連携を更新しました",
1343+
fields: {
1344+
client_secret: "新しいクライアントシークレット"
1345+
}
1346+
}
12951347
},
12961348
delete_oauth_application: {
12971349
label: "OAuthアプリケーションを削除",

0 commit comments

Comments
 (0)