diff --git a/.changeset/cloud-connection-bind-failure-i18n-5054.md b/.changeset/cloud-connection-bind-failure-i18n-5054.md new file mode 100644 index 000000000..68a88aac2 --- /dev/null +++ b/.changeset/cloud-connection-bind-failure-i18n-5054.md @@ -0,0 +1,28 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +A cloud-connection bind failure now reads in the user's language whichever clock +noticed it (objectui#5054). + +One abandoned device approval could be noticed by either of two clocks, and the +Cloud Connection panel had a different answer for each. When the panel's own +`expires_in` deadline fired first it rendered `cloudConnection.errors.expired` — +translated in all ten packs. When the SERVER noticed first, `/bind/poll` answered +HTTP 400 with `message: 'Device authorization failed: expired_token'`; `getJson` +threw a bare `Error` carrying only that sentence, and the catch rendered it +verbatim. Same user, same failure, two languages, decided by which clock got +there first — visible on a zh console as the same abandoned approval reading +Chinese or English depending on whether the tab sat open past `expires_in`. + +`getJson` now carries the envelope's `declaredCode` and `code` across its throw, +and a single closed map turns the two RFC 8628 outcomes a user can actually cause +into console copy: `expired_token` → the existing `cloudConnection.errors.expired`, +`access_denied` → a new `cloudConnection.errors.accessDenied` added to all ten +locale packs. `declaredCode` is read first, because ADR-0112 keeps the upstream +spelling there — `code` is `DEVICE_CODE_FAILED` for both. + +Every other code is unchanged: `invalid_grant`, and anything upstream invents +next, still render the wire `message`, which stays the single source of truth for +failures this console has no copy for. No API, export or resolver was widened. diff --git a/packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx b/packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx index 0cf575273..1f71bc212 100644 --- a/packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx +++ b/packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx @@ -65,6 +65,42 @@ type Phase = | { kind: 'bound'; status: StatusData } | { kind: 'error'; message: string }; +/** + * The two RFC 8628 device-authorization outcomes a user can actually cause, + * rendered in the user's language instead of the producer's (objectui#5054). + * + * `declaredCode` is read FIRST because that is where the upstream spelling + * lives: it is not a member of the closed `ApiErrorSchema.code` vocabulary, so + * ADR-0112 puts it in the open producer-authored channel and `code` carries the + * registered member — `DEVICE_CODE_FAILED` for BOTH of these. `code` is + * consulted second only so a producer that does put the RFC spelling in the + * registered slot is still understood. + * + * ⛔ Closed on purpose. Every other code — `invalid_grant`, `slow_down`, + * whatever upstream invents next — returns `null` here and keeps rendering the + * wire `message`, which is both today's behaviour and the single source of + * truth for failures this console has no copy for. Widening this map means + * adding a key to all ten packs, not adding a branch here. + * + * The `t()` arguments are string literals so `check:i18n-keys` can resolve + * them against the `en` pack; a `Record` read as `t(key)` is a + * dynamic key and that gate goes blind to it. + */ +function translateFailureCode( + t: (key: string) => string, + declaredCode?: unknown, + code?: unknown, +): string | null { + for (const candidate of [declaredCode, code]) { + if (candidate === 'expired_token') return t('cloudConnection.errors.expired'); + if (candidate === 'access_denied') return t('cloudConnection.errors.accessDenied'); + } + return null; +} + +/** An `Error` that still carries the envelope's codes — see `getJson`. */ +type ApiFailure = Error & { declaredCode?: string; code?: string }; + async function getJson(url: string, init?: RequestInit): Promise { const resp = await fetch(url, { credentials: 'same-origin', @@ -74,7 +110,19 @@ async function getJson(url: string, init?: RequestInit): Promise { const body = await resp.json().catch(() => ({})); if (!resp.ok && body?.success !== true) { const msg = body?.error?.message ?? body?.error?.code ?? body?.error ?? `HTTP ${resp.status}`; - throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)); + // The codes ride along. A bare `Error` dropped them, and the message was + // then the only thing that survived the throw — which is exactly why a + // SERVER-detected expiry rendered the producer's English while the same + // expiry noticed by this panel's own clock rendered the locale. Attached, + // not subclassed: extending `Error` is brittle under a downlevel target. + const failure: ApiFailure = Object.assign( + new Error(typeof msg === 'string' ? msg : JSON.stringify(msg)), + { + declaredCode: typeof body?.error?.declaredCode === 'string' ? body.error.declaredCode : undefined, + code: typeof body?.error?.code === 'string' ? body.error.code : undefined, + }, + ); + throw failure; } return body; } @@ -86,6 +134,28 @@ export function CloudConnectionPanel() { const [copied, setCopied] = useState(false); const pollTimer = useRef | null>(null); + // `t` is a fresh function on every render under several translation + // providers, so it must not reach a `useCallback` dependency list that the + // MOUNT effect transitively depends on: `refreshStatus` is exactly that, and + // an unstable dep there re-runs the effect on every state update — an + // infinite render loop. Measured: routing `t` into `refreshStatus`'s deps + // timed out all nine cases in this directory's two suites at 15s. A latest-ref + // gives the helper the current `t` while keeping its own identity stable. + const tRef = useRef(t); + useEffect(() => { tRef.current = t; }, [t]); + + /** + * What a caught failure says to a human. One reading for all four catch + * sites, so no future call site can reintroduce the split this card closed. + */ + const failureText = useCallback((err: unknown): string => { + const e = err as { declaredCode?: unknown; code?: unknown; message?: unknown } | null | undefined; + return ( + translateFailureCode(tRef.current, e?.declaredCode, e?.code) ?? + ((e?.message as string | undefined) ?? String(err)) + ); + }, []); + const stopPolling = useCallback(() => { if (pollTimer.current) { clearTimeout(pollTimer.current); pollTimer.current = null; } }, []); @@ -96,9 +166,9 @@ export function CloudConnectionPanel() { const data: StatusData = body?.data ?? { environmentId: null, bound: false, connection: null }; setPhase(data.bound ? { kind: 'bound', status: data } : { kind: 'unbound' }); } catch (err: any) { - setPhase({ kind: 'error', message: err?.message ?? String(err) }); + setPhase({ kind: 'error', message: failureText(err) }); } - }, []); + }, [failureText]); useEffect(() => { void refreshStatus(); @@ -142,14 +212,20 @@ export function CloudConnectionPanel() { // render, and an unguarded object would reach JSX as a child. setPhase({ kind: 'error', - message: body?.error?.message ?? body?.error?.code ?? t('cloudConnection.errors.bindFailed'), + // The code -> copy map runs FIRST here too (objectui#5054): a body + // that reaches this branch carrying a spelling the console knows must + // read the same as the same spelling arriving on a 400, or the + // asymmetry just moves to a third reader. + message: + translateFailureCode(t, body?.error?.declaredCode, body?.error?.code) ?? + body?.error?.message ?? body?.error?.code ?? t('cloudConnection.errors.bindFailed'), }); } catch (err: any) { - setPhase({ kind: 'error', message: err?.message ?? String(err) }); + setPhase({ kind: 'error', message: failureText(err) }); } }; pollTimer.current = setTimeout(tick, intervalMs); - }, [refreshStatus, t]); + }, [failureText, refreshStatus, t]); const connect = useCallback(async () => { setBusy(true); @@ -170,11 +246,11 @@ export function CloudConnectionPanel() { setPhase({ kind: 'waiting', code, popupOpened }); poll(code, Date.now()); } catch (err: any) { - setPhase({ kind: 'error', message: err?.message ?? String(err) }); + setPhase({ kind: 'error', message: failureText(err) }); } finally { setBusy(false); } - }, [poll, t]); + }, [failureText, poll, t]); const disconnect = useCallback(async () => { setBusy(true); @@ -182,11 +258,11 @@ export function CloudConnectionPanel() { await getJson(`${BASE}/unbind`, { method: 'POST', body: '{}' }); await refreshStatus(); } catch (err: any) { - setPhase({ kind: 'error', message: err?.message ?? String(err) }); + setPhase({ kind: 'error', message: failureText(err) }); } finally { setBusy(false); } - }, [refreshStatus]); + }, [failureText, refreshStatus]); const copyCode = useCallback(async (code: string) => { try { diff --git a/packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindError.test.tsx b/packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindError.test.tsx index 8e37981f5..047004c7e 100644 --- a/packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindError.test.tsx +++ b/packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindError.test.tsx @@ -125,6 +125,26 @@ describe('objectui#5028 — the bind-failure text a human reads', () => { // objectstack#9267's body, verbatim, at the status it is served with. // `getJson` throws on it, so the message shown is the one IT picked; the // changed line in poll() is not on this path in either direction. + // + // objectui#5054 moved what "the one IT picked" IS. This case used to assert + // the wire sentence `Device authorization failed: expired_token`; that was + // the measured asymmetry — the SAME expiry noticed by the panel's own clock + // rendered `cloudConnection.errors.expired`, so one condition read in two + // languages. `getJson` now carries the envelope's codes across its throw and + // the catch maps the two user-causable RFC 8628 spellings onto that key, so + // both readers land on it. The fixture is untouched — it is still the + // producer's verbatim envelope — and the `DEVICE_CODE_FAILED` negative is + // still this file's own subject: no machine code reaches a human. + // + // ⚠️ What this case lost, and where it went: the wire English used to + // DISCRIMINATE the two readers, because only `getJson` produced it. Post-fix + // both readers answer `expired_token` identically — that is the point of the + // card — so the route claim in this case's title now rests on the argument + // above (400 -> `getJson` throws) rather than on its own assertion. The + // measurable discriminator moved to `CloudConnectionPanel.bindErrorLocale + // .test.tsx`'s ROUTE case, which uses a 400 carrying no `error` object at + // all: `getJson` renders `HTTP 400`, poll()'s terminal branch would render + // `cloudConnection.errors.bindFailed`, and nothing else tells them apart. pollReply = { status: 400, body: { @@ -140,7 +160,8 @@ describe('objectui#5028 — the bind-failure text a human reads', () => { await connectAndPollOnce(); - expect(screen.getByText('Device authorization failed: expired_token')).toBeInTheDocument(); + expect(screen.getByText('cloudConnection.errors.expired')).toBeInTheDocument(); + expect(screen.queryByText('Device authorization failed: expired_token')).not.toBeInTheDocument(); expect(screen.queryByText('DEVICE_CODE_FAILED')).not.toBeInTheDocument(); }); diff --git a/packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindErrorLocale.test.tsx b/packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindErrorLocale.test.tsx new file mode 100644 index 000000000..fe29f2034 --- /dev/null +++ b/packages/app-shell/src/console/cloud-connection/__tests__/CloudConnectionPanel.bindErrorLocale.test.tsx @@ -0,0 +1,223 @@ +/** + * 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. + */ + +/** + * objectui#5054 — ONE bind failure must read in ONE language, whichever clock + * noticed it. + * + * ## The asymmetry this file pins, measured on the pre-fix tip + * + * A device authorization that expires can be noticed by either of two clocks, + * and the panel had a different answer for each: + * + * - the SERVER's clock — `/bind/poll` answers HTTP 400 with + * `{ code: 'DEVICE_CODE_FAILED', declaredCode: 'expired_token', + * message: 'Device authorization failed: expired_token' }` + * (objectstack `packages/cloud-connection/src/cloud-connection-plugin.ts`, + * the terminal `/bind/poll` exit). `getJson` throws on any non-2xx, and the + * catch rendered `err.message` verbatim: an English sentence, on all ten + * locales. + * - the PANEL's own clock — `poll()`'s `expires_in` deadline fires first and + * renders `t('cloudConnection.errors.expired')`: translated, on all ten + * locales. + * + * Same abandoned approval, same user, two languages, decided by which clock + * noticed first. The ruling on #5054 (Option A restricted + B fallback) maps the + * two user-causable RFC 8628 spellings onto locale keys — `declaredCode` first, + * then `code` — and leaves every unrecognized code rendering the wire `message`. + * + * ## Why BOTH sides get a case here + * + * A probe that only exercises the reported (server) side cannot fail on the + * other, so it cannot testify that the asymmetry is closed — only that one half + * moved. `SERVER` and `CLIENT` below are the same condition reached through the + * two different readers, and `SYMMETRY` asserts they land on the same string. + * + * ## Predicted directions, written before running + * + * Pre-fix tip: SERVER expired RED · SERVER denied RED · SYMMETRY RED · + * CLIENT expired GREEN · CONTROL unknown-code GREEN. + * The two green ones are green on purpose: CLIENT is the side that + * was already correct (it must not regress), CONTROL is the + * B-fallback the ruling keeps. Their silence is not evidence, so + * each has its own mutation leg — see the PR body. + * + * The identity `t` below is deliberate (same idiom as the sibling + * `CloudConnectionPanel.bindError.test.tsx`): asserting on the KEY says "the + * translated string was chosen" without pinning today's English copy, and it is + * what lets SYMMETRY compare the two readers' output directly. + * `cloudConnection-locale-parity.test.ts` separately guarantees each key + * resolves to a non-empty, actually-translated value in all ten packs. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; + +vi.mock('@object-ui/i18n', async () => { + const actual = await vi.importActual>('@object-ui/i18n'); + return { + ...actual, + useObjectTranslation: () => ({ t: (key: string) => key, language: 'en' }), + }; +}); + +import { CloudConnectionPanel } from '../CloudConnectionPanel'; + +const EXPIRED_KEY = 'cloudConnection.errors.expired'; +const ACCESS_DENIED_KEY = 'cloudConnection.errors.accessDenied'; + +/** The `/bind/start` answer — `expires_in` is which clock gets to notice. */ +let startData: Record = { + device_code: 'dc_1', user_code: 'ABCD-EFGH', interval: 2, expires_in: 600, +}; +/** The `/bind/poll` answer the case under test wants. */ +let pollReply: { status: number; body: unknown } = { status: 200, body: {} }; +/** Every URL fetched, in order — how CLIENT proves the server was never asked. */ +let fetched: string[] = []; + +const reply = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** objectstack's verbatim terminal `/bind/poll` envelope for an RFC 8628 code. */ +const deviceAuthFailure = (declaredCode: string) => ({ + status: 400, + body: { + success: false, + data: { pending: false }, + error: { + code: 'DEVICE_CODE_FAILED', + declaredCode, + message: `Device authorization failed: ${declaredCode}`, + }, + }, +}); + +beforeEach(() => { + fetched = []; + startData = { device_code: 'dc_1', user_code: 'ABCD-EFGH', interval: 2, expires_in: 600 }; + pollReply = { status: 200, body: {} }; + vi.useFakeTimers(); + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + const url = String(input); + fetched.push(url); + if (url.endsWith('/status')) { + return reply(200, { success: true, data: { environmentId: null, bound: false, connection: null } }); + } + // No verification_uri: the popup path is not this file's subject, and + // leaving it out keeps window.open out of the run entirely. + if (url.endsWith('/bind/start')) return reply(200, { success: true, data: startData }); + if (url.endsWith('/bind/poll')) return reply(pollReply.status, pollReply.body); + throw new Error(`unexpected fetch: ${url}`); + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +const advance = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + +/** + * Mount, connect, and let exactly one poll tick land. `interval: 2` is the floor + * `poll()` clamps to, so the first tick fires at 2000ms — which is also what + * decides the clock: `expires_in: 600` leaves the deadline far away (the SERVER + * gets to answer), `expires_in: 1` has it already passed when the tick runs (the + * PANEL answers without asking). + */ +async function connectAndPollOnce() { + render(); + await advance(0); // the mount-time /status read settles -> unbound + const connect = screen.getByRole('button', { name: /cloudConnection\.unbound\.connect/ }); + await act(async () => { connect.click(); }); + await advance(0); // /bind/start settles -> waiting, first tick scheduled + await advance(2000); // the tick +} + +/** The text the error phase is currently rendering. */ +const shownError = () => screen.getByRole('button', { name: /cloudConnection\.retry/ }) + .parentElement!.querySelector('.text-destructive')!.textContent!.trim(); + +describe('objectui#5054 — one bind failure, one language, whichever clock noticed', () => { + it('SERVER-detected expiry reads the locale, not the wire English', async () => { + pollReply = deviceAuthFailure('expired_token'); + + await connectAndPollOnce(); + + expect(fetched).toContain('/api/v1/cloud-connection/bind/poll'); + expect(screen.getByText(EXPIRED_KEY)).toBeInTheDocument(); + expect(screen.queryByText('Device authorization failed: expired_token')).not.toBeInTheDocument(); + }); + + it('SERVER-detected denial reads the locale, not the wire English', async () => { + pollReply = deviceAuthFailure('access_denied'); + + await connectAndPollOnce(); + + expect(screen.getByText(ACCESS_DENIED_KEY)).toBeInTheDocument(); + expect(screen.queryByText('Device authorization failed: access_denied')).not.toBeInTheDocument(); + }); + + it('CLIENT-detected expiry reads the locale — the side that was already right', async () => { + // expires_in below the 2s tick floor: the deadline has passed when the tick + // runs, so `poll()` answers from its own clock and never asks the server. + startData = { ...startData, expires_in: 1 }; + + await connectAndPollOnce(); + + expect(fetched).not.toContain('/api/v1/cloud-connection/bind/poll'); + expect(screen.getByText(EXPIRED_KEY)).toBeInTheDocument(); + }); + + it('SYMMETRY: both clocks render the SAME string for the same expiry', async () => { + pollReply = deviceAuthFailure('expired_token'); + await connectAndPollOnce(); + const serverDetected = shownError(); + + // Second mount, same condition, the other reader. + screen.getByRole('button', { name: /cloudConnection\.retry/ }); // the first is still up + document.body.innerHTML = ''; + fetched = []; + startData = { ...startData, expires_in: 1 }; + await connectAndPollOnce(); + const clientDetected = shownError(); + + expect({ serverDetected, clientDetected }) + .toEqual({ serverDetected: EXPIRED_KEY, clientDetected: EXPIRED_KEY }); + }); + + it('ROUTE: a 400 is read by getJson, never by poll()\'s terminal branch', async () => { + // The discriminator the sibling suite's CONTROL case lost when this card + // made both readers agree on `expired_token`. A 400 carrying NO `error` + // object is the one fixture the two readers answer differently: + // getJson -> its last arm, the literal `HTTP 400`; + // poll() -> `t('cloudConnection.errors.bindFailed')`. + // Seeing the first proves a non-2xx never reaches poll()'s display branch, + // which is what makes `getJson` the site this card had to fix. + pollReply = { status: 400, body: { success: false, data: { pending: false } } }; + + await connectAndPollOnce(); + + expect(screen.getByText('HTTP 400')).toBeInTheDocument(); + expect(screen.queryByText('cloudConnection.errors.bindFailed')).not.toBeInTheDocument(); + }); + + it('CONTROL: an unrecognized code still renders the wire message (the B fallback)', async () => { + // `invalid_grant` is a real RFC 8628 spelling the ruling deliberately did + // NOT name — the map is closed at the two codes a user can cause. This case + // is green before and after the fix; it fails only if the map widens. + pollReply = deviceAuthFailure('invalid_grant'); + + await connectAndPollOnce(); + + expect(screen.getByText('Device authorization failed: invalid_grant')).toBeInTheDocument(); + expect(screen.queryByText(EXPIRED_KEY)).not.toBeInTheDocument(); + expect(screen.queryByText(ACCESS_DENIED_KEY)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index b872411e7..e4445561e 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2910,6 +2910,7 @@ const ar = { retry: "إعادة المحاولة", errors: { expired: "انتهت صلاحية الطلب قبل الموافقة عليه. ابدأ من جديد.", + accessDenied: "تم رفض طلب الاتصال. ابدأ من جديد.", bindFailed: "فشل الربط.", deviceCodeFailed: "فشل طلب رمز الجهاز.", }, diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index a6943b680..eab3e48f6 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2903,6 +2903,7 @@ const de = { retry: "Erneut versuchen", errors: { expired: "Die Anfrage ist abgelaufen, bevor sie genehmigt wurde. Starten Sie erneut.", + accessDenied: "Die Verbindungsanfrage wurde abgelehnt. Starten Sie erneut.", bindFailed: "Verbindung fehlgeschlagen.", deviceCodeFailed: "Anforderung des Gerätecodes fehlgeschlagen.", }, diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 8326b897f..5c2929b34 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -3218,6 +3218,7 @@ const en = { retry: 'Try again', errors: { expired: 'The request expired before it was approved. Start again.', + accessDenied: 'The connection request was denied. Start again.', bindFailed: 'Binding failed.', deviceCodeFailed: 'Device code request failed.', }, diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 56c03ebdf..23c733338 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2907,6 +2907,7 @@ const es = { retry: "Reintentar", errors: { expired: "La solicitud caducó antes de ser aprobada. Vuelva a empezar.", + accessDenied: "La solicitud de conexión fue rechazada. Vuelva a empezar.", bindFailed: "Error al vincular.", deviceCodeFailed: "Error en la solicitud del código de dispositivo.", }, diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 5ca0f0d80..cc50cf93b 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2905,6 +2905,7 @@ const fr = { retry: "Réessayer", errors: { expired: "La demande a expiré avant d'être approuvée. Recommencez.", + accessDenied: "La demande de connexion a été refusée. Recommencez.", bindFailed: "Échec de la liaison.", deviceCodeFailed: "Échec de la demande de code d'appareil.", }, diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 70d79306f..7c9713593 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2903,6 +2903,7 @@ const ja = { retry: "再試行", errors: { expired: "承認される前にリクエストの有効期限が切れました。もう一度やり直してください。", + accessDenied: "接続リクエストは拒否されました。もう一度やり直してください。", bindFailed: "バインドに失敗しました。", deviceCodeFailed: "デバイスコードのリクエストに失敗しました。", }, diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index c4af45ae0..e9b459cde 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2902,6 +2902,7 @@ const ko = { retry: "다시 시도", errors: { expired: "승인되기 전에 요청이 만료되었습니다. 다시 시작하세요.", + accessDenied: "연결 요청이 거부되었습니다. 다시 시작하세요.", bindFailed: "바인딩에 실패했습니다.", deviceCodeFailed: "장치 코드 요청에 실패했습니다.", }, diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 8229b4505..841597015 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2902,6 +2902,7 @@ const pt = { retry: "Tentar novamente", errors: { expired: "A solicitação expirou antes de ser aprovada. Comece novamente.", + accessDenied: "A solicitação de conexão foi recusada. Comece novamente.", bindFailed: "Falha ao vincular.", deviceCodeFailed: "Falha na solicitação do código do dispositivo.", }, diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 2a151183b..caebefc22 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2914,6 +2914,7 @@ const ru = { retry: "Повторить", errors: { expired: "Срок действия запроса истёк до подтверждения. Начните заново.", + accessDenied: "Запрос на подключение отклонён. Начните заново.", bindFailed: "Не удалось выполнить привязку.", deviceCodeFailed: "Не удалось запросить код устройства.", }, diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 9eb74f8ff..c852bd644 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -3030,6 +3030,7 @@ const zh = { retry: '重试', errors: { expired: '请求在获批前已过期,请重新开始。', + accessDenied: '连接请求已被拒绝,请重新开始。', bindFailed: '绑定失败。', deviceCodeFailed: '设备码请求失败。', },