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
33 changes: 33 additions & 0 deletions .changeset/cloud-connection-panel-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"@object-ui/i18n": patch
"@object-ui/app-shell": patch
---

fix(cloud-connection): localize the Cloud Connection panel (objectstack#3589 follow-up)

`CloudConnectionPanel` — the `cloud-connection:panel` SDUI widget that is the
entire body of the Cloud Connection Setup page — had no i18n at all: no
`@object-ui/i18n` import, and no `cloudConnection` namespace in any of the ten
built-in locale packs. Its siblings on neighbouring pages
(`marketplace:installed-list`, `mcp:connect-agent`) were already fully
localized, so this one page rendered a translated header above an English body
once the framework-side `page:header` resolution landed.

- New `cloudConnection` namespace in all ten packs (en, zh, ja, ko, de, fr, es,
pt, ru, ar), matching the coverage its sibling namespaces already had. Covers
every phase of the device-code flow: checking, error + retry, waiting
(approval prompt, user code, copy), bound (connection detail labels), and
unbound (call to action).
- The three hard-coded failure messages (expired request, bind failure, device
code request failure) are translated where they are raised, not where they
are rendered, since they are stored in component state.
- The "code is pre-filled…" line was one sentence stitched together across JSX
with a conditional tail and a bare `'.'`. It is now two self-contained
strings, so a translator never receives a dangling clause whose word order
they cannot change.
- The `bound_at` timestamp now formats with the active UI language rather than
the browser default, matching the surrounding copy.

Also adds a locale-parity test asserting the `cloudConnection` key set is
identical across all ten packs — partial coverage degrades quietly, because
i18next falls back to `en` and the result merely looks half-translated.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
CheckCircle2,
Unplug,
} from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';
import { ComponentRegistry } from '@object-ui/core';

const BASE = '/api/v1/cloud-connection';
Expand Down Expand Up @@ -79,6 +80,7 @@ async function getJson(url: string, init?: RequestInit): Promise<any> {
}

export function CloudConnectionPanel() {
const { t, language } = useObjectTranslation();
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
Expand Down Expand Up @@ -107,7 +109,7 @@ export function CloudConnectionPanel() {
const intervalMs = Math.max(code.interval, 2) * 1000;
const tick = async () => {
if (Date.now() - startedAt > code.expires_in * 1000) {
setPhase({ kind: 'error', message: 'The request expired before it was approved. Start again.' });
setPhase({ kind: 'error', message: t('cloudConnection.errors.expired') });
return;
}
try {
Expand All @@ -123,20 +125,20 @@ export function CloudConnectionPanel() {
await refreshStatus();
return;
}
setPhase({ kind: 'error', message: body?.error?.code ?? 'Binding failed.' });
setPhase({ kind: 'error', message: body?.error?.code ?? t('cloudConnection.errors.bindFailed') });
} catch (err: any) {
setPhase({ kind: 'error', message: err?.message ?? String(err) });
}
};
pollTimer.current = setTimeout(tick, intervalMs);
}, [refreshStatus]);
}, [refreshStatus, t]);

const connect = useCallback(async () => {
setBusy(true);
try {
const body = await getJson(`${BASE}/bind/start`, { method: 'POST', body: '{}' });
const code: DeviceCode = body?.data;
if (!code?.device_code || !code?.user_code) throw new Error('Device code request failed.');
if (!code?.device_code || !code?.user_code) throw new Error(t('cloudConnection.errors.deviceCodeFailed'));
// Auto-open the approval page — the GitHub-login moment. Still within
// the click's transient activation, so popup blockers generally allow
// it; the code display below is the blocked-popup fallback.
Expand All @@ -154,7 +156,7 @@ export function CloudConnectionPanel() {
} finally {
setBusy(false);
}
}, [poll]);
}, [poll, t]);

