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
11 changes: 11 additions & 0 deletions .changeset/30313-allowed-ip-ranges-client-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/console': patch
'@object-ui/i18n': patch
---

feat(console): the Allowed IP ranges setting shows the address the server judges before you save

Clause-②: no
Metadata-protocol: none

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.
25 changes: 21 additions & 4 deletions ui/apps/console/src/pages/settings/SettingsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ export interface SettingsFieldProps {
* sitting under a red error reads as if the field has two states at once.
*/
error?: string;
/**
* A fact the PAGE knows about this row that the manifest cannot carry
* (objectstack#30313: the address the server judges beside Allowed IP
* ranges). Drawn under the control after the help text or the error, never
* in place of either: a refusal does not make the fact any less true.
* Already translated by the page.
*/
note?: string;
/**
* The scope this row's Save writes. The page passes it; a host that does
* not (the timezone prompt) keeps the badges a lower scope would see.
Expand Down Expand Up @@ -395,7 +403,7 @@ function DomainCombobox({
}

export function SettingsField(props: SettingsFieldProps) {
const { spec, resolved, value, onChange, onAction, locked, saving, labels, error, writeScope } =
const { spec, resolved, value, onChange, onAction, locked, saving, labels, error, note, writeScope } =
props;
const id = useId();
const { t } = useObjectTranslation();
Expand Down Expand Up @@ -557,7 +565,11 @@ export function SettingsField(props: SettingsFieldProps) {
// through `wrapper`, so marking it here covers all of them at once instead of
// per-case (objectstack#4224).
const errorId = `${id}-error`;
const noteId = `${id}-note`;
const labelId = `${id}-label`;
// The note describes the control too, so a screen reader reads it with the
// field, after the error when there is one.
const describedBy = [error ? errorId : null, note ? noteId : null].filter(Boolean).join(' ');
// `labelFor` is the id of the labelable control the row renders — `id`, the
// one every single-control case puts on it — or `undefined` for a case whose
// control is a group that names itself from `labelId` instead.
Expand All @@ -579,17 +591,22 @@ export function SettingsField(props: SettingsFieldProps) {
writeScope={writeScope}
describeDefault={describeDefault}
/>
{error && isValidElement(children)
{describedBy && isValidElement(children)
? cloneElement(children as ReactElement<Record<string, unknown>>, {
'aria-invalid': true,
'aria-describedby': errorId,
...(error ? { 'aria-invalid': true } : {}),
'aria-describedby': describedBy,
})
: children}
{error ? (
<FieldError id={errorId} message={error} />
) : (
<FieldDescription description={fieldHelp} />
)}
{note ? (
<p id={noteId} className="text-xs text-muted-foreground mt-1" data-testid="settings-field-note">
{note}
</p>
) : null}
</div>
);

Expand Down
66 changes: 66 additions & 0 deletions ui/apps/console/src/pages/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import { extractFieldErrors } from '@object-ui/react';
import { useObjectTranslation } from '@object-ui/i18n';
import { SettingsField } from './SettingsField';
import {
getJudgedClientAddress,
getSettingsNamespace,
lockedKeyOf,
runSettingsAction,
saveSettingsNamespace,
type JudgedClientAddress,
} from './api';
import { resolveLabel, type SettingsNamespacePayload, type Specifier } from './types';
import { useSettingsLabel, type SettingsLabelHelpers } from './useSettingsLabel';
Expand All @@ -48,6 +50,32 @@ function evalVisibility(expr: string | undefined, data: Record<string, unknown>)
}
}

/**
* The row the judged client address is drawn beside (objectstack#30313): the
* `auth` namespace's Allowed IP ranges. A save of a list that leaves this
* address out is refused (`invalid_value`, `constraint.clientAddress`), so the
* admin is shown it before saving rather than learning it from the refusal.
*/
const CLIENT_ADDRESS_NAMESPACE = 'auth';
const CLIENT_ADDRESS_KEY = 'allowed_ip_ranges';

/**
* The note under Allowed IP ranges, one whole sentence per state so a pack
* words each its own way: no address the server can establish (any non-empty
* list is then refused at the save), an address read through a trusted proxy's
* forwarded headers, or the transport's own peer address, which behind an
* untrusted proxy is the proxy's.
*/
function clientAddressNote(
judged: JudgedClientAddress,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (judged.clientAddress === null) return t('console.settingsView.clientAddressUnknown');
return judged.trustProxy
? t('console.settingsView.clientAddressViaProxy', { address: judged.clientAddress })
: t('console.settingsView.clientAddressDirect', { address: judged.clientAddress });
}

/** `{ ...rest }` without `key`, returned as a new object so React sees the change. */
function omitKey<T>(map: Record<string, T>, key: string): Record<string, T> {
const { [key]: _dropped, ...rest } = map;
Expand Down Expand Up @@ -331,6 +359,37 @@ export function SettingsView() {
void loadNamespace();
}, [namespace]);

/**
* The caller's address as the server judges it, on the `auth` page only
* (objectstack#30313), or null: not that page, not read yet, or the read
* failed. A failure draws no note rather than a guess: the route is
* platform-admin gated and absent on a server older than objectstack#30398,
* and a refused save still names the address in the row.
*/
const [judgedRead, setJudgedRead] = useState<{
namespace: string;
judged: JudgedClientAddress;
} | null>(null);
useEffect(() => {
if (namespace !== CLIENT_ADDRESS_NAMESPACE) return;
let live = true;
getJudgedClientAddress().then(
(judged) => {
if (live) setJudgedRead({ namespace, judged });
},
() => {
// No note; see above.
},
);
return () => {
live = false;
};
}, [namespace]);
// Tagged with the namespace it was read on, so another namespace's page
// never draws it.
const judgedAddress =
judgedRead && judgedRead.namespace === namespace ? judgedRead.judged : null;

/** Effective live values for visibility expressions. */
const liveValues = useMemo(() => {
const v: Record<string, unknown> = {};
Expand Down Expand Up @@ -661,6 +720,13 @@ export function SettingsView() {
saving={saving}
labels={labels}
error={key ? fieldErrors[key] : undefined}
note={
judgedAddress &&
namespace === CLIENT_ADDRESS_NAMESPACE &&
key === CLIENT_ADDRESS_KEY
? clientAddressNote(judgedAddress, t)
: undefined
}
// The scope the server writes this key at: the specifier's
// own, else the manifest's, else the spec's default `tenant`.
writeScope={spec.scope ?? manifest.scope ?? 'tenant'}
Expand Down
Loading
Loading