diff --git a/.changeset/30313-allowed-ip-ranges-client-address.md b/.changeset/30313-allowed-ip-ranges-client-address.md new file mode 100644 index 00000000000..fc61e7497d2 --- /dev/null +++ b/.changeset/30313-allowed-ip-ranges-client-address.md @@ -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. diff --git a/ui/apps/console/src/pages/settings/SettingsField.tsx b/ui/apps/console/src/pages/settings/SettingsField.tsx index 0e6185f53c3..c22d5477729 100644 --- a/ui/apps/console/src/pages/settings/SettingsField.tsx +++ b/ui/apps/console/src/pages/settings/SettingsField.tsx @@ -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. @@ -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(); @@ -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. @@ -579,10 +591,10 @@ export function SettingsField(props: SettingsFieldProps) { writeScope={writeScope} describeDefault={describeDefault} /> - {error && isValidElement(children) + {describedBy && isValidElement(children) ? cloneElement(children as ReactElement>, { - 'aria-invalid': true, - 'aria-describedby': errorId, + ...(error ? { 'aria-invalid': true } : {}), + 'aria-describedby': describedBy, }) : children} {error ? ( @@ -590,6 +602,11 @@ export function SettingsField(props: SettingsFieldProps) { ) : ( )} + {note ? ( +

+ {note} +

+ ) : null} ); diff --git a/ui/apps/console/src/pages/settings/SettingsView.tsx b/ui/apps/console/src/pages/settings/SettingsView.tsx index c852470c178..b5ed73e960f 100644 --- a/ui/apps/console/src/pages/settings/SettingsView.tsx +++ b/ui/apps/console/src/pages/settings/SettingsView.tsx @@ -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'; @@ -48,6 +50,32 @@ function evalVisibility(expr: string | undefined, data: Record) } } +/** + * 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, +): 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(map: Record, key: string): Record { const { [key]: _dropped, ...rest } = map; @@ -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 = {}; @@ -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'} diff --git a/ui/apps/console/src/pages/settings/__tests__/SettingsView.clientAddress-30313.test.tsx b/ui/apps/console/src/pages/settings/__tests__/SettingsView.clientAddress-30313.test.tsx new file mode 100644 index 00000000000..18488031d7f --- /dev/null +++ b/ui/apps/console/src/pages/settings/__tests__/SettingsView.clientAddress-30313.test.tsx @@ -0,0 +1,235 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#30313 — the Allowed IP ranges row shows the caller's address as + * the server judges it, before a save. + * + * A save of `auth.allowed_ip_ranges` whose list leaves the saving request's own + * address out is refused (`invalid_value`, `constraint.clientAddress`), and the + * row said nothing about that address until the refusal. The server serves it + * at `GET /api/v1/auth/admin/client-address` (objectstack#30398), as + * `{ clientAddress, trustProxy }` from the save check's own call, so the page + * reads it and draws it under the row. The console never works the address + * out itself. + * + * The whole page over a stubbed `fetch` and the REAL `../api` module, so the + * URL, the envelope unwrap and the shape check are under test along with the + * row. Expected sentences come from the shipped `en` pack rather than being + * retyped, so the copy can be reworded without editing this file; what is + * pinned is which sentence each state draws, what it is filled with, and on + * which row. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { I18nProvider, en } from '@object-ui/i18n'; + +const { toastError, toastSuccess } = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); +vi.mock('sonner', () => ({ toast: { error: toastError, success: toastSuccess } })); + +import { SettingsView } from '../SettingsView'; + +const CLIENT_ADDRESS_URL = '/api/v1/auth/admin/client-address'; + +/** The `auth` rows that matter here: the list, and a neighbour that must stay bare. */ +const AUTH = { + manifest: { + namespace: 'auth', + version: 1, + label: 'Authentication', + specifiers: [ + { type: 'textarea', key: 'allowed_ip_ranges', label: 'Allowed IP ranges', description: 'CIDR ranges.' }, + { type: 'text', key: 'trusted_origins', label: 'Trusted origins' }, + ], + }, + values: { + allowed_ip_ranges: { value: null, source: 'default', locked: false }, + trusted_origins: { value: null, source: 'default', locked: false }, + }, +}; + +const LOCALIZATION = { + manifest: { + namespace: 'localization', + version: 1, + label: 'Localization', + specifiers: [{ type: 'textarea', key: 'allowed_ip_ranges', label: 'Allowed IP ranges' }], + }, + values: { allowed_ip_ranges: { value: null, source: 'default', locked: false } }, +}; + +const ok = (data: unknown) => + ({ ok: true, status: 200, statusText: 'OK', json: async () => ({ success: true, data }) }) as unknown as Response; + +const refused = (status: number, code: string) => + ({ + ok: false, + status, + statusText: status === 401 ? 'Unauthorized' : 'Forbidden', + json: async () => ({ success: false, error: { code, message: 'Platform admin required.' } }), + }) as unknown as Response; + +const fetchMock = vi.fn(); + +/** Serve the namespace page, and answer the address read with `address`. */ +function serve(page: unknown, address: () => Promise) { + fetchMock.mockImplementation(async (url: string) => + String(url).endsWith(CLIENT_ADDRESS_URL) ? address() : ok(page), + ); +} + +const addressReads = () => fetchMock.mock.calls.filter(([url]) => String(url).endsWith(CLIENT_ADDRESS_URL)); + +const sentence = (key: 'clientAddressDirect' | 'clientAddressViaProxy' | 'clientAddressUnknown', address?: string) => + en.console.settingsView[key].replace('{{address}}', address ?? ''); + +/** The row a key draws, found by the hook the page itself uses. */ +const rowOf = (key: string) => document.querySelector(`[data-settings-key="${key}"]`); + +beforeEach(() => { + fetchMock.mockReset(); + toastError.mockReset(); + toastSuccess.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function renderAt(namespace: string) { + return render( + + + + } /> + + + , + ); +} + +describe('SettingsView — Allowed IP ranges shows the address the server judges (objectstack#30313)', () => { + it('draws the served address under the list, and only under the list', async () => { + serve(AUTH, async () => ok({ clientAddress: '203.0.113.7', trustProxy: false })); + renderAt('auth'); + + const note = await screen.findByTestId('settings-field-note'); + expect(note.textContent).toBe(sentence('clientAddressDirect', '203.0.113.7')); + expect(rowOf('allowed_ip_ranges')!.contains(note)).toBe(true); + expect(rowOf('trusted_origins')!.querySelector('[data-testid="settings-field-note"]')).toBeNull(); + // The help text stays: the note is drawn beside it, not in its place. + expect(rowOf('allowed_ip_ranges')!.textContent).toContain('CIDR ranges.'); + // The control names the note for a screen reader. + const box = rowOf('allowed_ip_ranges')!.querySelector('textarea')!; + expect(box.getAttribute('aria-describedby')).toBe(note.id); + expect(box.hasAttribute('aria-invalid')).toBe(false); + + // One read, at the served route, with the session cookie. + expect(addressReads()).toHaveLength(1); + expect(addressReads()[0][1]).toMatchObject({ credentials: 'include' }); + }); + + it('says the address came through the trusted proxy when trustProxy is on', async () => { + serve(AUTH, async () => ok({ clientAddress: '198.51.100.20', trustProxy: true })); + renderAt('auth'); + + const note = await screen.findByTestId('settings-field-note'); + expect(note.textContent).toBe(sentence('clientAddressViaProxy', '198.51.100.20')); + }); + + it('says no address could be established when the server answers null', async () => { + serve(AUTH, async () => ok({ clientAddress: null, trustProxy: false })); + renderAt('auth'); + + const note = await screen.findByTestId('settings-field-note'); + expect(note.textContent).toBe(sentence('clientAddressUnknown')); + }); + + it.each([ + ['a caller who is not a platform admin (403)', async () => refused(403, 'FORBIDDEN')], + ['no session (401)', async () => refused(401, 'UNAUTHORIZED')], + ['a body that is not the declared shape', async () => ok({ clientAddress: '203.0.113.7' })], + ['a network failure', async () => Promise.reject(new TypeError('Failed to fetch'))], + ])('draws no note, and the page still renders, on %s', async (_what, answer) => { + serve(AUTH, answer); + renderAt('auth'); + + await screen.findByText('Allowed IP ranges'); + await waitFor(() => expect(addressReads()).toHaveLength(1)); + // Let the rejected read settle before asserting its absence. + await new Promise((r) => setTimeout(r, 0)); + expect(screen.queryByTestId('settings-field-note')).toBeNull(); + expect(screen.queryByText(/Failed to load settings/)).toBeNull(); + }); + + it('never reads the address on another namespace, even one with a same-named key', async () => { + serve(LOCALIZATION, async () => ok({ clientAddress: '203.0.113.7', trustProxy: false })); + renderAt('localization'); + + await screen.findByText('Allowed IP ranges'); + expect(addressReads()).toHaveLength(0); + expect(screen.queryByTestId('settings-field-note')).toBeNull(); + }); + + it('keeps the note beside the refusal that names the same address', async () => { + const REFUSAL = + 'No entry in this list matches 203.0.113.7, the address you are connected from, so saving it would refuse your own next sign-in.'; + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + if (String(url).endsWith(CLIENT_ADDRESS_URL)) return ok({ clientAddress: '203.0.113.7', trustProxy: false }); + if (init?.method === 'PUT') { + return { + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ + success: false, + error: { + code: 'SETTINGS_VALIDATION', + message: `Settings for 'auth' are incomplete: allowed_ip_ranges — ${REFUSAL}`, + details: { + namespace: 'auth', + fields: [ + { + field: 'allowed_ip_ranges', + code: 'invalid_value', + message: REFUSAL, + label: 'Allowed IP ranges', + constraint: { clientAddress: '203.0.113.7' }, + }, + ], + }, + }, + }), + } as unknown as Response; + } + return ok(AUTH); + }); + renderAt('auth'); + + const note = await screen.findByTestId('settings-field-note'); + const box = rowOf('allowed_ip_ranges')!.querySelector('textarea')!; + fireEvent.change(box, { target: { value: '192.0.2.0/24' } }); + fireEvent.click(await screen.findByRole('button', { name: /save/i })); + + const error = await screen.findByRole('alert'); + expect(error.textContent).toBe(REFUSAL); + expect(rowOf('allowed_ip_ranges')!.contains(error)).toBe(true); + // The note is still there, after the error, and both describe the control. + expect(screen.getByTestId('settings-field-note')).toBe(note); + expect(box.getAttribute('aria-describedby')).toBe(`${error.id} ${note.id}`); + expect(box.getAttribute('aria-invalid')).toBe('true'); + // The address is read once per visit, not again after the save. + expect(addressReads()).toHaveLength(1); + }); +}); diff --git a/ui/apps/console/src/pages/settings/api.ts b/ui/apps/console/src/pages/settings/api.ts index 14b80d58523..40e84487636 100644 --- a/ui/apps/console/src/pages/settings/api.ts +++ b/ui/apps/console/src/pages/settings/api.ts @@ -212,6 +212,50 @@ export async function runSettingsAction( return { ok: res.ok, message }; } +/** + * The caller's address as the server judges it (objectstack#30313), read from + * `GET /api/v1/auth/admin/client-address` (objectstack#30398). + * + * `clientAddress` is the address the `auth.allowed_ip_ranges` save check and + * the sign-in gate judge this connection by (the save check's own call), or + * `null` when the server cannot establish one. `trustProxy` says whether + * forwarded headers counted (`server.trustProxy`). ⛔ The console never works + * its own address out: behind a proxy the address a browser could learn is not + * the one the server judges. + */ +export interface JudgedClientAddress { + clientAddress: string | null; + trustProxy: boolean; +} + +const AUTH_BASE = `${SERVER_URL}/api/v1/auth`; + +/** + * Read {@link JudgedClientAddress}. The route is platform-admin gated, so a + * caller who is not one is refused (`401`/`403`) and this throws like any other + * failed read; so does a body that is not the declared shape. The caller + * decides what a failure shows. + */ +export async function getJudgedClientAddress(): Promise { + const res = await fetch(`${AUTH_BASE}/admin/client-address`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + const body = await jsonOrThrow | null>(res); + const address = body && typeof body === 'object' ? body.clientAddress : undefined; + if ( + !body || + typeof body !== 'object' || + typeof body.trustProxy !== 'boolean' || + !(address === null || (typeof address === 'string' && address.length > 0)) + ) { + throw new Error( + 'Malformed response from GET /api/v1/auth/admin/client-address: expected an object carrying `clientAddress` (a string or null) and `trustProxy` (a boolean).', + ); + } + return { clientAddress: address, trustProxy: body.trustProxy }; +} + /** A body that actually carries an action verdict — `ok` is the one required key. */ function isActionResult(value: unknown): value is SettingsActionResult { return ( diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index e9e53882939..627e8a135f0 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2442,6 +2442,9 @@ const ar = { unsupportedSpecifierType: "نوع إعداد غير مدعوم: {{type}}", invalidPhone: "أدخل رقم هاتف يتكون من الأرقام والمسافات و + ( ) - . فقط", invalidColor: "أدخل لونًا سداسيًا عشريًا، مثل #1a2b3c", + clientAddressDirect: "عنوانك كما يحدده الخادم: {{address}}. لا يمكن حفظ قائمة لا تتضمنه من هنا. لا يثق هذا الخادم بترويسات الوكيل، لذا خلف وكيل يكون هذا عنوان الوكيل.", + clientAddressViaProxy: "عنوانك كما يحدده الخادم: {{address}}، مقروءًا من ترويسات التمرير للوكيل الذي يثق به هذا الخادم. لا يمكن حفظ قائمة لا تتضمنه من هنا.", + clientAddressUnknown: "لا يستطيع الخادم تحديد عنوانك، لذا لا يمكن حفظ قائمة غير فارغة من هنا.", }, workspaceTimezonePrompt: { title: "تعيين المنطقة الزمنية للنظام", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index d0646531a62..d1b7ebd6e34 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2203,6 +2203,9 @@ const de = { unsupportedSpecifierType: "Nicht unterstützter Einstellungstyp: {{type}}", invalidPhone: "Geben Sie eine Telefonnummer nur mit Ziffern, Leerzeichen und + ( ) - . ein", invalidColor: "Geben Sie eine Hex-Farbe ein, z. B. #1a2b3c", + clientAddressDirect: "Ihre Adresse, wie der Server sie beurteilt: {{address}}. Eine Liste ohne diese Adresse kann von hier aus nicht gespeichert werden. Dieser Server vertraut keinen Proxy-Headern, hinter einem Proxy ist dies also die Adresse des Proxys.", + clientAddressViaProxy: "Ihre Adresse, wie der Server sie beurteilt: {{address}}, gelesen aus den Weiterleitungs-Headern des Proxys, dem dieser Server vertraut. Eine Liste ohne diese Adresse kann von hier aus nicht gespeichert werden.", + clientAddressUnknown: "Der Server kann Ihre Adresse nicht ermitteln, daher kann von hier aus keine nicht leere Liste gespeichert werden.", }, workspaceTimezonePrompt: { title: "Zeitzone des Systems festlegen", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index c305d1cf275..52261a72b26 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2360,6 +2360,12 @@ const en = { // `validation.url`; these two name what a phone number and a colour take. invalidPhone: 'Enter a phone number using only digits, spaces and + ( ) - .', invalidColor: 'Enter a hex color, such as #1a2b3c', + // The address the server judges beside Allowed IP ranges (objectstack#30313), + // one whole sentence per state of `GET /api/v1/auth/admin/client-address`: + // `trustProxy` off, on, and no address the server can establish. + clientAddressDirect: '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.', + clientAddressViaProxy: 'Your address, as the server judges it: {{address}}, read from the forwarded headers of the proxy this server trusts. A list that leaves it out cannot be saved from here.', + clientAddressUnknown: 'The server cannot establish your address, so a non-empty list cannot be saved from here.', }, // The one-time workspace-timezone prompt (objectui#11758): asked of an // administrator while `localization.timezone` is still the manifest default. diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 998a09d42e9..7d27c99ebc0 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2265,6 +2265,9 @@ const es = { unsupportedSpecifierType: "Tipo de ajuste no admitido: {{type}}", invalidPhone: "Introduzca un número de teléfono solo con dígitos, espacios y + ( ) - .", invalidColor: "Introduzca un color hexadecimal, como #1a2b3c", + clientAddressDirect: "Su dirección, tal como la juzga el servidor: {{address}}. Una lista que no la incluya no se puede guardar desde aquí. Este servidor no confía en los encabezados de proxy, así que detrás de un proxy esta es la dirección del proxy.", + clientAddressViaProxy: "Su dirección, tal como la juzga el servidor: {{address}}, leída de los encabezados reenviados por el proxy en el que confía este servidor. Una lista que no la incluya no se puede guardar desde aquí.", + clientAddressUnknown: "El servidor no puede determinar su dirección, así que desde aquí no se puede guardar una lista que no esté vacía.", }, workspaceTimezonePrompt: { title: "Configure la zona horaria del sistema", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 8387dbb5ffb..146462c42ef 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2261,6 +2261,9 @@ const fr = { unsupportedSpecifierType: "Type de paramètre non pris en charge : {{type}}", invalidPhone: "Saisissez un numéro de téléphone composé uniquement de chiffres, d’espaces et de + ( ) - .", invalidColor: "Saisissez une couleur hexadécimale, par exemple #1a2b3c", + clientAddressDirect: "Votre adresse, telle que le serveur la juge : {{address}}. Une liste qui ne la contient pas ne peut pas être enregistrée d’ici. Ce serveur ne fait pas confiance aux en-têtes de proxy : derrière un proxy, c’est donc l’adresse du proxy.", + clientAddressViaProxy: "Votre adresse, telle que le serveur la juge : {{address}}, lue dans les en-têtes transférés par le proxy auquel ce serveur fait confiance. Une liste qui ne la contient pas ne peut pas être enregistrée d’ici.", + clientAddressUnknown: "Le serveur ne peut pas établir votre adresse : une liste non vide ne peut donc pas être enregistrée d’ici.", }, workspaceTimezonePrompt: { title: "Définir le fuseau horaire du système", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index a1078891d66..30674d9e687 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2205,6 +2205,9 @@ const ja = { unsupportedSpecifierType: "サポートされていない設定項目の種類: {{type}}", invalidPhone: "数字、スペース、+ ( ) - . のみで電話番号を入力してください", invalidColor: "#1a2b3c のような16進カラーを入力してください", + clientAddressDirect: "サーバーが判定したあなたのアドレス: {{address}}。このアドレスを含まないリストはここから保存できません。このサーバーはプロキシのヘッダーを信頼しないため、プロキシの背後ではプロキシのアドレスになります。", + clientAddressViaProxy: "サーバーが判定したあなたのアドレス: {{address}}(このサーバーが信頼するプロキシの転送ヘッダーから取得)。このアドレスを含まないリストはここから保存できません。", + clientAddressUnknown: "サーバーがあなたのアドレスを特定できないため、空でないリストはここから保存できません。", }, workspaceTimezonePrompt: { title: "システムのタイムゾーンを設定", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 76b51f0a744..2c60ae80d44 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2203,6 +2203,9 @@ const ko = { unsupportedSpecifierType: "지원되지 않는 설정 항목 유형: {{type}}", invalidPhone: "숫자, 공백, + ( ) - . 만 사용하여 전화번호를 입력해주세요", invalidColor: "#1a2b3c 와 같은 16진수 색상을 입력해주세요", + clientAddressDirect: "서버가 판단한 사용자 주소: {{address}}. 이 주소가 포함되지 않은 목록은 여기에서 저장할 수 없습니다. 이 서버는 프록시 헤더를 신뢰하지 않으므로, 프록시 뒤에서는 프록시의 주소가 표시됩니다.", + clientAddressViaProxy: "서버가 판단한 사용자 주소: {{address}} (이 서버가 신뢰하는 프록시의 전달 헤더에서 읽음). 이 주소가 포함되지 않은 목록은 여기에서 저장할 수 없습니다.", + clientAddressUnknown: "서버가 사용자 주소를 확인할 수 없으므로, 비어 있지 않은 목록은 여기에서 저장할 수 없습니다.", }, workspaceTimezonePrompt: { title: "시스템 시간대 설정", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 4275aaee33e..d5f30daed08 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2260,6 +2260,9 @@ const pt = { unsupportedSpecifierType: "Tipo de configuração não suportado: {{type}}", invalidPhone: "Insira um número de telefone usando apenas dígitos, espaços e + ( ) - .", invalidColor: "Insira uma cor hexadecimal, como #1a2b3c", + clientAddressDirect: "Seu endereço, como o servidor o avalia: {{address}}. Uma lista que não o inclua não pode ser salva daqui. Este servidor não confia em cabeçalhos de proxy, então, atrás de um proxy, este é o endereço do proxy.", + clientAddressViaProxy: "Seu endereço, como o servidor o avalia: {{address}}, lido dos cabeçalhos encaminhados pelo proxy em que este servidor confia. Uma lista que não o inclua não pode ser salva daqui.", + clientAddressUnknown: "O servidor não consegue determinar seu endereço, então uma lista não vazia não pode ser salva daqui.", }, workspaceTimezonePrompt: { title: "Definir o fuso horário do sistema", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index a549ca1cdae..7bc8a45aa06 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2329,6 +2329,9 @@ const ru = { unsupportedSpecifierType: "Неподдерживаемый тип настройки: {{type}}", invalidPhone: "Введите номер телефона, используя только цифры, пробелы и + ( ) - .", invalidColor: "Введите цвет в шестнадцатеричном формате, например #1a2b3c", + clientAddressDirect: "Ваш адрес, как его определяет сервер: {{address}}. Список без этого адреса нельзя сохранить отсюда. Этот сервер не доверяет заголовкам прокси, поэтому за прокси это адрес самого прокси.", + clientAddressViaProxy: "Ваш адрес, как его определяет сервер: {{address}}, взят из пересылаемых заголовков прокси, которому доверяет этот сервер. Список без этого адреса нельзя сохранить отсюда.", + clientAddressUnknown: "Сервер не может определить ваш адрес, поэтому непустой список нельзя сохранить отсюда.", }, workspaceTimezonePrompt: { title: "Задайте часовой пояс системы", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 1c30bab54db..3ec4dc825f5 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1973,6 +1973,9 @@ const zh = { unsupportedSpecifierType: '不支持的设置项类型:{{type}}', invalidPhone: '请输入仅由数字、空格和 + ( ) - . 组成的电话号码', invalidColor: '请输入十六进制颜色,例如 #1a2b3c', + clientAddressDirect: '服务器判定的你的地址:{{address}}。不包含该地址的列表无法在此保存。此服务器不信任代理转发头,若部署在代理之后,这里显示的是代理的地址。', + clientAddressViaProxy: '服务器判定的你的地址:{{address}},取自此服务器所信任代理的转发头。不包含该地址的列表无法在此保存。', + clientAddressUnknown: '服务器无法确定你的地址,因此无法在此保存非空的列表。', }, workspaceTimezonePrompt: { title: '设置系统时区',