Skip to content

Commit 115a43a

Browse files
feat(console): clear the value saved beneath an env pin in Setup (#31039)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoVUxqTWaM6rFCGZo1WDXBGb)_ Fixes #30665 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 设置页里被环境变量锁定的字段,若锁下面还存着一个保存值(在本页写入的那一层),该行会说明"环境变量的值仍然生效",并提供"清除保存的值"按钮;确认后只对这一个键写 `null`。 - 为什么改:#30385 裁决 A 第 5 项:管理员用环境变量锁定(如 `OS_AUTH_ALLOWED_IP_RANGES`)来自救后,以前在 Setup 里删不掉锁下面的旧值,一旦移除变量旧值就回来;服务端的清除(#30448)和"锁下是否有存值"的读数(#30823)都已落地,这里补上界面。 - 风险与回滚:只动 Console 设置页与语言包四个新键;按钮只在"锁定 + 本层确有存值"时出现,确认后才写,页面草稿不受影响;回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/pages/settings/SettingsField.tsx`(`storedBeneathPin` 与 `ClearSavedBeneathPin`)、`SettingsView.tsx`(`onClearSaved`)、`__tests__/SettingsView.clearBeneathPin-30665.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/pages/settings/__tests__/SettingsView.clearBeneathPin-30665.test.tsx`,预期 7/7 通过。 - 需要你做的:无,席位落地。 ## What changes - **`SettingsField.tsx`:** `storedBeneathPin(resolved, writeScope)` reads the server's `cascadeChain` alone: true when the row is env-pinned (`locked`, `source: 'env'`) and an entry beneath the `env` entry at the scope this page writes carries a non-null value (a secret's entry is the read mask, which counts). On such a row, and only when the host passes `onClearSaved`, the row draws one line ("A value is also saved here. The environment value stays in force; ...") and a "Clear the saved value" button. The button opens a confirm (title and action "Clear the saved value", body saying the saved value will not come back when the variable is removed and the environment value stays in force; Cancel is the shared `actionConfirm.cancel`). Drawn under the control for every input row and under the help text for a toggle row. - **`SettingsView.tsx`:** `onClearSaved(key)` writes `PUT /api/settings/:namespace` with `{ [key]: null }` for that key alone, merges the read-back, toasts "Saved value cleared" and reads the namespace again. The page draft is not touched: the row is read-only, so the clear is not one of the page's edits. A refusal toasts the server's sentence (the pin never refuses a clear, so `lockedByEnv` would misname a row lock or a 403). - **`@object-ui/i18n`:** four `console.settingsView` keys (`savedBeneathPin`, `clearSaved`, `clearSavedConfirm`, `savedCleared`) in all eleven packs (since #30902 merged: `zh-Hans`, `zh-Hant` and `pt-BR` replace `zh` and `pt`). Wording clears the saved value and never says delete. - **`.changeset/30665-clear-saved-beneath-env-pin.md`:** `@object-ui/console` and `@object-ui/i18n` patch. What the server serves, measured on `origin/main` `41dc302496` before the first edit: - The namespace read (`getNamespace` → `resolveMany(..., beneathPins = true)`) answers a pinned key `{ source: 'env', locked: true, cascadeChain: [env entry (effective), ...stored cascade beneath] }` via `withStoredBeneath`; the entries beneath lose `effective` and end with the `default` entry. A pinned key whose env value is null keeps its one `env` entry. A secret beneath the pin is `storedSecretPresence` on the service and masked entry by entry by `redactSecretValues` on the route, so the console sees the mask or `null`. - The clear (`setMany`, the `[#30385]` block): `null` / `undefined` for a key an in-force override pins passes the `SettingsLockedError` check and is judged by the row locks and write predicates like any clear; it writes at the key's registered scope (`spec.scope ?? manifest.scope ?? 'tenant'`), the same derivation the page passes as `writeScope`. So the control is gated on an entry at that scope: a value stored only at another rung could not be removed by this clear, and no control is drawn for it. No new server field is needed; no file under `packages/**` changes. ## Tests `ui/apps/console/src/pages/settings/__tests__/SettingsView.clearBeneathPin-30665.test.tsx` (7 tests, whole page over a stubbed `fetch` and the real `../api`): - the control appears on exactly the three pinned rows with a value stored at the write scope (text, masked secret, toggle) and not on a pinned row with nothing stored, a pinned row stored only above the write scope, or an unpinned row; - the line and the confirm say the environment value stays in force and never say delete; - confirm sends exactly `{"allowed_ip_ranges":null}`, toasts, and the row stays locked and read-only with no control left after the server's answer; - a masked secret's clear sends `{"webhook_secret":null}`; - Cancel sends nothing; - an unsaved edit elsewhere stays in the draft and is not sent; - a refused clear toasts the server's sentence and keeps the control. ## Verification <details><summary>Local runs on head (see the report comment for the final sha)</summary> - `pnpm --dir ui exec vitest run apps/console/src/pages/settings/` → Test Files 38 passed (38), Tests 304 passed (304). - `pnpm --dir ui exec vitest run packages/i18n/` → Test Files 97 passed (97), Tests 1521 passed, 13 skipped (key parity across all ten packs included; on head fb666f8 after merging main: settings + i18n 136/136 files, 1949 passed, 13 skipped, eleven-pack parity included). - Ablation (`node scripts/ablation-replace.mjs`, source is what the console project resolves): the write-scope condition in `storedBeneathPin` widened to any stored rung → 2 failed / 5 passed (the offer test and the clear test, both seeing the extra control on the tenant row stored only at global); restored, blob equal to HEAD, `git diff HEAD` empty. - Derived gates (`dispatch-gates.mjs --commands`): every line run with stdin closed; exit codes in the report comment, reconciled with `--ran`. </details> ## Acceptance notes - The clear writes at once after its own confirm rather than through the page's Save bar. A pinned row is read-only, so a pending clear would have to draw a value the row never shows; the confirm is the row's own question, like an `action_button`'s `confirmText`. - The `i18n-locale-en` chunk gains four strings; its per-chunk ceiling is unchanged. The eager-closure budget is weighed by CI's `Build & E2E UI` job (`pnpm --dir ui run check:eager-closure`), not re-run locally. - The first-run checklist renders `SettingsField` without `onClearSaved`, so it shows no control. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzzesuj1TsQQD4hQjM)_ <!-- signed: domain:ui#3 · os-tesla · session_01L9qibxW6qraU2Z3mwupUGp --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d9d3eb7 commit 115a43a

15 files changed

Lines changed: 447 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): a settings field an environment variable pins offers to clear the value saved beneath the pin
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **`@object-ui/console`:** on a Setup settings page, a field an `OS_*` environment variable pins still shows the lock badge and stays read-only. When the namespace read lists a value stored beneath the pin at the scope the page writes (a secret's as the read mask), the row also says that a value is saved there and that the environment value stays in force, and offers "Clear the saved value". The control asks first, then writes `null` for that one key, the one write the server lets through a pin; the page's unsaved edits are left as they are. After the clear the row still reads the environment value, and the saved value no longer comes back when the variable is removed. A pinned field with nothing stored at that scope, a field no variable pins, and the first-run checklist show no control.
12+
- **`@object-ui/i18n`:** four new `console.settingsView` keys in all eleven packs: `savedBeneathPin`, `clearSaved`, `clearSavedConfirm` and `savedCleared`.

‎ui/apps/console/src/pages/settings/SettingsField.tsx‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,63 @@ export interface SettingsFieldProps {
128128
* not (the first-run checklist) keeps the badges a lower scope would see.
129129
*/
130130
writeScope?: SpecifierScope;
131+
/**
132+
* Clears the value stored beneath an env pin (objectstack#30665): the page
133+
* writes `null` for this key, the one write the server lets through a pin
134+
* (ADR-0007 D6). Offered only where {@link storedBeneathPin} holds, so a
135+
* host that does not pass it, or a row with nothing stored, shows nothing.
136+
*/
137+
onClearSaved?: () => void;
138+
}
139+
140+
/**
141+
* Whether a value is stored beneath the env pin at the scope this row writes,
142+
* the one a clear would null (objectstack#30665). Read off the server's
143+
* `cascadeChain` alone: the namespace read lists the stored cascade beneath an
144+
* in-force `env` entry, a secret's entries as the read mask or `null`. Nothing
145+
* stored, or stored only at another scope, is no control: a clear here could
146+
* not remove it.
147+
*/
148+
export function storedBeneathPin(
149+
resolved: ResolvedSettingValue | undefined,
150+
writeScope: SpecifierScope | undefined,
151+
): boolean {
152+
if (!resolved?.locked || resolved.source !== 'env' || !writeScope) return false;
153+
return (resolved.cascadeChain ?? []).some(
154+
(entry) => entry.scope === writeScope && entry.value !== null && entry.value !== undefined,
155+
);
156+
}
157+
158+
/**
159+
* The line and control under a pinned row that has a value stored beneath the
160+
* pin (objectstack#30665). The wording clears the saved value, never deletes
161+
* it, and says the environment value stays in force; the clear asks first,
162+
* because it writes at once rather than through the page's draft.
163+
*/
164+
function ClearSavedBeneathPin({ onClear, disabled }: { onClear: () => void; disabled?: boolean }) {
165+
const { t } = useObjectTranslation();
166+
return (
167+
<div className="flex items-center justify-between gap-3 mt-1" data-testid="settings-saved-beneath-pin">
168+
<p className="text-xs text-muted-foreground">{t('console.settingsView.savedBeneathPin')}</p>
169+
<AlertDialog>
170+
<AlertDialogTrigger asChild>
171+
<Button type="button" variant="outline" size="sm" className="h-7 shrink-0 text-xs" disabled={disabled}>
172+
{t('console.settingsView.clearSaved')}
173+
</Button>
174+
</AlertDialogTrigger>
175+
<AlertDialogContent>
176+
<AlertDialogHeader>
177+
<AlertDialogTitle>{t('console.settingsView.clearSaved')}</AlertDialogTitle>
178+
<AlertDialogDescription>{t('console.settingsView.clearSavedConfirm')}</AlertDialogDescription>
179+
</AlertDialogHeader>
180+
<AlertDialogFooter>
181+
<AlertDialogCancel>{t('actionConfirm.cancel')}</AlertDialogCancel>
182+
<AlertDialogAction onClick={onClear}>{t('console.settingsView.clearSaved')}</AlertDialogAction>
183+
</AlertDialogFooter>
184+
</AlertDialogContent>
185+
</AlertDialog>
186+
</div>
187+
);
131188
}
132189

133190
type SourceBadge =
@@ -519,8 +576,20 @@ function ToggleSwitch({
519576
}
520577

521578
export function SettingsField(props: SettingsFieldProps) {
522-
const { spec, resolved, value, onChange, onAction, locked, saving, labels, error, note, writeScope } =
523-
props;
579+
const {
580+
spec,
581+
resolved,
582+
value,
583+
onChange,
584+
onAction,
585+
locked,
586+
saving,
587+
labels,
588+
error,
589+
note,
590+
writeScope,
591+
onClearSaved,
592+
} = props;
524593
const id = useId();
525594
const { t, language } = useObjectTranslation();
526595
const disabled = Boolean(locked || saving);
@@ -691,6 +760,11 @@ export function SettingsField(props: SettingsFieldProps) {
691760
// control is a group that names itself from `labelId` instead.
692761
// A value already cleared in the draft has nothing left to reset.
693762
const onReset = value === null ? undefined : () => onChange(null);
763+
// The clear beneath an env pin, where a value is stored at this row's scope.
764+
const clearSaved =
765+
onClearSaved && storedBeneathPin(resolved, writeScope) ? (
766+
<ClearSavedBeneathPin onClear={onClearSaved} disabled={saving} />
767+
) : null;
694768
// `data-settings-key` lets the page find this row to focus it when a save is
695769
// blocked on it (objectui#13239).
696770
const wrapper = (children: React.ReactNode, labelFor: string | undefined = id) => (
@@ -723,6 +797,7 @@ export function SettingsField(props: SettingsFieldProps) {
723797
{note}
724798
</p>
725799
) : null}
800+
{clearSaved}
726801
</div>
727802
);
728803

@@ -832,6 +907,7 @@ export function SettingsField(props: SettingsFieldProps) {
832907
) : (
833908
<FieldDescription description={fieldHelp} />
834909
)}
910+
{clearSaved}
835911
</div>
836912
<ToggleSwitch
837913
id={id}

‎ui/apps/console/src/pages/settings/SettingsView.tsx‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* Fetches manifest+values from `/api/settings/:namespace`, lays them
55
* out via <SettingsField>, tracks dirty state, and saves through PUT.
66
* A successful save or action reads the namespace again, manifest included.
7-
* Env-locked fields are read-only and show <EnvLockBadge>.
7+
* Env-locked fields are read-only and show <EnvLockBadge>; one with a value
8+
* stored beneath the pin offers to clear that saved value.
89
*/
910

1011
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -596,6 +597,30 @@ export function SettingsView() {
596597
}
597598
};
598599

600+
/**
601+
* Clear the value stored beneath an env pin (objectstack#30665): `null` for
602+
* this one key, the write the server lets through a pin (ADR-0007 D6). It
603+
* writes at once, after the row's own confirm, and leaves the draft alone: the
604+
* row cannot be edited, so the clear is not one of the page's edits. The pin
605+
* still answers the key, so the row reads the env value before and after.
606+
*/
607+
const onClearSaved = async (key: string) => {
608+
setSaving(true);
609+
try {
610+
const res = await saveSettingsNamespace(namespace, { [key]: null });
611+
setPayload({ ...payload, values: { ...values, ...res.values } });
612+
toast.success(t('console.settingsView.savedCleared'));
613+
void reread(namespace);
614+
} catch (err: any) {
615+
// The pin itself never refuses a clear, so a refusal here is something
616+
// else (a lock a stored row carries, a 403): the server's sentence says
617+
// which, and `lockedByEnv` would misname it.
618+
toast.error(err?.message ?? t('console.settingsView.saveFailed'));
619+
} finally {
620+
setSaving(false);
621+
}
622+
};
623+
599624
const onAction = async (actionId: string) => {
600625
setSaving(true);
601626
try {
@@ -734,6 +759,7 @@ export function SettingsView() {
734759
// The scope the server writes this key at: the specifier's
735760
// own, else the manifest's, else the spec's default `tenant`.
736761
writeScope={spec.scope ?? manifest.scope ?? 'tenant'}
762+
onClearSaved={key ? () => onClearSaved(key) : undefined}
737763
/>
738764
);
739765
})}

0 commit comments

Comments
 (0)