diff --git a/.changeset/action-confirm-one-dialog-identity.md b/.changeset/action-confirm-one-dialog-identity.md new file mode 100644 index 0000000000..f85b0a1626 --- /dev/null +++ b/.changeset/action-confirm-one-dialog-identity.md @@ -0,0 +1,51 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): one decision, one dialog — carry identity confirm questions on `description` (#7309) + +The shared console action runner chains confirmation **then** param collection, +both awaited (objectui `packages/core/src/actions/ActionRunner.ts`). An action +declaring `confirmText` **and** `params` therefore opened **two** sequential +dialogs for one click, with nothing sent until the second — while the first +already read as "the action ran". + +The maintainer's 2026-08-10 ruling on #7278 (shipped in PR #7592) is to carry the +confirm question in the action's top-level `description` (#7367), which the param +dialog renders under its title, and to drop `confirmText`. #7278 applied it to the +two `plugin-approvals` actions; this change sweeps the **14** remaining in-repo +action sites, all in `identity/`: + +| object | actions | +|---|---| +| `sys_user` | `ban_user`, `delete_my_account`, `disable_two_factor`, `generate_backup_codes` | +| `sys_oauth_application` | `enable_oauth_application`, `disable_oauth_application`, `rotate_client_secret`, `delete_oauth_application` | +| `sys_two_factor` | `disable_two_factor`, `regenerate_backup_codes` | +| `sys_account` | `unlink_account` | +| `sys_organization` | `change_slug` | +| `sys_sso_provider` | `delete_sso_provider` | +| `sys_team_member` | `remove_team_member` | + +**No warning was reworded and none was dropped** — each question moves verbatim +from `confirmText` to `description`, so the user still reads it before committing, +now in the one dialog that collects the params. `sys_oauth_application.rotate_client_secret` +went from three dialogs to two: one param dialog (question + `client_id`), then the +existing post-run `resultDialog` that reveals the new secret. That reveal is output +shown once *after* the rotation, not a second pre-run decision, so it stays. + +**`confirmText` is untouched where it is still correct.** A param-LESS action has +no param dialog to fold the question into, so the confirm *is* its only dialog — +`delete_organization`, `leave_organization` and `impersonate_user` keep theirs. + +The `en` / `zh-CN` / `ja-JP` / `es-ES` bundles move the same 14 leaves by hand. +`os i18n extract` treats a renamed key as a new gap and this repo extracts with +`--fill=default`, which would have seeded English over the curated translations +in three of the four shipped locales — invisible to `check:i18n`, whose fresh +extract would agree with the English it just wrote. A carryover test pins each +locale as translated rather than echoing the English source. + +Tests pin the user-visible consequence in both directions: that an action +carrying params opens one dialog, **and** that its question is still shown. +Deleting a warning instead of moving it goes red on `ban_user`, +`delete_my_account` and `rotate_client_secret` — the failure a "no `confirmText` +anywhere" grep cannot see. diff --git a/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts b/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts new file mode 100644 index 0000000000..b4eb98b4c7 --- /dev/null +++ b/packages/platform-objects/src/apps/translations/confirm-question-carryover.test.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7309 — translation-carryover guard for the 14 confirm questions moved from + * `confirmText` to `description`. + * + * The wording did not change; the KEY did. `os i18n extract` treats a renamed + * key as a brand-new gap, and this repo's `i18n:extract` script runs with + * `--fill=default`, which seeds every new leaf from the English source in every + * locale. So a regeneration that accompanies the move overwrites the curated + * zh-CN / ja-JP / es-ES strings for the very same sentence with English. They + * were carried across by hand here. + * + * Nothing else would notice. `check:i18n` compares the bundles against a fresh + * extract, and English-in-a-non-English-locale is perfectly "in sync" — the + * bundle is exactly what the extractor produces. The loss is invisible to the + * drift gate BY CONSTRUCTION, on destructive surfaces (`ban_user`, + * `delete_my_account`, `rotate_client_secret`), in three of the four shipped + * locales. This is the same trap #7278 hit and pinned for its two actions; the + * mechanism is identical, so the pin is too. + */ + +import { describe, it, expect } from 'vitest'; +import { enObjects } from './en.objects.generated.js'; +import { zhCNObjects } from './zh-CN.objects.generated.js'; +import { jaJPObjects } from './ja-JP.objects.generated.js'; +import { esESObjects } from './es-ES.objects.generated.js'; + +const LOCALES = [ + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +] as const; + +/** The 14 (object, action) pairs #7309 converted. */ +const PAIRS: readonly (readonly [string, string])[] = [ + ['sys_user', 'ban_user'], + ['sys_user', 'delete_my_account'], + ['sys_user', 'disable_two_factor'], + ['sys_user', 'generate_backup_codes'], + ['sys_two_factor', 'disable_two_factor'], + ['sys_two_factor', 'regenerate_backup_codes'], + ['sys_account', 'unlink_account'], + ['sys_organization', 'change_slug'], + ['sys_team_member', 'remove_team_member'], + ['sys_oauth_application', 'enable_oauth_application'], + ['sys_oauth_application', 'disable_oauth_application'], + ['sys_oauth_application', 'rotate_client_secret'], + ['sys_oauth_application', 'delete_oauth_application'], + ['sys_sso_provider', 'delete_sso_provider'], +] as const; + +const node = (bundle: any, obj: string, act: string) => bundle?.[obj]?._actions?.[act]; + +describe('#7309 — the moved confirm questions stay translated', () => { + it('the English bundle carries each question on `description`, not `confirmText`', () => { + for (const [obj, act] of PAIRS) { + const n = node(enObjects, obj, act); + expect(n, `${obj}.${act} missing from en bundle`).toBeDefined(); + expect(n.confirmText, `en ${obj}.${act}.confirmText`).toBeUndefined(); + expect(n.description, `en ${obj}.${act}.description`).toBeTruthy(); + } + }); + + it('every non-English locale translates it instead of echoing the English source', () => { + for (const [locale, bundle] of LOCALES) { + for (const [obj, act] of PAIRS) { + const translated = node(bundle, obj, act)?.description; + expect(translated, `${locale} ${obj}.${act}.description missing`).toBeTruthy(); + expect( + translated, + `${locale} ${obj}.${act}.description is the untranslated English source — a re-run of ` + + '`pnpm i18n:extract` seeds new keys from English (`--fill=default`), so the curated ' + + 'string was lost. Restore the translation (the wording is unchanged from the old ' + + '`confirmText`).', + ).not.toBe(node(enObjects, obj, act)?.description); + } + } + }); + + it('no locale left the retired `confirmText` behind on these actions', () => { + for (const [locale, bundle] of LOCALES) { + for (const [obj, act] of PAIRS) { + expect(node(bundle, obj, act)?.confirmText, `${locale} ${obj}.${act}.confirmText`).toBeUndefined(); + } + } + }); + + it('param-LESS actions keep their translated `confirmText` in every locale', () => { + // The over-application guard, mirrored on the translation side: these have + // no param dialog, so the confirm is the only dialog and the key is correct. + for (const [locale, bundle] of [['en', enObjects] as const, ...LOCALES]) { + for (const [obj, act] of [ + ['sys_organization', 'delete_organization'], + ['sys_organization', 'leave_organization'], + ['sys_user', 'impersonate_user'], + ] as const) { + expect(node(bundle, obj, act)?.confirmText, `${locale} ${obj}.${act}.confirmText`).toBeTruthy(); + } + } + }); +}); diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 7b1f0be65b..abae253366 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -135,7 +135,7 @@ export const enObjects: NonNullable = { }, ban_user: { label: "Ban User", - confirmText: "Ban this user? They will be signed out and unable to sign in until unbanned.", + description: "Ban this user? They will be signed out and unable to sign in until unbanned.", successMessage: "User banned", params: { banReason: { @@ -251,7 +251,7 @@ export const enObjects: NonNullable = { }, delete_my_account: { label: "Delete My Account", - confirmText: "Permanently delete your account? This cannot be undone — all your sessions will be terminated and all data you own will be removed per the configured retention policy.", + description: "Permanently delete your account? This cannot be undone — all your sessions will be terminated and all data you own will be removed per the configured retention policy.", successMessage: "Account deleted", params: { password: { @@ -270,7 +270,7 @@ export const enObjects: NonNullable = { }, disable_two_factor: { label: "Disable Two-Factor Auth", - confirmText: "Turn off two-factor authentication? Your account will be less secure.", + description: "Turn off two-factor authentication? Your account will be less secure.", successMessage: "Two-factor authentication disabled.", params: { password: { @@ -280,7 +280,7 @@ export const enObjects: NonNullable = { }, generate_backup_codes: { label: "Regenerate Backup Codes", - confirmText: "Generate a new set of backup codes? Any previously generated codes will stop working.", + description: "Generate a new set of backup codes? Any previously generated codes will stop working.", successMessage: "New backup codes generated — save them somewhere safe.", params: { password: { @@ -452,7 +452,7 @@ export const enObjects: NonNullable = { }, unlink_account: { label: "Unlink Account", - confirmText: "Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.", + description: "Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.", successMessage: "Identity link removed" } } @@ -555,7 +555,7 @@ export const enObjects: NonNullable = { }, change_slug: { label: "Change Slug", - confirmText: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?", + description: "Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?", successMessage: "Organization slug changed" } } @@ -794,7 +794,7 @@ export const enObjects: NonNullable = { }, remove_team_member: { label: "Remove from Team", - confirmText: "Remove this user from the team? They will lose any team-scoped access.", + description: "Remove this user from the team? They will lose any team-scoped access.", successMessage: "Team member removed" } } @@ -1057,7 +1057,7 @@ export const enObjects: NonNullable = { }, disable_two_factor: { label: "Disable 2FA", - confirmText: "Disable two-factor authentication on your account?", + description: "Disable two-factor authentication on your account?", successMessage: "2FA disabled", params: { password: { @@ -1067,7 +1067,7 @@ export const enObjects: NonNullable = { }, regenerate_backup_codes: { label: "Regenerate Backup Codes", - confirmText: "Regenerate backup codes? All previous backup codes will stop working immediately.", + description: "Regenerate backup codes? All previous backup codes will stop working immediately.", params: { password: { label: "Current Password" @@ -1333,12 +1333,12 @@ export const enObjects: NonNullable = { _actions: { disable_oauth_application: { label: "Disable OAuth Application", - confirmText: "Disable this OAuth application? Active access/refresh tokens issued to it will continue to be rejected at the token, authorize, and introspect endpoints. Existing integrations will stop working immediately.", + description: "Disable this OAuth application? Active access/refresh tokens issued to it will continue to be rejected at the token, authorize, and introspect endpoints. Existing integrations will stop working immediately.", successMessage: "OAuth application disabled" }, enable_oauth_application: { label: "Enable OAuth Application", - confirmText: "Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.", + description: "Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.", successMessage: "OAuth application enabled" }, create_oauth_application: { @@ -1373,7 +1373,7 @@ export const enObjects: NonNullable = { }, rotate_client_secret: { label: "Rotate Client Secret", - 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.", + description: "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.", resultDialog: { title: "Client secret rotated", description: "Save the new secret now — it is shown only once. Update every integration before the previous secret's grace period ends.", @@ -1385,7 +1385,7 @@ export const enObjects: NonNullable = { }, delete_oauth_application: { label: "Delete OAuth Application", - confirmText: "Permanently delete this OAuth application? All issued tokens and consents will be invalidated and integrations using this client_id will stop working immediately. This cannot be undone.", + description: "Permanently delete this OAuth application? All issued tokens and consents will be invalidated and integrations using this client_id will stop working immediately. This cannot be undone.", successMessage: "OAuth application deleted" } } @@ -1860,7 +1860,7 @@ export const enObjects: NonNullable = { }, delete_sso_provider: { label: "Delete SSO Provider", - confirmText: "Delete this SSO provider? Users from its domain will no longer be able to sign in through it.", + description: "Delete this SSO provider? Users from its domain will no longer be able to sign in through it.", successMessage: "SSO provider deleted" } } diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 03c9c35ceb..7153480942 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -135,7 +135,7 @@ export const esESObjects: NonNullable = { }, ban_user: { label: "Bloquear usuario", - confirmText: "¿Bloquear a este usuario? Cerrará sesión y no podrá volver a iniciarla hasta que se desbloquee.", + description: "¿Bloquear a este usuario? Cerrará sesión y no podrá volver a iniciarla hasta que se desbloquee.", successMessage: "Usuario bloqueado", params: { banReason: { @@ -251,7 +251,7 @@ export const esESObjects: NonNullable = { }, delete_my_account: { label: "Eliminar mi cuenta", - confirmText: "¿Eliminar tu cuenta de forma permanente? Esta acción no se puede deshacer: se cerrarán todas tus sesiones y se eliminarán todos los datos de tu propiedad según la política de retención configurada.", + description: "¿Eliminar tu cuenta de forma permanente? Esta acción no se puede deshacer: se cerrarán todas tus sesiones y se eliminarán todos los datos de tu propiedad según la política de retención configurada.", successMessage: "Cuenta eliminada", params: { password: { @@ -270,7 +270,7 @@ export const esESObjects: NonNullable = { }, disable_two_factor: { label: "Deshabilitar autenticación de dos factores", - confirmText: "¿Desactivar la autenticación de dos factores? Tu cuenta será menos segura.", + description: "¿Desactivar la autenticación de dos factores? Tu cuenta será menos segura.", successMessage: "Autenticación de dos factores deshabilitada.", params: { password: { @@ -280,7 +280,7 @@ export const esESObjects: NonNullable = { }, generate_backup_codes: { label: "Regenerar códigos de respaldo", - confirmText: "¿Generar un nuevo juego de códigos de respaldo? Los códigos generados anteriormente dejarán de funcionar.", + description: "¿Generar un nuevo juego de códigos de respaldo? Los códigos generados anteriormente dejarán de funcionar.", successMessage: "Nuevos códigos de respaldo generados; guárdalos en un lugar seguro.", params: { password: { @@ -452,7 +452,7 @@ export const esESObjects: NonNullable = { }, unlink_account: { label: "Desvincular cuenta", - confirmText: "¿Desvincular este vínculo de identidad? El usuario ya no podrá iniciar sesión con este proveedor hasta que lo vuelva a vincular desde la configuración de su cuenta.", + description: "¿Desvincular este vínculo de identidad? El usuario ya no podrá iniciar sesión con este proveedor hasta que lo vuelva a vincular desde la configuración de su cuenta.", successMessage: "Vínculo de identidad eliminado" } } @@ -555,7 +555,7 @@ export const esESObjects: NonNullable = { }, change_slug: { label: "Cambiar slug", - confirmText: "Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar?", + description: "Renombrar el slug reescribe todos los subdominios de la plataforma para esta organización y reserva el slug anterior durante 90 días. ¿Continuar?", successMessage: "Slug de la organización cambiado" } } @@ -794,7 +794,7 @@ export const esESObjects: NonNullable = { }, remove_team_member: { label: "Eliminar del equipo", - confirmText: "¿Eliminar a este usuario del equipo? Perderá cualquier acceso con ámbito de equipo.", + description: "¿Eliminar a este usuario del equipo? Perderá cualquier acceso con ámbito de equipo.", successMessage: "Miembro del equipo eliminado" } } @@ -1057,7 +1057,7 @@ export const esESObjects: NonNullable = { }, disable_two_factor: { label: "Deshabilitar 2FA", - confirmText: "¿Deshabilitar la autenticación de doble factor en su cuenta?", + description: "¿Deshabilitar la autenticación de doble factor en su cuenta?", successMessage: "2FA deshabilitado", params: { password: { @@ -1067,7 +1067,7 @@ export const esESObjects: NonNullable = { }, regenerate_backup_codes: { label: "Regenerar códigos de respaldo", - confirmText: "¿Regenerar los códigos de respaldo? Todos los códigos de respaldo anteriores dejarán de funcionar de inmediato.", + description: "¿Regenerar los códigos de respaldo? Todos los códigos de respaldo anteriores dejarán de funcionar de inmediato.", params: { password: { label: "Contraseña actual" @@ -1333,12 +1333,12 @@ export const esESObjects: NonNullable = { _actions: { disable_oauth_application: { label: "Deshabilitar aplicación OAuth", - confirmText: "¿Deshabilitar esta aplicación OAuth? Los tokens de acceso/actualización activos emitidos para ella seguirán siendo rechazados en los endpoints token, authorize e introspect. Las integraciones existentes dejarán de funcionar de inmediato.", + description: "¿Deshabilitar esta aplicación OAuth? Los tokens de acceso/actualización activos emitidos para ella seguirán siendo rechazados en los endpoints token, authorize e introspect. Las integraciones existentes dejarán de funcionar de inmediato.", successMessage: "Aplicación OAuth deshabilitada" }, enable_oauth_application: { label: "Habilitar aplicación OAuth", - confirmText: "¿Volver a habilitar esta aplicación OAuth? La emisión de tokens, la autorización y la introspección se reanudarán de inmediato.", + description: "¿Volver a habilitar esta aplicación OAuth? La emisión de tokens, la autorización y la introspección se reanudarán de inmediato.", successMessage: "Aplicación OAuth habilitada" }, create_oauth_application: { @@ -1373,7 +1373,7 @@ export const esESObjects: NonNullable = { }, rotate_client_secret: { label: "Rotar Client Secret", - 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.", + description: "¿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.", resultDialog: { title: "Secreto de cliente rotado", 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.", @@ -1385,7 +1385,7 @@ export const esESObjects: NonNullable = { }, delete_oauth_application: { label: "Eliminar aplicación OAuth", - confirmText: "¿Eliminar de forma permanente esta aplicación OAuth? Todos los tokens y consentimientos emitidos quedarán invalidados y las integraciones que usen este client_id dejarán de funcionar de inmediato. Esta acción no se puede deshacer.", + description: "¿Eliminar de forma permanente esta aplicación OAuth? Todos los tokens y consentimientos emitidos quedarán invalidados y las integraciones que usen este client_id dejarán de funcionar de inmediato. Esta acción no se puede deshacer.", successMessage: "Aplicación OAuth eliminada" } } @@ -1860,7 +1860,7 @@ export const esESObjects: NonNullable = { }, delete_sso_provider: { label: "Eliminar proveedor SSO", - confirmText: "¿Eliminar este proveedor SSO? Los usuarios de su dominio ya no podrán iniciar sesión a través de él.", + description: "¿Eliminar este proveedor SSO? Los usuarios de su dominio ya no podrán iniciar sesión a través de él.", successMessage: "Proveedor SSO eliminado" } } diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 730d750c7f..fca7b7eaeb 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -135,7 +135,7 @@ export const jaJPObjects: NonNullable = { }, ban_user: { label: "利用停止", - confirmText: "このユーザーを利用停止にしますか?利用停止になるとサインアウトされ、解除されるまでサインインできなくなります。", + description: "このユーザーを利用停止にしますか?利用停止になるとサインアウトされ、解除されるまでサインインできなくなります。", successMessage: "ユーザーを利用停止にしました", params: { banReason: { @@ -251,7 +251,7 @@ export const jaJPObjects: NonNullable = { }, delete_my_account: { label: "アカウント削除", - confirmText: "アカウントを完全に削除しますか?この操作は元に戻せません。すべてのセッションが終了され、設定された保持ポリシーに従って所有するすべてのデータが削除されます。", + description: "アカウントを完全に削除しますか?この操作は元に戻せません。すべてのセッションが終了され、設定された保持ポリシーに従って所有するすべてのデータが削除されます。", successMessage: "アカウントを削除しました", params: { password: { @@ -270,7 +270,7 @@ export const jaJPObjects: NonNullable = { }, disable_two_factor: { label: "二要素認証を無効化", - confirmText: "二要素認証をオフにしますか?アカウントの安全性が低下します。", + description: "二要素認証をオフにしますか?アカウントの安全性が低下します。", successMessage: "二要素認証を無効にしました。", params: { password: { @@ -280,7 +280,7 @@ export const jaJPObjects: NonNullable = { }, generate_backup_codes: { label: "バックアップコードを再生成", - confirmText: "新しいバックアップコードを生成しますか?以前に生成されたコードはすべて使用できなくなります。", + description: "新しいバックアップコードを生成しますか?以前に生成されたコードはすべて使用できなくなります。", successMessage: "新しいバックアップコードを生成しました。安全な場所に保管してください。", params: { password: { @@ -452,7 +452,7 @@ export const jaJPObjects: NonNullable = { }, unlink_account: { label: "連携解除", - confirmText: "このID連携を解除しますか?ユーザーがアカウント設定から再度連携するまで、このプロバイダーでサインインできなくなります。", + description: "このID連携を解除しますか?ユーザーがアカウント設定から再度連携するまで、このプロバイダーでサインインできなくなります。", successMessage: "アイデンティティ連携を解除しました" } } @@ -555,7 +555,7 @@ export const jaJPObjects: NonNullable = { }, change_slug: { label: "スラッグを変更", - confirmText: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?", + description: "スラッグを変更すると、この組織のすべてのプラットフォームサブドメインが書き換えられ、旧スラッグは 90 日間確保されます。続行しますか?", successMessage: "組織のスラッグを変更しました" } } @@ -794,7 +794,7 @@ export const jaJPObjects: NonNullable = { }, remove_team_member: { label: "チームから削除", - confirmText: "このユーザーをチームから削除しますか?チームスコープのアクセスを失います。", + description: "このユーザーをチームから削除しますか?チームスコープのアクセスを失います。", successMessage: "チームメンバーを削除しました" } } @@ -1057,7 +1057,7 @@ export const jaJPObjects: NonNullable = { }, disable_two_factor: { label: "2FA を無効化", - confirmText: "アカウントの二要素認証を無効化しますか?", + description: "アカウントの二要素認証を無効化しますか?", successMessage: "2FA を無効化しました", params: { password: { @@ -1067,7 +1067,7 @@ export const jaJPObjects: NonNullable = { }, regenerate_backup_codes: { label: "バックアップコード再生成", - confirmText: "バックアップコードを再生成しますか?以前のバックアップコードはすべて直ちに使用できなくなります。", + description: "バックアップコードを再生成しますか?以前のバックアップコードはすべて直ちに使用できなくなります。", params: { password: { label: "現在のパスワード" @@ -1333,12 +1333,12 @@ export const jaJPObjects: NonNullable = { _actions: { disable_oauth_application: { label: "OAuthアプリケーションを無効化", - confirmText: "このOAuthアプリケーションを無効化しますか?発行済みの有効なアクセストークン/リフレッシュトークンは、token、authorize、introspect の各エンドポイントで引き続き拒否されます。既存の連携は直ちに動作しなくなります。", + description: "このOAuthアプリケーションを無効化しますか?発行済みの有効なアクセストークン/リフレッシュトークンは、token、authorize、introspect の各エンドポイントで引き続き拒否されます。既存の連携は直ちに動作しなくなります。", successMessage: "OAuthアプリケーションを無効化しました" }, enable_oauth_application: { label: "OAuthアプリケーションを有効化", - confirmText: "このOAuthアプリケーションを再度有効化しますか?トークンの発行、認可、イントロスペクションが直ちに再開されます。", + description: "このOAuthアプリケーションを再度有効化しますか?トークンの発行、認可、イントロスペクションが直ちに再開されます。", successMessage: "OAuthアプリケーションを有効化しました" }, create_oauth_application: { @@ -1373,7 +1373,7 @@ export const jaJPObjects: NonNullable = { }, rotate_client_secret: { label: "クライアントシークレット更新", - confirmText: "このOAuthクライアントのシークレットをローテーションしますか?以前のシークレットは直ちに使用できなくなり、それを使用している連携は新しいシークレットに更新されるまで動作しなくなります。新しいシークレットは一度しか表示されません。", + description: "このOAuthクライアントのシークレットをローテーションしますか?以前のシークレットは直ちに使用できなくなり、それを使用している連携は新しいシークレットに更新されるまで動作しなくなります。新しいシークレットは一度しか表示されません。", resultDialog: { title: "クライアントシークレットをローテーションしました", description: "新しいシークレットを今すぐ保存してください。表示は一度きりです。旧シークレットの猶予期間が終わる前にすべての連携を更新してください。", @@ -1385,7 +1385,7 @@ export const jaJPObjects: NonNullable = { }, delete_oauth_application: { label: "OAuthアプリケーションを削除", - confirmText: "このOAuthアプリケーションを完全に削除しますか?発行済みのすべてのトークンと同意が無効化され、この client_id を使用している連携は直ちに動作しなくなります。この操作は元に戻せません。", + description: "このOAuthアプリケーションを完全に削除しますか?発行済みのすべてのトークンと同意が無効化され、この client_id を使用している連携は直ちに動作しなくなります。この操作は元に戻せません。", successMessage: "OAuthアプリケーションを削除しました" } } @@ -1860,7 +1860,7 @@ export const jaJPObjects: NonNullable = { }, delete_sso_provider: { label: "SSO プロバイダーを削除", - confirmText: "この SSO プロバイダーを削除しますか?そのドメインのユーザーは、これを通じてサインインできなくなります。", + description: "この SSO プロバイダーを削除しますか?そのドメインのユーザーは、これを通じてサインインできなくなります。", successMessage: "SSO プロバイダーを削除しました" } } diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 6b3cd9a451..1817b1e176 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -135,7 +135,7 @@ export const zhCNObjects: NonNullable = { }, ban_user: { label: "封禁用户", - confirmText: "要封禁该用户吗?封禁后会立即登出,且在解除封禁前无法再次登录。", + description: "要封禁该用户吗?封禁后会立即登出,且在解除封禁前无法再次登录。", successMessage: "用户已封禁", params: { banReason: { @@ -251,7 +251,7 @@ export const zhCNObjects: NonNullable = { }, delete_my_account: { label: "删除我的账号", - confirmText: "确定要永久删除您的账户吗?此操作无法撤销——您的所有会话都将被终止,并将按照配置的保留策略移除您拥有的所有数据。", + description: "确定要永久删除您的账户吗?此操作无法撤销——您的所有会话都将被终止,并将按照配置的保留策略移除您拥有的所有数据。", successMessage: "已删除账号", params: { password: { @@ -270,7 +270,7 @@ export const zhCNObjects: NonNullable = { }, disable_two_factor: { label: "停用双因素认证", - confirmText: "要关闭双因素认证吗?您的账户安全性将降低。", + description: "要关闭双因素认证吗?您的账户安全性将降低。", successMessage: "双因素认证已停用。", params: { password: { @@ -280,7 +280,7 @@ export const zhCNObjects: NonNullable = { }, generate_backup_codes: { label: "重新生成备用码", - confirmText: "要生成一组新的备用码吗?之前生成的备用码将全部失效。", + description: "要生成一组新的备用码吗?之前生成的备用码将全部失效。", successMessage: "新备用码已生成——请妥善保存。", params: { password: { @@ -452,7 +452,7 @@ export const zhCNObjects: NonNullable = { }, unlink_account: { label: "解除关联", - confirmText: "确定要解除此身份关联吗?在用户从账户设置中重新关联之前,将无法再使用此提供方登录。", + description: "确定要解除此身份关联吗?在用户从账户设置中重新关联之前,将无法再使用此提供方登录。", successMessage: "已解除身份关联" } } @@ -555,7 +555,7 @@ export const zhCNObjects: NonNullable = { }, change_slug: { label: "修改标识符", - confirmText: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?", + description: "重命名标识符会重写该组织所有平台子域名,旧标识符将保留占用 90 天。继续吗?", successMessage: "组织标识符已修改" } } @@ -794,7 +794,7 @@ export const zhCNObjects: NonNullable = { }, remove_team_member: { label: "移出团队", - confirmText: "要将该用户移出团队吗?移除后其将失去所有团队范围内的访问权限。", + description: "要将该用户移出团队吗?移除后其将失去所有团队范围内的访问权限。", successMessage: "团队成员已移除" } } @@ -1057,7 +1057,7 @@ export const zhCNObjects: NonNullable = { }, disable_two_factor: { label: "停用 2FA", - confirmText: "要停用你账号上的双因素认证吗?", + description: "要停用你账号上的双因素认证吗?", successMessage: "2FA 已停用", params: { password: { @@ -1067,7 +1067,7 @@ export const zhCNObjects: NonNullable = { }, regenerate_backup_codes: { label: "重新生成备用码", - confirmText: "确定要重新生成备份码吗?此前的所有备份码将立即失效。", + description: "确定要重新生成备份码吗?此前的所有备份码将立即失效。", params: { password: { label: "当前密码" @@ -1333,12 +1333,12 @@ export const zhCNObjects: NonNullable = { _actions: { disable_oauth_application: { label: "停用 OAuth 应用", - confirmText: "确定要停用此 OAuth 应用吗?已为其签发的有效访问令牌/刷新令牌将继续在 token、authorize 和 introspect 端点被拒绝。现有集成将立即停止工作。", + description: "确定要停用此 OAuth 应用吗?已为其签发的有效访问令牌/刷新令牌将继续在 token、authorize 和 introspect 端点被拒绝。现有集成将立即停止工作。", successMessage: "OAuth 应用已停用" }, enable_oauth_application: { label: "启用 OAuth 应用", - confirmText: "确定要重新启用此 OAuth 应用吗?令牌签发、授权和内省将立即恢复。", + description: "确定要重新启用此 OAuth 应用吗?令牌签发、授权和内省将立即恢复。", successMessage: "OAuth 应用已启用" }, create_oauth_application: { @@ -1373,7 +1373,7 @@ export const zhCNObjects: NonNullable = { }, rotate_client_secret: { label: "轮换 Client Secret", - confirmText: "确定要轮换此 OAuth 客户端的密钥吗?旧密钥将立即失效,任何使用它的集成都将中断,直到更新为新密钥为止。新密钥仅显示一次。", + description: "确定要轮换此 OAuth 客户端的密钥吗?旧密钥将立即失效,任何使用它的集成都将中断,直到更新为新密钥为止。新密钥仅显示一次。", resultDialog: { title: "Client Secret 已轮换", description: "请立即保存新密钥——它只显示一次。请在旧密钥宽限期结束前更新所有集成。", @@ -1385,7 +1385,7 @@ export const zhCNObjects: NonNullable = { }, delete_oauth_application: { label: "删除 OAuth 应用", - confirmText: "确定要永久删除此 OAuth 应用吗?所有已签发的令牌和授权同意都将失效,使用此 client_id 的集成将立即停止工作。此操作无法撤销。", + description: "确定要永久删除此 OAuth 应用吗?所有已签发的令牌和授权同意都将失效,使用此 client_id 的集成将立即停止工作。此操作无法撤销。", successMessage: "OAuth 应用已删除" } } @@ -1860,7 +1860,7 @@ export const zhCNObjects: NonNullable = { }, delete_sso_provider: { label: "删除 SSO 提供方", - confirmText: "删除该 SSO 提供方吗?其域名下的用户将无法再通过它登录。", + description: "删除该 SSO 提供方吗?其域名下的用户将无法再通过它登录。", successMessage: "SSO 提供方已删除" } } diff --git a/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts new file mode 100644 index 0000000000..1850522f63 --- /dev/null +++ b/packages/platform-objects/src/identity/action-confirm-one-dialog.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7309 — one decision, one dialog, across the identity objects. + * + * The shared console action runner chains confirmation THEN param collection, + * both awaited (objectui `packages/core/src/actions/ActionRunner.ts`). An action + * declaring `confirmText` *and* `params` therefore shows the user TWO sequential + * dialogs for one click, and nothing is sent until the second — while the first + * one already reads as "the action ran". The maintainer's 2026-08-10 ruling on + * #7278 (shipped in PR #7592) is to carry the confirm question in the action's + * top-level `description` (#7367), which the param dialog renders under its + * title, and to drop `confirmText`. #7309 sweeps that across the 14 remaining + * in-repo action sites, all of them here in `identity/`. + * + * **What these tests pin is the user-visible consequence, not the key spelling.** + * The risk of this change is not that a `confirmText` survives somewhere — it is + * that a genuine warning QUIETLY DISAPPEARS from a destructive action while every + * "no `confirmText` anywhere" grep stays green. `ban_user`, `delete_my_account` + * and `rotate_client_secret` are the surfaces where that would cost the most, so + * the wording is pinned verbatim, phrase by phrase, in the negative direction: + * deleting the question instead of moving it must go RED here. + * + * The converse is pinned too. `confirmText` stays CORRECT for a param-LESS + * action, where the confirm is the only dialog there is — so this file also + * asserts that the sweep did not over-apply and strip those. + */ + +import { describe, expect, it } from 'vitest'; +import { SysUser } from './sys-user.object.js'; +import { SysOauthApplication } from './sys-oauth-application.object.js'; +import { SysTwoFactor } from './sys-two-factor.object.js'; +import { SysAccount } from './sys-account.object.js'; +import { SysOrganization } from './sys-organization.object.js'; +import { SysSsoProvider } from './sys-sso-provider.object.js'; +import { SysTeamMember } from './sys-team-member.object.js'; + +const OBJECTS = [ + ['sys_user', SysUser], + ['sys_oauth_application', SysOauthApplication], + ['sys_two_factor', SysTwoFactor], + ['sys_account', SysAccount], + ['sys_organization', SysOrganization], + ['sys_sso_provider', SysSsoProvider], + ['sys_team_member', SysTeamMember], +] as const; + +const actionsOf = (obj: any): any[] => (obj?.actions ?? []) as any[]; +const collects = (a: any) => Array.isArray(a.params) && a.params.length > 0; + +const byName = (obj: any, name: string) => { + const a = actionsOf(obj).find((x) => x.name === name); + if (!a) throw new Error(`action ${name} not declared`); + return a; +}; + +/** The 14 sites #7309 converted: object → action names. */ +const CONVERTED: Record = { + sys_user: ['ban_user', 'delete_my_account', 'disable_two_factor', 'generate_backup_codes'], + sys_oauth_application: [ + 'enable_oauth_application', 'disable_oauth_application', + 'rotate_client_secret', 'delete_oauth_application', + ], + sys_two_factor: ['disable_two_factor', 'regenerate_backup_codes'], + sys_account: ['unlink_account'], + sys_organization: ['change_slug'], + sys_sso_provider: ['delete_sso_provider'], + sys_team_member: ['remove_team_member'], +}; + +describe('#7309 — an action that collects params opens ONE dialog', () => { + it.each(OBJECTS)('%s declares no action pairing `confirmText` with `params`', (_name, obj) => { + const doubled = actionsOf(obj) + .filter((a) => a.confirmText && collects(a)) + .map((a) => a.name); + expect( + doubled, + 'these actions would open a confirm dialog and THEN a param dialog for one decision — ' + + "move the question to the action's top-level `description` (#7278/#7309). " + + 'NB: the top-level key, never `ai.description` (the LLM-facing tool contract).', + ).toEqual([]); + }); + + it.each(OBJECTS)('%s: every param-collecting action still ASKS its question', (name, obj) => { + // The half a "no confirmText" grep cannot see. An action that collected + // params and used to warn must still carry human-readable dialog copy; + // dropping the key without rehoming the sentence lands here. + for (const action of CONVERTED[name] ?? []) { + const a = byName(obj, action); + expect(collects(a), `${name}.${action} should still collect params`).toBe(true); + expect(a.confirmText, `${name}.${action}.confirmText should be gone`).toBeUndefined(); + expect( + typeof a.description === 'string' && a.description.trim().length > 0, + `${name}.${action}: the confirm question was dropped, not moved — the user now gets a ` + + 'param dialog with no warning at all. Restore it on `description`.', + ).toBe(true); + } + }); + + it('the question is human dialog copy, never armed as an AI tool description', () => { + // `ai.description` is the LLM-facing contract (≥40 chars, required when + // `ai.exposed`) — the same word one level down. Putting the question there + // would arm a tool description while the dialog fell back to its generic line. + for (const [name, obj] of OBJECTS) { + for (const action of CONVERTED[name] ?? []) { + expect(byName(obj, action).ai?.description, `${name}.${action}.ai.description`).toBeUndefined(); + } + } + }); +}); + +describe('#7309 — the destructive warnings survived the move, word for word', () => { + // Pinned verbatim because these are the surfaces where a silently vanished + // warning costs the most. A reworded warning is a decision someone should make + // deliberately; a deleted one should never be an accident. + it('sys_user.ban_user still says the user is signed out and locked out', () => { + const a = byName(SysUser, 'ban_user'); + expect(a.description).toBe( + 'Ban this user? They will be signed out and unable to sign in until unbanned.', + ); + expect(a.description).toContain('signed out'); + expect(a.description).toContain('unable to sign in'); + }); + + it('sys_user.delete_my_account still says it cannot be undone', () => { + const a = byName(SysUser, 'delete_my_account'); + expect(a.description).toContain('cannot be undone'); + expect(a.description).toContain('sessions will be terminated'); + }); + + it('sys_oauth_application.rotate_client_secret keeps its finality + shown-once warning', () => { + // Three dialogs for one click collapse to two, and the survivor pair is the + // right one: ONE param dialog (question + `client_id`), then the post-run + // `resultDialog` that reveals the new secret. The reveal is not a second + // pre-run decision, so it is not part of the #7278 defect — but the "shown + // only once" warning has to reach the user BEFORE they commit, which is + // exactly what riding `description` preserves. + const a = byName(SysOauthApplication, 'rotate_client_secret'); + expect(a.description).toContain('stop working immediately'); + expect(a.description).toContain('shown only once'); + expect(a.confirmText).toBeUndefined(); + expect(a.resultDialog, 'the post-run secret reveal must stay').toBeDefined(); + expect(collects(a)).toBe(true); + }); + + it('sys_user.disable_two_factor still warns the account gets less secure', () => { + expect(byName(SysUser, 'disable_two_factor').description).toContain('less secure'); + }); + + it('sys_oauth_application.delete_oauth_application still warns tokens are invalidated', () => { + const a = byName(SysOauthApplication, 'delete_oauth_application'); + expect(a.description).toContain('invalidated'); + expect(a.description).toContain('cannot be undone'); + }); + + it('sys_two_factor.regenerate_backup_codes still warns old codes stop working', () => { + expect(byName(SysTwoFactor, 'regenerate_backup_codes').description).toContain('stop working'); + }); +}); + +describe('#7309 — the sweep did not over-apply', () => { + // `confirmText` is the RIGHT key for an action with no params: there is no + // second dialog to fold the question into, and stripping it would delete the + // only warning the user ever sees. These three are the param-less neighbours + // that sit in the same files as converted actions. + it.each([ + ['sys_organization', SysOrganization, 'delete_organization'], + ['sys_organization', SysOrganization, 'leave_organization'], + ['sys_user', SysUser, 'impersonate_user'], + ] as const)('%s.%s keeps `confirmText` — it has no param dialog to fold into', (_n, obj, action) => { + const a = byName(obj, action); + expect(collects(a), `${action} is expected to be param-less`).toBe(false); + expect(a.confirmText, `${action} lost its only warning`).toBeTruthy(); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 582268ee82..e270b5be26 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -81,7 +81,10 @@ export const SysAccount = ObjectSchema.create({ locations: ['list_item', 'record_header'], type: 'api', target: '/api/v1/auth/unlink-account', - confirmText: 'Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.', + // Confirm question on `description`, not `confirmText`: this action collects + // params, and pairing the two keys opens two dialogs for one decision + // (#7278 ruling 2026-08-10, swept by #7309). + description: 'Unlink this identity link? The user will no longer be able to sign in with this provider until they re-link it from their account settings.', successMessage: 'Identity link removed', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index c4a1efe97c..cb1d371eca 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -66,7 +66,12 @@ export const SysOauthApplication = ObjectSchema.create({ method: 'POST', target: '/api/v1/auth/admin/oauth2/toggle-disabled', requiresFeature: 'oidcProvider', - confirmText: 'Disable this OAuth application? Active access/refresh tokens issued to it will continue to be rejected at the token, authorize, and introspect endpoints. Existing integrations will stop working immediately.', + // The confirm question rides `description`, not `confirmText`: this action + // collects params, and the console action runner chains confirmation THEN + // param collection, so pairing the two keys opens two dialogs for one + // decision (#7278 ruling 2026-08-10, swept by #7309). The param dialog + // renders this under its title — one dialog, question intact. + description: 'Disable this OAuth application? Active access/refresh tokens issued to it will continue to be rejected at the token, authorize, and introspect endpoints. Existing integrations will stop working immediately.', successMessage: 'OAuth application disabled', refreshAfter: true, visible: '!record.disabled', @@ -86,7 +91,8 @@ export const SysOauthApplication = ObjectSchema.create({ method: 'POST', target: '/api/v1/auth/admin/oauth2/toggle-disabled', requiresFeature: 'oidcProvider', - confirmText: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.', + // Confirm question on `description` — one dialog, not two (#7278/#7309). + description: 'Re-enable this OAuth application? Token issuance, authorization, and introspection will resume immediately.', successMessage: 'OAuth application enabled', refreshAfter: true, visible: 'record.disabled', @@ -138,7 +144,12 @@ export const SysOauthApplication = ObjectSchema.create({ method: 'POST', target: '/api/v1/auth/oauth2/client/rotate-secret', requiresFeature: 'oidcProvider', - 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.', + // Three dialogs collapse to two here, and the two that remain are the two + // the user actually needs: this ONE param dialog (question + `client_id`), + // then the `resultDialog` that reveals the new secret AFTER the rotation. + // The result dialog is not part of the #7278 defect — it is a post-run + // reveal for output shown only once, not a second pre-run decision. + description: '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.', refreshAfter: true, params: [ { name: 'client_id', field: 'client_id', defaultFromRow: true, required: true }, @@ -163,7 +174,8 @@ export const SysOauthApplication = ObjectSchema.create({ method: 'POST', target: '/api/v1/auth/oauth2/delete-client', requiresFeature: 'oidcProvider', - confirmText: 'Permanently delete this OAuth application? All issued tokens and consents will be invalidated and integrations using this client_id will stop working immediately. This cannot be undone.', + // Confirm question on `description` — one dialog, not two (#7278/#7309). + description: 'Permanently delete this OAuth application? All issued tokens and consents will be invalidated and integrations using this client_id will stop working immediately. This cannot be undone.', successMessage: 'OAuth application deleted', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 74802b2304..60a433f541 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -148,7 +148,12 @@ export const SysOrganization = ObjectSchema.create({ target: '/api/v1/cloud/organizations/{id}/change-slug', method: 'POST', requiresFeature: 'multiOrgEnabled', - confirmText: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?', + // Confirm question on `description`, not `confirmText`: this action collects + // params, and pairing the two keys opens two dialogs for one decision + // (#7278 ruling 2026-08-10, swept by #7309). The two param-LESS actions + // above (`delete_organization`, `leave_organization`) keep `confirmText` + // — with no param dialog, the confirm IS the only dialog. + description: 'Renaming the slug rewrites every platform subdomain for this org and parks the old slug for 90 days. Continue?', successMessage: 'Organization slug changed', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index 0649cd337c..4e2c4ddb10 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -195,7 +195,10 @@ export const SysSsoProvider = ObjectSchema.create({ type: 'api', method: 'POST', target: '/api/v1/auth/sso/delete-provider', - confirmText: 'Delete this SSO provider? Users from its domain will no longer be able to sign in through it.', + // Confirm question on `description`, not `confirmText`: this action collects + // params, and pairing the two keys opens two dialogs for one decision + // (#7278 ruling 2026-08-10, swept by #7309). + description: 'Delete this SSO provider? Users from its domain will no longer be able to sign in through it.', successMessage: 'SSO provider deleted', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index ee2d7f2249..821c0dc18a 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -67,7 +67,10 @@ export const SysTeamMember = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/remove-team-member', requiresFeature: 'organization', - confirmText: 'Remove this user from the team? They will lose any team-scoped access.', + // Confirm question on `description`, not `confirmText`: this action collects + // params, and pairing the two keys opens two dialogs for one decision + // (#7278 ruling 2026-08-10, swept by #7309). + description: 'Remove this user from the team? They will lose any team-scoped access.', successMessage: 'Team member removed', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index 5a760e4a1b..596652ca50 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -90,7 +90,10 @@ export const SysTwoFactor = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/two-factor/disable', requiresFeature: 'twoFactor', - confirmText: 'Disable two-factor authentication on your account?', + // Confirm question on `description`, not `confirmText`: this action collects + // params, and pairing the two keys opens two dialogs for one decision + // (#7278 ruling 2026-08-10, swept by #7309). + description: 'Disable two-factor authentication on your account?', successMessage: '2FA disabled', refreshAfter: true, params: [ @@ -106,7 +109,8 @@ export const SysTwoFactor = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/two-factor/generate-backup-codes', requiresFeature: 'twoFactor', - confirmText: 'Regenerate backup codes? All previous backup codes will stop working immediately.', + // Confirm question on `description` — one dialog, not two (#7278/#7309). + description: 'Regenerate backup codes? All previous backup codes will stop working immediately.', refreshAfter: true, params: [ { name: 'password', label: 'Current Password', type: 'text', required: true }, diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 382db980e8..e63797f060 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -96,7 +96,16 @@ export const SysUser = ObjectSchema.create({ recordIdParam: 'userId', successMessage: 'User banned', refreshAfter: true, - confirmText: 'Ban this user? They will be signed out and unable to sign in until unbanned.', + // The confirm question rides `description`, not `confirmText`: this action + // collects params, and the console action runner chains confirmation THEN + // param collection (both awaited), so pairing the two keys opens two + // dialogs for one decision — and the first already reads as "it ran". + // Maintainer ruling on #7278 (2026-08-10), swept across the remaining + // in-repo sites by #7309. The param dialog renders this under its title, + // and nothing is POSTed until that one dialog's own Confirm. + // NB: the top-level `description` (#7367), never `ai.description` — that + // one is the LLM-facing tool contract and is shown to no user. + description: 'Ban this user? They will be signed out and unable to sign in until unbanned.', params: [ { name: 'banReason', label: 'Ban Reason', type: 'text', required: false }, ], @@ -361,7 +370,8 @@ export const SysUser = ObjectSchema.create({ // Self-delete needs a local password; managed users are deprovisioned // via the IdP (org-removal / SCIM), not local self-service. Hide for them. visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"', - confirmText: 'Permanently delete your account? This cannot be undone — all your sessions will be terminated and all data you own will be removed per the configured retention policy.', + // Confirm question on `description` — one dialog, not two (#7278/#7309). + description: 'Permanently delete your account? This cannot be undone — all your sessions will be terminated and all data you own will be removed per the configured retention policy.', successMessage: 'Account deleted', refreshAfter: false, params: [ @@ -400,7 +410,8 @@ export const SysUser = ObjectSchema.create({ target: '/api/v1/auth/two-factor/disable', visible: 'record.id == ctx.user.id && record.two_factor_enabled == true', requiresFeature: 'twoFactor', - confirmText: 'Turn off two-factor authentication? Your account will be less secure.', + // Confirm question on `description` — one dialog, not two (#7278/#7309). + description: 'Turn off two-factor authentication? Your account will be less secure.', successMessage: 'Two-factor authentication disabled.', refreshAfter: true, params: [ @@ -417,7 +428,8 @@ export const SysUser = ObjectSchema.create({ target: '/api/v1/auth/two-factor/generate-backup-codes', visible: 'record.id == ctx.user.id && record.two_factor_enabled == true', requiresFeature: 'twoFactor', - confirmText: 'Generate a new set of backup codes? Any previously generated codes will stop working.', + // Confirm question on `description` — one dialog, not two (#7278/#7309). + description: 'Generate a new set of backup codes? Any previously generated codes will stop working.', successMessage: 'New backup codes generated — save them somewhere safe.', refreshAfter: false, params: [