Skip to content

Commit d07e8d1

Browse files
fix(app-shell): Setup walkthrough rows — one name for Setup, confirms before Disable and before switching a packaged action off, read-only reasons, forced-reset wording (#27849)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Refs #27521 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 走查建议的界面部分——首页链接与应用切换器统一用 Setup 应用自己的名称;软件包页说明只读原因、禁用前确认、改称「删除软件包」;打包自动化关闭动作前确认、成功后提示、显示对象名称、手机上不再溢出;强制改密页用控制台自己的译文并把字段叫「临时密码」;记录页标签栏可横向滚动并有渐隐提示;info/warning 不再显示成警报橙色;Setup 页眉标签可用键盘打开说明。 - 为什么改:#27521 是 18.3.0 RC 走查的建议卡,分诊要求每行要么做掉、要么建卡、要么给出不做的理由;这里是 Setup 第 1–23 行中只需改 ui 的部分。 - 风险与回滚:只动 ui 文案、确认框和布局,不改 spec、服务端或元数据协议;标签栏和严重度颜色是所有应用共用的渲染器,影响面最大;回滚即还原本 PR。 - 先看:`ui/packages/components/src/renderers/layout/containers.tsx`(标签栏滚动与渐隐)、`ui/packages/app-shell/src/views/setup/PackagedActionsSection.tsx`(关闭前确认)、`ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`(禁用确认与只读原因)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/setup/ packages/app-shell/src/views/metadata-admin/PackagesPage.readOnlyReasonAndDisableConfirm-27521.test.tsx`,预期全部通过。 - 需要你做的:无,席位落地。 ## What changes Setup rows 1–23 of #27521 (row 6 is #27797), worked one by one against `main`. Taken here (ui only): - **Row 1 — one name for Setup.** Home's apps-strip link and the app switcher's entry now read the Setup app's own served label (`appLabel` over `resolveKeyedI18nLabel`), the name the app header shows, instead of the fixed "System Settings" / "Settings" strings; those strings stay only as the fallback when no app object is in hand. The Settings hub keeps its title: it is a page inside Setup ("All Settings" in the sidebar), not the app. - **Row 4 (ui half) — info is not an alarm.** A select value `info` or `warning` with no declared colour now reads blue or yellow from `SEMANTIC_COLOR_MAP` (`@object-ui/fields`), instead of the hashed palette slot that drew Notifications' `info` orange. A declared option colour still wins. - **Row 5 (ui half) — header tags explain themselves from the keyboard.** `ManagedByBadge` already opens its explanation on hover (measured on main); the tag now takes focus (`tabIndex=0`), which opens the same tooltip. - **Rows 12 and 22 (ui half) — the forced password change.** On `PASSWORD_RESET_REQUIRED` the overlay draws its own body (`auth.remediation.password.resetBody`: an administrator set a temporary password) instead of the server's sentence, which repeated the title in English for a French user; the first field is "Temporary password" (`auth.remediation.password.temporary`). An expired password keeps the server's sentence and "Current password". Both keys in all ten packs. - **Row 13 (ui half) — record tab strips scroll.** A horizontal `page:tabs` strip wider than its box scrolls sideways (`overflow-x-auto`, triggers `shrink-0`) and fades at each edge that hides a tab; `useHorizontalOverflow` (package-private, not exported) re-reads the edge on scroll and resize into `data-overflow`. The bottom rule became an inset shadow so the active underline still draws over it once the strip clips at its padding box. Measured on the user record at 1440: `scrollWidth` 1269 in a 1088 box, cut with no cue before; now it scrolls and reads `end` / `both` / `start`. - **Row 20 — Packaged automation.** Switching a packaged action off asks first (off stops it everywhere it is offered); switching on runs at once. An accepted switch, action or flow, says so in a toast; a refused one keeps its verbatim refusal and no toast. The Object column shows the object's label (`useObjectLabel`) over the machine name. On a phone the fixed column widths apply from `sm` up, long machine names break, and cells pad less: measured at 390, both tables went from `scrollWidth` 523 / 537 in a 342 box to 342 / 342. Not touched: #27504's question (whether a switched-off action's button stays on the record page). - **Row 21 — Packages.** The sheet says why a read-only package offers no write action: loaded from code (`scope: 'project'`), read-only here (scope-less read-only), or part of the platform (system / cloud, reworded from "platform kernel package"). Disable asks first; Enable runs at once. "Delete app" becomes "Delete package" (key `engine.packages.detail.deletePackage`). Installed Apps gets its own subtitle. Fixed on main, not changed here: - **Row 16, name derivation** — New Permission Set derives Name from Label on main (`createDerive` slugify on `permission`; typing "Field Ops Lead" fills `field_ops_lead`). - **Row 18** — the Add user picker shows email beside name: landed by #27808 (card 27433) while this branch was open; the merge took main's version. Carded, filed from the paragraphs in the `os-dev-report` comment on #27521: rows 2 (#27853), 3 (#27854), 4 rest (#27855), 10 Create User half (#27857), 12 Create User result line (#27858), 13 rest (#27859), 14 (#27861, #27862), 15 columns (#27863), 16 field order and stacked messages (#27864, #27865), 17 (#27866, too large for this PR), 19 (#27867), 22 rest (#27869). Each one's text, labels or layout comes from server or spec metadata (`service-settings` manifests, `platform-objects`, `plugin-auth`, `packages/spec/src/security/permission.form.ts`), or is its own ui card (row 14's registry-label read face, row 16's stacked messages in `ResourceEditPage`, row 17's grid). Will not do: - **Row 5, empty states** — Sharing Rules and Webhooks open on their "Active" view, which filters `active = true`; "No records match this view's filter" is the accurate sentence there, and first-run copy would misstate a view that hides inactive rows. - **Row 15, hover-only row actions** — the reveal on hover, focus-within, selection and coarse pointers is the grid-wide pattern of every list in the product (`RowActionMenu`, `data-table`); changing it for Setup alone would split one pattern in two. Later, separate PR: rows 7, 8, 9, 10 (Invite half), 11 and 23 touch the Invitations surface. #27797 is blocked behind server card #27843, so these rows wait for #27843 to land. | Row | Answer | Pin or reason | |---|---|---| | 1 | taken | `AppSwitcher.homeMenu-12605` (served label, any label), `HomePage.greetingAndApps-12601` | | 2 | carded #27853 | server copy (`service-settings` manifests, `plugin-webhooks`) | | 3 | carded #27854 | server nav (`setup-nav.contributions.ts`) | | 4 | taken (colour) · carded #27855 (rest) | `badge-severity-27521` | | 5 | taken (keyboard) · fixed on main (hover) · will-not-do (empty states) | `ManagedByBadge.keyboard-27521` | | 6 | #27797 | — | | 7, 8, 9, 10 (Invite), 11, 23 | waits on #27843, separate PR | — | | 10 (Create User) | carded #27857 | `plugin-auth` refusal, `sys-user.object.ts` params | | 12 | taken (overlay) · carded #27858 (result line) | `RemediationOverlay.passwordResetRequired-12365` (new describe), `forcedResetNewPassword` | | 13 | taken (tab strip) · carded #27859 (rest) | `page-tabs-overflow-cue-27521` | | 14 | carded #27861 (ui) · #27862 (server) | ui read face + server grant message | | 15 | will-not-do (hover) · carded #27863 (columns) | grid-wide pattern | | 16 | fixed on main (derivation) · carded #27864 (order) · #27865 (messages) | spec form + `ResourceEditPage` | | 17 | carded #27866 | too large here | | 18 | fixed on main by #27808 | — | | 19 | carded #27867 | server manifest; "de" still refused, measured | | 20 | taken | `PackagedActionsSection.test` §6; phone layout measured in Chromium (jsdom lays nothing out) | | 21 | taken | `PackagesPage.readOnlyReasonAndDisableConfirm-27521` | | 22 | taken (overlay) · carded #27869 (rest) | as row 12 | ## Tests New: `ManagedByBadge.keyboard-27521`, `badge-severity-27521`, `page-tabs-overflow-cue-27521`, `PackagesPage.readOnlyReasonAndDisableConfirm-27521`, a describe in `RemediationOverlay.passwordResetRequired-12365`, §6 of `PackagedActionsSection.test`, a case in `AppSwitcher.homeMenu-12605`. Existing pins updated for the new words: "Delete package", "Temporary password", the switch-off confirm (`switchOff` helper), the Setup link's label. ## Acceptance notes - `@object-ui/i18n`'s `de-quote-pairing-3876` pins an exact count of quoted spans in the de pack; the new de strings follow the pack's own unquoted `{{label}}` form for the flow switch, so the count stands. - The tab-strip fade is a CSS mask on the sticky strip, so the strip's own background fades with the tabs at that edge. - `@object-ui/i18n`: `engine.packages.detail.deleteApp` is renamed `engine.packages.detail.deletePackage`; no other reader of the old key exists in the tree. ## Verification All on head `5a54fd98` (after merging `origin/main`, `pnpm install --frozen-lockfile` and a full `turbo run build`, 114/114) unless a line says otherwise. <details><summary>Targeted tests — 483 files, 9777 passed, 0 failed</summary> ``` pnpm --dir ui exec vitest run --maxWorkers=2 packages/app-shell/src/views/setup/ packages/app-shell/src/views/metadata-admin/PackagesPage packages/app-shell/src/views/metadata-admin/AssignedUsersSection packages/app-shell/src/console/__tests__/RemediationOverlay packages/app-shell/src/console/home/ packages/app-shell/src/layout/__tests__/AppSwitcher packages/app-shell/src/components/ packages/fields/src/__tests__/ packages/components/src/__tests__/page-tabs packages/i18n/ scripts/ Test Files 483 passed | 3 skipped (486) Tests 9777 passed | 22 skipped (9799) os-verify-lock: VERDICT command-exit 0 ``` Before the merge, on `ed05c2ac`: `packages/components/src/__tests__/` 256 files / 2412 passed; `console/home` + `layout` 152 / 974; `components/` + `fields` + the ManagedBy pins 149 / 2940. </details> <details><summary>Ablation — the Disable confirm</summary> `node scripts/ablation-replace.mjs` on `PackagesPage.tsx`, anchor `onClick={() => (enabled ? startAction(() => setDisableOpen(true)) : void toggleEnable())}` → `onClick={() => void toggleEnable()}`: anchor x1 → x0, blob `032c769d` → `41626a6b`; `PackagesPage.readOnlyReasonAndDisableConfirm-27521` 2 failed / 6 passed (both (b) cases); restored: blob == HEAD `032c769d`, `git diff HEAD` empty. </details> <details><summary>Gates (local subset, exit codes captured before any pipe)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 families on `2c0f76c9`; each run: ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @object-ui/components run type-check :: exit 0 pnpm --filter @object-ui/fields run type-check :: exit 0 pnpm --filter @object-ui/i18n run type-check :: exit 0 pnpm --filter @object-ui/app-shell run type-check :: exit 0 eslint (changed ui files) :: exit 1 pnpm --dir ui check:new-line-citations :: exit 0 eslint (changed ui files, inline config honoured) :: exit 0 ``` `pnpm --dir ui exec vitest run scripts/` first ran together with `packages/i18n/` and went red on `de-quote-pairing-3876` (the new de strings carried „…“); fixed in `ed05c2ac`, then green in the run above. The full ESLint run with `--no-inline-config` reports one pre-existing error in `containers.tsx` (`react-hooks/static-components` on the record-title read face, already disabled inline on `main`); with inline config honoured: 0 errors. </details> <details><summary>Browser (Chromium, stock `examples/app-showcase --seed-admin`, admin)</summary> - 1440: Home shows "System Settings" and the switcher "Settings" on main; the Setup header shows "Setup". - 1440, user record: tab strip `scrollWidth` 1269 in a 1088 box with `overflow: visible` on main; on the branch it scrolls, `data-overflow` reads `end` → `both` → `start`, the active underline sits on the rule. - 390, Packaged automation: tables `scrollWidth` 523 / 537 in a 342 box on main; 342 / 342 on the branch; 1136 / 1136 at 1440. - 1440, Company: Country "de" answers "1 setting needs attention: Country", input `maxlength=2` (row 19, carded). </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZLnkdSxVfvNuQ6RgB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c0f9fdb commit d07e8d1

39 files changed

Lines changed: 789 additions & 66 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/components': patch
4+
'@object-ui/fields': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
fix(app-shell): Setup reads clearer — one name for the app, a confirm before Disable and before switching a packaged action off, the reason a package is read-only, and a forced password change in the user's words
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
Suggestions from the 18.3.0 RC Setup walkthrough, each in the console:
14+
15+
- **One name for Setup.** Home's link and the app switcher's entry now carry the Setup app's own label, the name its header shows, instead of "System Settings" and "Settings".
16+
- **A forced password change** reads in the console's own words, in the user's language: the body says an administrator set a temporary password, and the first field is "Temporary password". An expired password keeps the server's sentence and "Current password".
17+
- **The package sheet** says why a read-only package offers no write action (loaded from code, read-only here, or part of the platform), asks before Disable, and calls its delete action "Delete package". Installed Apps has its own subtitle.
18+
- **Packaged automation** asks before switching a packaged action off, says in a toast when a switch was accepted, shows each action's object by its label beside the machine name, and fits a phone screen without a sideways scroll.
19+
- **Record tab strips** that hold more tabs than fit scroll sideways and fade at the edge that hides a tab.
20+
- **A select value of `info` or `warning`** with no declared colour reads blue or yellow, not a hashed alarm orange.
21+
- **Setup's header tags** (Admin config, Platform schema, System-managed, Identity) take keyboard focus, which opens their explanation as hover does.
22+
- `@object-ui/i18n` adds `auth.remediation.password.resetBody`, `auth.remediation.password.temporary` and five `packagedAutomation.*` keys in all ten locales, and five `engine.packages.*` keys in the Studio namespace (en, zh); `engine.packages.detail.deleteApp` becomes `engine.packages.detail.deletePackage`.

‎ui/packages/app-shell/src/components/ManagedByBadge.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,8 +214,11 @@ export function ManagedByBadge({ managedBy, label, className }: ManagedByBadgePr
214214
<TooltipProvider delayDuration={200}>
215215
<Tooltip>
216216
<TooltipTrigger asChild>
217+
{/* Focusable, so the explanation a hover opens is reachable from
218+
the keyboard too: the tooltip opens on focus. */}
217219
<Badge
218220
variant="outline"
221+
tabIndex={0}
219222
data-testid="managed-by-badge"
220223
data-bucket={managedBy}
221224
className={cn(
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* objectstack#27521 row 5 — a Setup header tag (Admin config, Platform
3+
* schema, System-managed, Identity) explains itself in a tooltip, and that
4+
* explanation is reachable from the keyboard: the tag takes focus, and focus
5+
* opens the tooltip as hover does.
6+
*/
7+
import '@testing-library/jest-dom/vitest';
8+
import { describe, expect, it } from 'vitest';
9+
import { render, screen } from '@testing-library/react';
10+
import userEvent from '@testing-library/user-event';
11+
import { ManagedByBadge } from '../ManagedByBadge';
12+
13+
describe('ManagedByBadge — the explanation is reachable from the keyboard (objectstack#27521)', () => {
14+
it('Tab focuses the tag and opens its explanation', async () => {
15+
render(<ManagedByBadge managedBy="config" />);
16+
const badge = screen.getByTestId('managed-by-badge');
17+
expect(screen.queryByRole('tooltip')).toBeNull();
18+
19+
await userEvent.tab();
20+
expect(badge).toHaveFocus();
21+
expect(await screen.findByRole('tooltip')).toHaveTextContent('Administrator configuration');
22+
});
23+
24+
it('CONTROL: a platform object draws no tag, so there is nothing to focus', async () => {
25+
render(<ManagedByBadge managedBy="platform" />);
26+
expect(screen.queryByTestId('managed-by-badge')).toBeNull();
27+
});
28+
});

‎ui/packages/app-shell/src/console/RemediationOverlay.tsx‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,23 @@ function ChangePasswordForm({ forcedReset, message }: { forcedReset: boolean; me
178178
<h2 className="text-lg font-semibold">
179179
{forcedReset ? t('auth.remediation.password.resetTitle') : t('auth.remediation.password.title')}
180180
</h2>
181-
{/* `message` is server-authored (localized upstream or not at all); only
182-
the empty-message fallback is ours to translate. */}
181+
{/* An expiry's `message` is server-authored (localized upstream or not
182+
at all); only the empty-message fallback is ours to translate. A
183+
forced reset's body is ours, as its title is: the code alone says
184+
what happened, and the server's sentence only repeats the title in
185+
the server's language. */}
183186
<p className="mt-1 text-sm text-muted-foreground">
184-
{message || t('auth.remediation.password.fallbackMessage')}
187+
{forcedReset
188+
? t('auth.remediation.password.resetBody')
189+
: message || t('auth.remediation.password.fallbackMessage')}
185190
</p>
186191
</div>
187192
<div className="space-y-2">
188-
<Label htmlFor="rem-cur">{t('auth.remediation.password.current')}</Label>
193+
{/* After a forced reset the password in hand is the one an
194+
administrator issued, so the field is named for it. */}
195+
<Label htmlFor="rem-cur">
196+
{forcedReset ? t('auth.remediation.password.temporary') : t('auth.remediation.password.current')}
197+
</Label>
189198
<Input id="rem-cur" type="password" autoComplete="current-password" value={current}
190199
onChange={(e) => setCurrent(e.target.value)} required />
191200
</div>

‎ui/packages/app-shell/src/console/__tests__/RemediationOverlay.forcedResetNewPassword.test.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ function submit(current: string, next: string, labels: { current: string; next:
4646
fireEvent.submit(screen.getByLabelText(labels.next).closest('form') as HTMLFormElement);
4747
}
4848

49-
const EN = { current: 'Current password', next: 'New password', confirm: 'Confirm new password' };
50-
const ZH = { current: '当前密码', next: '新密码', confirm: '确认新密码' };
49+
// A forced reset names the field for the password an administrator issued;
50+
// an expiry keeps "Current password".
51+
const EN = { current: 'Temporary password', next: 'New password', confirm: 'Confirm new password' };
52+
const EN_EXPIRED = { ...EN, current: 'Current password' };
53+
const ZH = { current: '临时密码', next: '新密码', confirm: '确认新密码' };
5154
const FORCED: Gate = { code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' };
5255

5356
beforeEach(() => {
@@ -81,7 +84,7 @@ describe('RemediationOverlay — a forced reset asks for a new password', () =>
8184

8285
it('leaves an expiry to the server: the same password is sent on', async () => {
8386
renderWith({ code: 'PASSWORD_EXPIRED', message: '' }, 'en');
84-
submit('Same!Passw0rd-1', 'Same!Passw0rd-1', EN);
87+
submit('Same!Passw0rd-1', 'Same!Passw0rd-1', EN_EXPIRED);
8588
await vi.waitFor(() => expect(changePassword).toHaveBeenCalledWith('Same!Passw0rd-1', 'Same!Passw0rd-1'));
8689
expect(screen.queryByText('Choose a new password that is different from your temporary password.')).toBeNull();
8790
});

‎ui/packages/app-shell/src/console/__tests__/RemediationOverlay.impersonation-13208.test.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ describe('RemediationOverlay on an impersonation session (objectui#13208)', () =
150150
expect(screen.getByText(/only they can do this themselves/)).toBeTruthy();
151151
expect(screen.getByTestId('remediation-impersonation-stop').textContent).toBe('Stop impersonating');
152152
// Not the target's credential form, and not the message written to them.
153-
expect(screen.queryByLabelText('Current password')).toBeNull();
153+
expect(screen.queryByLabelText('Temporary password')).toBeNull();
154154
expect(screen.queryByLabelText('New password')).toBeNull();
155155
expect(screen.queryByText(HELD_MESSAGE)).toBeNull();
156156
// The other way out stays.
@@ -196,7 +196,7 @@ describe('RemediationOverlay on an impersonation session (objectui#13208)', () =
196196
const stranded = await screen.findByTestId('remediation-impersonation-not-restored');
197197
expect(stranded.textContent).toMatch(/still impersonating RT8 Dave/);
198198
expect(screen.getByTestId('remediation-impersonation-exit')).toBeTruthy();
199-
expect(screen.queryByLabelText('Current password')).toBeNull();
199+
expect(screen.queryByLabelText('Temporary password')).toBeNull();
200200
expect(reload).not.toHaveBeenCalled();
201201
});
202202

@@ -227,9 +227,9 @@ describe('RemediationOverlay on an impersonation session (objectui#13208)', () =
227227
await act(async () => {});
228228
await raiseGate();
229229

230-
expect(await screen.findByLabelText('Current password')).toBeTruthy();
230+
expect(await screen.findByLabelText('Temporary password')).toBeTruthy();
231231
expect(screen.getByRole('heading', { name: 'You must change your password' })).toBeTruthy();
232-
expect(screen.getByText(HELD_MESSAGE)).toBeTruthy();
232+
expect(screen.getByText(/An administrator set a temporary password for you/)).toBeTruthy();
233233
expect(screen.queryByTestId('remediation-impersonation-exit')).toBeNull();
234234
expect(screen.queryByTestId('remediation-impersonation-stop')).toBeNull();
235235
});

‎ui/packages/app-shell/src/console/__tests__/RemediationOverlay.passwordResetRequired-12365.test.tsx‎

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function renderWith(next: Gate) {
3838
describe('RemediationOverlay — PASSWORD_RESET_REQUIRED (objectui#12365)', () => {
3939
it('shows the change-password form for a forced reset, under a reset title', () => {
4040
renderWith({ code: 'PASSWORD_RESET_REQUIRED', message: 'You must change your password before continuing.' });
41-
expect(screen.getByLabelText('Current password')).toBeTruthy();
41+
expect(screen.getByLabelText('Temporary password')).toBeTruthy();
4242
expect(screen.getByLabelText('New password')).toBeTruthy();
4343
expect(screen.getByLabelText('Confirm new password')).toBeTruthy();
4444
expect(screen.getByRole('heading', { name: 'You must change your password' })).toBeTruthy();
@@ -53,3 +53,46 @@ describe('RemediationOverlay — PASSWORD_RESET_REQUIRED (objectui#12365)', () =
5353
expect(screen.getByRole('heading', { name: 'Your password has expired' })).toBeTruthy();
5454
});
5555
});
56+
57+
/**
58+
* objectstack#27521 rows 12 and 22 — a forced reset's body and its first
59+
* field are the console's own words, in the user's language: the body says
60+
* what happened instead of repeating the title in the server's language, and
61+
* the field is named for the temporary password the user holds. An expiry
62+
* keeps the server's sentence and "Current password".
63+
*/
64+
describe('RemediationOverlay — a forced reset speaks in the console’s words (objectstack#27521)', () => {
65+
const SERVER_SENTENCE = 'You must change your password before continuing.';
66+
67+
function renderIn(language: string, next: Gate) {
68+
gate = next;
69+
return render(
70+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
71+
<RemediationOverlay />
72+
</I18nProvider>,
73+
);
74+
}
75+
76+
it('says why in its own body and names the temporary password, not the server sentence', () => {
77+
renderIn('en', { code: 'PASSWORD_RESET_REQUIRED', message: SERVER_SENTENCE });
78+
expect(screen.getByText(/An administrator set a temporary password for you/)).toBeTruthy();
79+
expect(screen.queryByText(SERVER_SENTENCE)).toBeNull();
80+
expect(screen.getByLabelText('Temporary password')).toBeTruthy();
81+
expect(screen.queryByLabelText('Current password')).toBeNull();
82+
});
83+
84+
it('reads in the user’s language (fr): title, body and fields together', async () => {
85+
renderIn('fr', { code: 'PASSWORD_RESET_REQUIRED', message: SERVER_SENTENCE });
86+
expect(await screen.findByRole('heading', { name: 'Vous devez changer votre mot de passe' })).toBeTruthy();
87+
expect(screen.getByText(/Un administrateur vous a attribué un mot de passe temporaire/)).toBeTruthy();
88+
expect(screen.queryByText(SERVER_SENTENCE)).toBeNull();
89+
expect(screen.getByLabelText('Mot de passe temporaire')).toBeTruthy();
90+
});
91+
92+
it('CONTROL: an expiry keeps the server sentence and "Current password"', () => {
93+
renderIn('en', { code: 'PASSWORD_EXPIRED', message: 'Your password is older than 90 days.' });
94+
expect(screen.getByText('Your password is older than 90 days.')).toBeTruthy();
95+
expect(screen.getByLabelText('Current password')).toBeTruthy();
96+
expect(screen.queryByLabelText('Temporary password')).toBeNull();
97+
});
98+
});

‎ui/packages/app-shell/src/console/home/HomeAppsStrip.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function HomeAppsStrip({
5858
canAuthorMetadata = true,
5959
marketplaceEnabled = true,
6060
settingsHref,
61+
settingsLabel,
6162
lastOpened,
6263
}: {
6364
apps: any[];
@@ -91,6 +92,12 @@ export function HomeAppsStrip({
9192
* case.
9293
*/
9394
settingsHref?: string;
95+
/**
96+
* The Setup app's own label, as its header and the app switcher draw it, so
97+
* the link names the app it opens in the reader's language. Omitted, the
98+
* link falls back to the `home.systemSettings` string.
99+
*/
100+
settingsLabel?: string;
94101
/**
95102
* [objectui#12601] When the user last had each app open, by app `name` (an
96103
* ISO timestamp), drawn under the tile's name as "2 hr. ago" (zh "2小时前打开";
@@ -153,7 +160,7 @@ export function HomeAppsStrip({
153160
data-testid="home-system-settings-link"
154161
>
155162
<Settings className="h-3.5 w-3.5" aria-hidden />
156-
{t('home.systemSettings', { defaultValue: 'System Settings' })}
163+
{settingsLabel || t('home.systemSettings', { defaultValue: 'System Settings' })}
157164
</Link>
158165
)}
159166
</div>

‎ui/packages/app-shell/src/console/home/HomePage.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,11 @@ export function HomePage() {
790790
apps={launcherApps}
791791
favorites={favorites}
792792
settingsHref={setupApp ? setupPath : undefined}
793+
settingsLabel={
794+
setupApp
795+
? appLabel({ name: setupApp.name, label: resolveKeyedI18nLabel(setupApp.label, t) })
796+
: undefined
797+
}
793798
lastOpened={lastOpened}
794799
/* Deliberately NOT resolved through `hostAppSegment` (objectui#4074
795800
scope guard): the marketplace is an admin-only surface that lives

‎ui/packages/app-shell/src/console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,16 @@ describe('the builder chips (objectui#12601)', () => {
298298
});
299299

300300
describe('the apps strip (objectui#12601)', () => {
301-
it('Setup is not among the tiles: it is the heading’s System Settings link, on the /setup alias’s route', () => {
301+
it('Setup is not among the tiles: it is the heading’s link, named for the app, on the /setup alias’s route', () => {
302302
render(<HomePage />);
303303
expect(screen.getByTestId('app-tile-crm')).toBeInTheDocument();
304304
expect(screen.queryByTestId('app-tile-setup')).toBeNull();
305305

306306
const link = screen.getByTestId('home-system-settings-link');
307-
expect(link).toHaveTextContent('«home.systemSettings»');
307+
// objectstack#27521 row 1 — named by the Setup app's own label, the name
308+
// its header and the app switcher show.
309+
expect(link).toHaveTextContent('Setup');
310+
expect(link).not.toHaveTextContent('«home.systemSettings»');
308311
expect(link).toHaveAttribute('href', '/apps/com.objectstack.setup');
309312
// The count pill counts the tiles, so Setup is not in it.
310313
expect(screen.getByTestId('home-apps-count')).toHaveTextContent(/^1$/);

0 commit comments

Comments
 (0)