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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .changeset/action-confirm-one-dialog-identity.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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();
}
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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: {
Expand Down Expand Up @@ -251,7 +251,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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: {
Expand All @@ -270,7 +270,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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: {
Expand All @@ -280,7 +280,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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: {
Expand Down Expand Up @@ -452,7 +452,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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"
}
}
Expand Down Expand Up @@ -555,7 +555,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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"
}
}
Expand Down Expand Up @@ -794,7 +794,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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"
}
}
Expand Down Expand Up @@ -1057,7 +1057,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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: {
Expand All @@ -1067,7 +1067,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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"
Expand Down Expand Up @@ -1333,12 +1333,12 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
_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: {
Expand Down Expand Up @@ -1373,7 +1373,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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.",
Expand All @@ -1385,7 +1385,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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"
}
}
Expand Down Expand Up @@ -1860,7 +1860,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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"
}
}
Expand Down
Loading
Loading