const disconnect = useCallback(async () => {
setBusy(true);
Expand All @@ -179,7 +181,7 @@ export function CloudConnectionPanel() {
if (phase.kind === 'loading') {
return (
<div className="flex items-center gap-2 rounded-lg border p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> Checking connection…
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> {t('cloudConnection.checking')}
</div>
);
}
Expand All @@ -195,7 +197,7 @@ export function CloudConnectionPanel() {
className="self-start rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => { stopPolling(); void refreshStatus(); }}
>
Try again
{t('cloudConnection.retry')}
</button>
</div>
);
Expand All @@ -208,8 +210,8 @@ export function CloudConnectionPanel() {
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{phase.popupOpened
? 'Approve the connection in the window that just opened — this page updates by itself.'
: 'Waiting for approval in the cloud console…'}
? t('cloudConnection.waiting.popupOpened')
: t('cloudConnection.waiting.polling')}
</div>
{!phase.popupOpened && link ? (
<a
Expand All @@ -218,7 +220,7 @@ export function CloudConnectionPanel() {
target="_blank"
rel="noreferrer"
>
Open the approval page <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
{t('cloudConnection.waiting.openApproval')} <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
) : null}
<div className="flex items-center gap-3">
Expand All @@ -230,27 +232,29 @@ export function CloudConnectionPanel() {
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => void copyCode(phase.code.user_code)}
>
<Copy className="h-3.5 w-3.5" aria-hidden="true" /> {copied ? 'Copied' : 'Copy'}
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
{copied ? t('cloudConnection.waiting.copied') : t('cloudConnection.waiting.copy')}
</button>
</div>
{/* Two self-contained strings rather than one sentence stitched across
JSX — a translator never receives a dangling clause or a bare '.'. */}
<p className="text-sm text-muted-foreground">
The code is pre-filled on the approval page
{t('cloudConnection.waiting.codePrefilled')}
{phase.popupOpened && link ? (
<>
{' '}— if the window did not appear,{' '}
{' '}
<a className="inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline" href={link} target="_blank" rel="noreferrer">
open it here <ExternalLink className="h-3 w-3" aria-hidden="true" />
{t('cloudConnection.waiting.openItHere')} <ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
.
</>
) : '.'}
) : null}
</p>
<button
type="button"
className="self-start rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
onClick={() => { stopPolling(); void refreshStatus(); }}
>
Cancel
{t('cloudConnection.waiting.cancel')}
</button>
</div>
);
Expand All @@ -263,26 +267,26 @@ export function CloudConnectionPanel() {
<div className="flex flex-col gap-4 rounded-lg border p-6">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-600" aria-hidden="true" />
<span className="font-medium">Connected to ObjectStack Cloud</span>
<span className="font-medium">{t('cloudConnection.bound.title')}</span>
</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-sm">
{conn.name ? (<><dt className="text-muted-foreground">Runtime</dt><dd>{conn.name}</dd></>) : null}
{conn.organization_id ? (<><dt className="text-muted-foreground">Organization</dt><dd className="font-mono">{conn.organization_id}</dd></>) : null}
{conn.account_email ? (<><dt className="text-muted-foreground">Approved by</dt><dd>{conn.account_email}</dd></>) : null}
{runtimeId ? (<><dt className="text-muted-foreground">Runtime ID</dt><dd className="font-mono text-xs">{runtimeId}</dd></>) : null}
{phase.status.environmentId ? (<><dt className="text-muted-foreground">Environment</dt><dd className="font-mono">{phase.status.environmentId}</dd></>) : null}
{conn.bound_at ? (<><dt className="text-muted-foreground">Since</dt><dd>{new Date(conn.bound_at).toLocaleString()}</dd></>) : null}
{conn.name ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.runtime')}</dt><dd>{conn.name}</dd></>) : null}
{conn.organization_id ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.organization')}</dt><dd className="font-mono">{conn.organization_id}</dd></>) : null}
{conn.account_email ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.approvedBy')}</dt><dd>{conn.account_email}</dd></>) : null}
{runtimeId ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.runtimeId')}</dt><dd className="font-mono text-xs">{runtimeId}</dd></>) : null}
{phase.status.environmentId ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.environment')}</dt><dd className="font-mono">{phase.status.environmentId}</dd></>) : null}
{conn.bound_at ? (<><dt className="text-muted-foreground">{t('cloudConnection.bound.since')}</dt><dd>{new Date(conn.bound_at).toLocaleString(language)}</dd></>) : null}
</dl>
<p className="text-sm text-muted-foreground">
Your organization's private packages now appear in the Marketplace under “Your organization”.
{t('cloudConnection.bound.privatePackages')}
</p>
<button
type="button"
disabled={busy}
className="inline-flex items-center gap-1.5 self-start rounded-md border border-destructive/40 px-3 py-1.5 text-sm text-destructive hover:bg-destructive/5 disabled:opacity-50"
onClick={() => void disconnect()}
>
<Unplug className="h-3.5 w-3.5" aria-hidden="true" /> Disconnect
<Unplug className="h-3.5 w-3.5" aria-hidden="true" /> {t('cloudConnection.bound.disconnect')}
</button>
</div>
);
Expand All @@ -293,13 +297,10 @@ export function CloudConnectionPanel() {
<div className="flex flex-col gap-4 rounded-lg border p-6">
<div className="flex items-center gap-2">
<CloudOff className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
<span className="font-medium">Not connected</span>
<span className="font-medium">{t('cloudConnection.unbound.title')}</span>
</div>
<p className="text-sm text-muted-foreground">
Connect this runtime to an ObjectStack control plane to browse your
organization's private packages and install them here. Approval is a
single click in your cloud account — no ids or credentials are typed
into this page.
{t('cloudConnection.unbound.body')}
</p>
<button
type="button"
Expand All @@ -308,7 +309,7 @@ export function CloudConnectionPanel() {
onClick={() => void connect()}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Cloud className="h-4 w-4" aria-hidden="true" />}
Connect
{t('cloudConnection.unbound.connect')}
</button>
</div>
);
Expand Down
82 changes: 82 additions & 0 deletions packages/i18n/src/__tests__/cloudConnection-locale-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* cloudConnection locale parity (objectstack#3589 follow-up).
*
* The Cloud Connection panel shipped with every string hard-coded in English
* while its sibling widgets (marketplace, connectAgent) were fully localized —
* so the page rendered a translated header above an English body. The related
* framework-side gap had the same shape: `nav_cloud_connection` existed only
* in zh-CN, leaving ja-JP/es-ES silently on the English fallback.
*
* Partial coverage is the failure mode worth pinning, because it degrades
* quietly: i18next falls back to `en` and the UI just looks half-translated.
* This asserts the `cloudConnection` key set is identical across all ten
* built-in packs, so adding a key to `en` alone fails here rather than in a
* user's browser.
*/
import { describe, it, expect } from 'vitest';
import { builtInLocales } from '../locales';

/** Flatten a nested translation node into sorted dot-paths. */
function keyPaths(node: unknown, prefix = ''): string[] {
if (node === null || typeof node !== 'object') return [prefix];
return Object.entries(node as Record<string, unknown>)
.flatMap(([k, v]) => keyPaths(v, prefix ? `${prefix}.${k}` : k))
.sort();
}

/** Read a dot-path out of a nested translation node. */
function readPath(node: unknown, path: string): unknown {
return path.split('.').reduce<unknown>(
(acc, seg) => (acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[seg] : undefined),
node,
);
}

type LocaleCode = keyof typeof builtInLocales;

const LOCALES = Object.keys(builtInLocales) as LocaleCode[];
const cloudConnectionOf = (code: LocaleCode): unknown =>
(builtInLocales[code] as Record<string, unknown>).cloudConnection;

describe('cloudConnection translations', () => {
it('is present in every built-in locale pack', () => {
const missing = LOCALES.filter((code) => !cloudConnectionOf(code));
expect(missing).toEqual([]);
});

it('exposes an identical key set in every locale', () => {
const expected = keyPaths(cloudConnectionOf('en'));
// Guard against the flattener silently returning nothing.
expect(expected.length).toBeGreaterThan(15);

for (const code of LOCALES) {
expect({ code, keys: keyPaths(cloudConnectionOf(code)) }).toEqual({ code, keys: expected });
}
});

it('has a non-empty string at every leaf', () => {
for (const code of LOCALES) {
const node = cloudConnectionOf(code);
for (const path of keyPaths(node)) {
const value = readPath(node, path);
expect({ code, path, ok: typeof value === 'string' && value.trim().length > 0 })
.toEqual({ code, path, ok: true });
}
}
});

it('actually translates the user-facing prose, not just the key structure', () => {
// Short labels legitimately collide across languages ("Runtime" is
// "Runtime" in de/fr/es), so only the prose keys are checked — those
// matching English verbatim means the pack was never translated.
const prose = ['unbound.body', 'bound.privatePackages', 'waiting.popupOpened'] as const;
const read = (code: LocaleCode, path: string) => readPath(cloudConnectionOf(code), path);

for (const code of LOCALES.filter((c) => c !== 'en')) {
for (const path of prose) {
expect({ code, path, sameAsEnglish: read(code, path) === read('en', path) })
.toEqual({ code, path, sameAsEnglish: false });
}
}
});
});
35 changes: 35 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,41 @@ const ar = {
done: 'تم — إخفاء',
},
},
cloudConnection: {
checking: "جارٍ التحقق من الاتصال…",
retry: "إعادة المحاولة",
errors: {
expired: "انتهت صلاحية الطلب قبل الموافقة عليه. ابدأ من جديد.",
bindFailed: "فشل الربط.",
deviceCodeFailed: "فشل طلب رمز الجهاز.",
},
waiting: {
popupOpened: "وافق على الاتصال في النافذة التي فُتحت للتو — سيتم تحديث هذه الصفحة تلقائيًا.",
polling: "في انتظار الموافقة في وحدة التحكم السحابية…",
openApproval: "فتح صفحة الموافقة",
copy: "نسخ",
copied: "تم النسخ",
codePrefilled: "الرمز مُعبَّأ مسبقًا في صفحة الموافقة.",
openItHere: "لم تظهر النافذة؟ افتحها من هنا",
cancel: "إلغاء",
},
bound: {
title: "متصل بـ ObjectStack Cloud",
runtime: "بيئة التشغيل",
organization: "المؤسسة",
approvedBy: "تمت الموافقة بواسطة",
runtimeId: "معرّف بيئة التشغيل",
environment: "البيئة",
since: "متصل منذ",
privatePackages: "تظهر الآن الحزم الخاصة بمؤسستك في السوق ضمن «مؤسستك».",
disconnect: "قطع الاتصال",
},
unbound: {
title: "غير متصل",
body: "اربط بيئة التشغيل هذه بمستوى تحكم ObjectStack لتصفّح الحزم الخاصة بمؤسستك وتثبيتها هنا. الموافقة بنقرة واحدة في حسابك السحابي — دون إدخال أي معرّفات أو بيانات اعتماد في هذه الصفحة.",
connect: "اتصال",
},
},
marketplace: {
title: "سوق التطبيقات",
subtitle: "استعرض التطبيقات المعتمدة المنشورة في كتالوج ObjectStack. انقر على تطبيق لرؤية التفاصيل وتثبيته في أحد بيئاتك.",
Expand Down
35 changes: 35 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1844,6 +1844,41 @@ const de = {
done: 'Fertig — ausblenden',
},
},
cloudConnection: {
checking: "Verbindung wird geprüft…",
retry: "Erneut versuchen",
errors: {
expired: "Die Anfrage ist abgelaufen, bevor sie genehmigt wurde. Starten Sie erneut.",
bindFailed: "Verbindung fehlgeschlagen.",
deviceCodeFailed: "Anforderung des Gerätecodes fehlgeschlagen.",
},
waiting: {
popupOpened: "Genehmigen Sie die Verbindung im soeben geöffneten Fenster — diese Seite aktualisiert sich von selbst.",
polling: "Warten auf die Genehmigung in der Cloud-Konsole…",
openApproval: "Genehmigungsseite öffnen",
copy: "Kopieren",
copied: "Kopiert",
codePrefilled: "Der Code ist auf der Genehmigungsseite bereits eingetragen.",
openItHere: "Fenster nicht erschienen? Hier öffnen",
cancel: "Abbrechen",
},
bound: {
title: "Mit ObjectStack Cloud verbunden",
runtime: "Runtime",
organization: "Organisation",
approvedBy: "Genehmigt von",
runtimeId: "Runtime-ID",
environment: "Umgebung",
since: "Verbunden seit",
privatePackages: "Die privaten Pakete Ihrer Organisation erscheinen jetzt im Marktplatz unter „Ihre Organisation“.",
disconnect: "Verbindung trennen",
},
unbound: {
title: "Nicht verbunden",
body: "Verbinden Sie diese Runtime mit einer ObjectStack Control Plane, um die privaten Pakete Ihrer Organisation zu durchsuchen und hier zu installieren. Die Genehmigung erfolgt mit einem einzigen Klick in Ihrem Cloud-Konto — es werden keine IDs oder Zugangsdaten auf dieser Seite eingegeben.",
connect: "Verbinden",
},
},
marketplace: {
title: "App-Marktplatz",
subtitle: "Durchsuchen Sie genehmigte Apps, die im ObjectStack-Katalog veröffentlicht wurden. Klicken Sie auf eine App, um Details zu sehen und sie in eine Ihrer Umgebungen zu installieren.",
Expand Down
Loading
Loading