Skip to content

Commit b6f4c0e

Browse files
feat(console): Allowed IP ranges shows the address the server judges before a save (#30540)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu2w1nngmTV9xFM22hjNeNh5)_ domain:ui#4 · os-sales · session_01L6bhgVFhcnGrB1Wp8fLTwB Fixes #30313 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:设置页「认证」里的「允许的 IP 段」字段下方,新增一行说明服务器判定的当前地址(读 `GET /api/v1/auth/admin/client-address`),并按 `trustProxy` 说明这个地址是取自受信任代理的转发头,还是连接本身(不信任代理时,在代理之后看到的是代理地址);服务器无法确定地址时,说明此处无法保存非空列表。保存被拒时这行仍在,与拒绝信息并列。 - 为什么改:保存一个不含自己地址的列表会被拒绝,但以前管理员只有先被拒一次,才知道服务器认的是哪个地址。 - 风险与回滚:只加不改,只读一个已落地的接口;接口拒绝(非平台管理员 401/403)、老服务器没有该接口或网络失败时,什么都不显示,页面其余不变。新增 3 个文案键,10 种语言都已翻译。回滚即撤本 PR。 - 先看:`ui/apps/console/src/pages/settings/SettingsView.tsx`(读取与 `clientAddressNote`);`SettingsField.tsx` 新的 `note` 槽与 `aria-describedby`;`api.ts` 的 `getJudgedClientAddress` 形状检查。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/pages/settings/__tests__/SettingsView.clientAddress-30313.test.tsx`,预期 1 个文件、9 条全绿。 - 需要你做的:无,席位落地。 ## What changes Item 1 of the card. The Authentication settings page reads the caller's address as the server judges it, `GET /api/v1/auth/admin/client-address` (PR #30483: `{ clientAddress, trustProxy }` from the save check's own `judgedClientAddress` call, platform-admin gated, `no-store`), and draws one line under the Allowed IP ranges row: - `trustProxy: false`: "Your address, as the server judges it: ADDRESS. A list that leaves it out cannot be saved from here. This server does not trust proxy headers, so behind a proxy this is the proxy's address." - `trustProxy: true`: the address, "read from the forwarded headers of the proxy this server trusts". - `clientAddress: null`: "The server cannot establish your address, so a non-empty list cannot be saved from here." (the route's documented consequence of null). The trustProxy half is spelled out because it changes what the admin should do with the address: with trust off, behind a proxy every caller is judged by the proxy's address. - `api.ts`: `getJudgedClientAddress()`, through the module's existing envelope unwrap; a body whose `clientAddress` is not a non-empty string or null, or whose `trustProxy` is not a boolean, throws by name. - `SettingsView.tsx`: reads it once per visit, only on the `auth` namespace, and passes the sentence as `note` to the `allowed_ip_ranges` row. Any failure (401, 403, a server without the route, a malformed body, a network error) draws no note: no guessed address, and the page is otherwise unchanged. - `SettingsField.tsx`: an optional `note` drawn under the control after the help text or the error, never in place of either; the control's `aria-describedby` names the error and the note. - `@object-ui/i18n`: `console.settingsView.clientAddressDirect`, `clientAddressViaProxy`, `clientAddressUnknown` in all ten shipped packs (en, zh, ja, ko, de, fr, es, pt, ru, ar). Item 2 re-measured, not rebuilt: on this branch a refused save still shows the server's sentence naming `constraint.clientAddress` in red under the row (the generic field-error path), and the new line stays beside it. Both items hold, so `Fixes #30313`. Before / after, real stack (showcase `objectstack dev --seed-admin`, the branch's console under vite, Playwright 1440 px; "before" is the same page with the note's one line ablated live, held and restored with `ablation-replace.mjs`): | | zh-CN | |:--|:--| | before, nothing under the field says which address is judged | ![before](https://github.com/objectstack-ai/objectstack/blob/e1c74c2245caa2590e453e24fa209a549b965f6c/evidence/30313/before-1-before-save-zh-CN-1440.png?raw=true) | | after, the judged address under the field | ![after](https://github.com/objectstack-ai/objectstack/blob/e1c74c2245caa2590e453e24fa209a549b965f6c/evidence/30313/after-1-before-save-zh-CN-1440.png?raw=true) | | after, a refused save: the server's sentence, and the line beside it | ![refused](https://github.com/objectstack-ai/objectstack/blob/e1c74c2245caa2590e453e24fa209a549b965f6c/evidence/30313/after-2-refused-zh-CN-1440.png?raw=true) | en-US after: ![after en](https://github.com/objectstack-ai/objectstack/blob/e1c74c2245caa2590e453e24fa209a549b965f6c/evidence/30313/after-1-before-save-en-US-1440.png?raw=true) ## Tests `ui/apps/console/src/pages/settings/__tests__/SettingsView.clientAddress-30313.test.tsx`, the whole page over a stubbed `fetch` and the real `api.ts`, sentences read from the shipped `en` pack: the note under the list and only there, help text kept, `aria-describedby`; the trust-proxy and null sentences; no note on 403, 401, a malformed body and a network failure; no read on another namespace with a same-named key; the note kept beside a `SETTINGS_VALIDATION` refusal carrying `constraint.clientAddress`, both named by `aria-describedby`, one read per visit. ## Acceptance notes - The note is hard-wired to `auth` / `allowed_ip_ranges` in the console, as the route ledger entry describes ("the console's Authentication settings page reads it beside the Allowed IP ranges field"); the settings manifest has no key to declare it. - `@object-ui/i18n` gains three keys and no export, so `Clause-②: no` stands. - The en pack is in the eager closure; three sentences are added to it. The eager-closure budget is measured by CI's Bundle Analysis, not locally. ## Verification <details><summary>Unit tests (head 7dbb986, code identical at da21d37)</summary> `pnpm exec vitest run --maxWorkers=2 apps/console/src/pages/settings` (in `ui/`, under `os-verify-lock`): `Test Files 35 passed (35)`, `Tests 297 passed (297)`, `VERDICT command-exit 0`. </details> <details><summary>Ablation</summary> `ablation-replace.mjs` on `SettingsView.tsx`, anchor `? clientAddressNote(judgedAddress, t)` to `? undefined` (anchor 1 to 0, blob b5ed73e to d292d590): the new file goes `4 failed | 5 passed (9)`, the four presence cases red, the five absence cases green as expected. Restored: blob equals HEAD, `git diff HEAD` empty. </details> <details><summary>Live readings (real stack)</summary> - `GET /api/v1/auth/admin/client-address` as the seeded admin: 200, `Cache-Control: no-store`, `{ success: true, data: { clientAddress: "127.0.0.1", trustProxy: false } }`. - Row text before a save (en-US): label, help text, then "Your address, as the server judges it: 127.0.0.1. A list that leaves it out cannot be saved from here. This server does not trust proxy headers, so behind a proxy this is the proxy's address." zh-CN: "服务器判定的你的地址:127.0.0.1。不包含该地址的列表无法在此保存。此服务器不信任代理转发头,若部署在代理之后,这里显示的是代理的地址。" - Save `203.0.113.0/24`: `PUT /api/settings/auth` 400 `SETTINGS_VALIDATION`, `details.fields[0]` field `allowed_ip_ranges`, code `invalid_value`, `constraint.clientAddress` `127.0.0.1`; the row shows that message in red, then the note; toast "1 setting needs attention: Allowed IP ranges". Same in zh-CN with the server's zh sentence. - Before (note ablated): the row carries label and help text only, in both locales; the refusal reads the same. </details> <details><summary>Derived gates (dispatch-gates --commands at 7dbb986, 15 paths; code identical at da21d37)</summary> 35 derived, 35 run, all exit 0; `--ran` reconciliation: 35 run, 0 NOT-MEASURED, 0 UNRUN. ```text 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 --dir ui exec vitest run scripts/ :: 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:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: 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 ``` Also: `turbo run build --filter='@object-ui/console^...'` then `@object-ui/console` `type-check` and `@object-ui/i18n` `type-check`: `VERDICT command-exit 0`. `eslint` on the changed console files and the en/zh packs: 0 errors (5 warnings, all on lines this PR does not touch). Repo-wide lint and the full UI suites are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgVFhcnGrB1Wp8fLTwB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bbd6072 commit b6f4c0e

15 files changed

Lines changed: 410 additions & 4 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): the Allowed IP ranges setting shows the address the server judges before you save
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A save of `auth.allowed_ip_ranges` whose list leaves out the saving request's own address is refused, and until now the Authentication settings page only told the admin that address in the refusal. The page now reads `GET /api/v1/auth/admin/client-address` (the save check's own reading of the address, `server.trustProxy` included) and draws one line under the field: the address, and whether it came from a trusted proxy's forwarded headers or, with `trustProxy` off, from the connection itself (behind a proxy, the proxy's address). When the server cannot establish an address the line says a non-empty list cannot be saved from here. The line stays beside a refusal. A caller the route refuses (not a platform admin), or a server without the route, sees no line and the page is otherwise unchanged. `@object-ui/i18n` adds `console.settingsView.clientAddressDirect`, `clientAddressViaProxy` and `clientAddressUnknown` in all ten shipped locales.

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ export interface SettingsFieldProps {
114114
* sitting under a red error reads as if the field has two states at once.
115115
*/
116116
error?: string;
117+
/**
118+
* A fact the PAGE knows about this row that the manifest cannot carry
119+
* (objectstack#30313: the address the server judges beside Allowed IP
120+
* ranges). Drawn under the control after the help text or the error, never
121+
* in place of either: a refusal does not make the fact any less true.
122+
* Already translated by the page.
123+
*/
124+
note?: string;
117125
/**
118126
* The scope this row's Save writes. The page passes it; a host that does
119127
* not (the timezone prompt) keeps the badges a lower scope would see.
@@ -395,7 +403,7 @@ function DomainCombobox({
395403
}
396404

397405
export function SettingsField(props: SettingsFieldProps) {
398-
const { spec, resolved, value, onChange, onAction, locked, saving, labels, error, writeScope } =
406+
const { spec, resolved, value, onChange, onAction, locked, saving, labels, error, note, writeScope } =
399407
props;
400408
const id = useId();
401409
const { t } = useObjectTranslation();
@@ -557,7 +565,11 @@ export function SettingsField(props: SettingsFieldProps) {
557565
// through `wrapper`, so marking it here covers all of them at once instead of
558566
// per-case (objectstack#4224).
559567
const errorId = `${id}-error`;
568+
const noteId = `${id}-note`;
560569
const labelId = `${id}-label`;
570+
// The note describes the control too, so a screen reader reads it with the
571+
// field, after the error when there is one.
572+
const describedBy = [error ? errorId : null, note ? noteId : null].filter(Boolean).join(' ');
561573
// `labelFor` is the id of the labelable control the row renders — `id`, the
562574
// one every single-control case puts on it — or `undefined` for a case whose
563575
// control is a group that names itself from `labelId` instead.
@@ -579,17 +591,22 @@ export function SettingsField(props: SettingsFieldProps) {
579591
writeScope={writeScope}
580592
describeDefault={describeDefault}
581593
/>
582-
{error && isValidElement(children)
594+
{describedBy && isValidElement(children)
583595
? cloneElement(children as ReactElement<Record<string, unknown>>, {
584-
'aria-invalid': true,
585-
'aria-describedby': errorId,
596+
...(error ? { 'aria-invalid': true } : {}),
597+
'aria-describedby': describedBy,
586598
})
587599
: children}
588600
{error ? (
589601
<FieldError id={errorId} message={error} />
590602
) : (
591603
<FieldDescription description={fieldHelp} />
592604
)}
605+
{note ? (
606+
<p id={noteId} className="text-xs text-muted-foreground mt-1" data-testid="settings-field-note">
607+
{note}
608+
</p>
609+
) : null}
593610
</div>
594611
);
595612

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ import { extractFieldErrors } from '@object-ui/react';
1818
import { useObjectTranslation } from '@object-ui/i18n';
1919
import { SettingsField } from './SettingsField';
2020
import {
21+
getJudgedClientAddress,
2122
getSettingsNamespace,
2223
lockedKeyOf,
2324
runSettingsAction,
2425
saveSettingsNamespace,
26+
type JudgedClientAddress,
2527
} from './api';
2628
import { resolveLabel, type SettingsNamespacePayload, type Specifier } from './types';
2729
import { useSettingsLabel, type SettingsLabelHelpers } from './useSettingsLabel';
@@ -48,6 +50,32 @@ function evalVisibility(expr: string | undefined, data: Record<string, unknown>)
4850
}
4951
}
5052

53+
/**
54+
* The row the judged client address is drawn beside (objectstack#30313): the
55+
* `auth` namespace's Allowed IP ranges. A save of a list that leaves this
56+
* address out is refused (`invalid_value`, `constraint.clientAddress`), so the
57+
* admin is shown it before saving rather than learning it from the refusal.
58+
*/
59+
const CLIENT_ADDRESS_NAMESPACE = 'auth';
60+
const CLIENT_ADDRESS_KEY = 'allowed_ip_ranges';
61+
62+
/**
63+
* The note under Allowed IP ranges, one whole sentence per state so a pack
64+
* words each its own way: no address the server can establish (any non-empty
65+
* list is then refused at the save), an address read through a trusted proxy's
66+
* forwarded headers, or the transport's own peer address, which behind an
67+
* untrusted proxy is the proxy's.
68+
*/
69+
function clientAddressNote(
70+
judged: JudgedClientAddress,
71+
t: (key: string, options?: Record<string, unknown>) => string,
72+
): string {
73+
if (judged.clientAddress === null) return t('console.settingsView.clientAddressUnknown');
74+
return judged.trustProxy
75+
? t('console.settingsView.clientAddressViaProxy', { address: judged.clientAddress })
76+
: t('console.settingsView.clientAddressDirect', { address: judged.clientAddress });
77+
}
78+
5179
/** `{ ...rest }` without `key`, returned as a new object so React sees the change. */
5280
function omitKey<T>(map: Record<string, T>, key: string): Record<string, T> {
5381
const { [key]: _dropped, ...rest } = map;
@@ -331,6 +359,37 @@ export function SettingsView() {
331359
void loadNamespace();
332360
}, [namespace]);
333361

362+
/**
363+
* The caller's address as the server judges it, on the `auth` page only
364+
* (objectstack#30313), or null: not that page, not read yet, or the read
365+
* failed. A failure draws no note rather than a guess: the route is
366+
* platform-admin gated and absent on a server older than objectstack#30398,
367+
* and a refused save still names the address in the row.
368+
*/
369+
const [judgedRead, setJudgedRead] = useState<{
370+
namespace: string;
371+
judged: JudgedClientAddress;
372+
} | null>(null);
373+
useEffect(() => {
374+
if (namespace !== CLIENT_ADDRESS_NAMESPACE) return;
375+
let live = true;
376+
getJudgedClientAddress().then(
377+
(judged) => {
378+
if (live) setJudgedRead({ namespace, judged });
379+
},
380+
() => {
381+
// No note; see above.
382+
},
383+
);
384+
return () => {
385+
live = false;
386+
};
387+
}, [namespace]);
388+
// Tagged with the namespace it was read on, so another namespace's page
389+
// never draws it.
390+
const judgedAddress =
391+
judgedRead && judgedRead.namespace === namespace ? judgedRead.judged : null;
392+
334393
/** Effective live values for visibility expressions. */
335394
const liveValues = useMemo(() => {
336395
const v: Record<string, unknown> = {};
@@ -661,6 +720,13 @@ export function SettingsView() {
661720
saving={saving}
662721
labels={labels}
663722
error={key ? fieldErrors[key] : undefined}
723+
note={
724+
judgedAddress &&
725+
namespace === CLIENT_ADDRESS_NAMESPACE &&
726+
key === CLIENT_ADDRESS_KEY
727+
? clientAddressNote(judgedAddress, t)
728+
: undefined
729+
}
664730
// The scope the server writes this key at: the specifier's
665731
// own, else the manifest's, else the spec's default `tenant`.
666732
writeScope={spec.scope ?? manifest.scope ?? 'tenant'}

0 commit comments

Comments
 (0)