Skip to content

Commit 77cc6bb

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(cloud-connection): one-click bind UX — zero-input panel + device-context approval page (#1673)
Cloud ADR runtime-identity-binding §2.3 (pairs with framework#1783/#1784, cloud#264): - CloudConnectionPanel: environment-id input removed (registration is created cloud-side at approval); Connect auto-opens the approval page in a popup — the user-code display becomes the popup-blocked fallback; bound view shows runtime name + runtime id - DeviceAuthPage: shows the requesting device's context (runtime_name / runtime_version from the verification URL) + an 'only approve if you started this' warning — informed consent for the RFC 8628 flow - two new auth.device.* keys across all 10 locales Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 30bded2 commit 77cc6bb

13 files changed

Lines changed: 107 additions & 42 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
"@object-ui/console": patch
4+
"@object-ui/i18n": patch
5+
---
6+
7+
Cloud Connection bind v2 UX (cloud ADR runtime-identity-binding §2.3): the binding flow becomes one click. `CloudConnectionPanel` drops the environment-id input entirely (registration happens cloud-side at approval), auto-opens the approval page in a popup on Connect (user-code display stays as the popup-blocked fallback), and shows the registered runtime name + runtime id once bound. `DeviceAuthPage` displays the requesting device's context (`runtime_name` / `runtime_version` from the verification URL) plus an "only approve if you started this" warning — the informed-consent surface for the RFC 8628 flow. Two new `auth.device.*` keys across all locales.

apps/console/src/pages/auth/DeviceAuthPage.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ export function DeviceAuthPage() {
4444
const { t } = useObjectTranslation();
4545
const [params] = useSearchParams();
4646
const code = params.get('user_code') ?? params.get('code') ?? '';
47+
// Device context appended by the requesting runtime's bind/start (ADR
48+
// runtime-identity-binding §2.3). Display-only informed consent — the
49+
// warning copy below is what carries the anti-phishing weight.
50+
const runtimeName = params.get('runtime_name') ?? '';
51+
const runtimeVersion = params.get('runtime_version') ?? '';
4752
const { user, isLoading } = useAuth();
4853
const navigate = useNavigate();
4954

@@ -211,13 +216,33 @@ export function DeviceAuthPage() {
211216
</CardDescription>
212217
</CardHeader>
213218
<CardContent className="space-y-4">
219+
{runtimeName ? (
220+
<div className="rounded-md border bg-background px-4 py-3 text-center">
221+
<p className="mb-1 text-xs text-muted-foreground">
222+
{t('auth.device.requesterLabel', { defaultValue: 'Connection request from' })}
223+
</p>
224+
<p className="font-medium">{runtimeName}</p>
225+
{runtimeVersion ? (
226+
<p className="text-xs text-muted-foreground">objectos {runtimeVersion}</p>
227+
) : null}
228+
</div>
229+
) : null}
230+
214231
<div className="rounded-md border bg-background px-4 py-3 text-center">
215232
<p className="mb-1 text-xs text-muted-foreground">
216233
{t('auth.device.userCodeLabel', { defaultValue: 'Device code' })}
217234
</p>
218235
<p className="font-mono text-lg font-semibold tracking-widest">{code}</p>
219236
</div>
220237

238+
<p className="text-center text-xs text-muted-foreground">
239+
{t('auth.device.approveWarning', {
240+
defaultValue:
241+
'Only approve if you started this connection yourself a moment ago. '
242+
+ "Once approved, this runtime can access your organization's private packages.",
243+
})}
244+
</p>
245+
221246
<div className="space-y-4">
222247
<p className="text-center text-sm text-muted-foreground">
223248
{t('auth.device.loggedInAs', {

packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx

Lines changed: 55 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@
88
* the page shell, nav placement and labels ship as metadata WITH the
99
* `@objectstack/cloud-connection` plugin (cloud ADR-0008 / console
1010
* SDUI-first direction). The widget talks to the runtime's same-origin
11-
* `/api/v1/cloud-connection/*` routes:
11+
* `/api/v1/cloud-connection/*` routes.
1212
*
13-
* status → [Connect] → bind/start → user-code display → bind/poll …
14-
* → bound (org / account / since) → [Disconnect] → unbind
13+
* Zero-input flow (ADR runtime-identity-binding §2.3): [Connect] →
14+
* bind/start (no environment id — the registration is created cloud-side
15+
* at approval) → the approval page auto-opens in a popup with the code
16+
* pre-filled and the device named → bind/poll … → bound. The visible
17+
* user code is the popup-blocked fallback, not the primary path.
1518
*
1619
* The runtime credential never reaches the browser — bind/poll persists
1720
* it server-side and strips it from the response.
@@ -36,9 +39,12 @@ interface ConnectionView {
3639
organization_id?: string | null;
3740
account_email?: string | null;
3841
bound_at?: string | null;
42+
name?: string | null;
43+
runtime_id?: string | null;
3944
}
4045
interface StatusData {
4146
environmentId: string | null;
47+
runtimeId?: string | null;
4248
bound: boolean;
4349
connection: ConnectionView | null;
4450
}
@@ -53,8 +59,8 @@ interface DeviceCode {
5359

5460
type Phase =
5561
| { kind: 'loading' }
56-
| { kind: 'unbound'; environmentId: string | null }
57-
| { kind: 'waiting'; code: DeviceCode; environmentId: string }
62+
| { kind: 'unbound' }
63+
| { kind: 'waiting'; code: DeviceCode; popupOpened: boolean }
5864
| { kind: 'bound'; status: StatusData }
5965
| { kind: 'error'; message: string };
6066

@@ -74,7 +80,6 @@ async function getJson(url: string, init?: RequestInit): Promise<any> {
7480

7581
export function CloudConnectionPanel() {
7682
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
77-
const [envIdInput, setEnvIdInput] = useState('');
7883
const [busy, setBusy] = useState(false);
7984
const [copied, setCopied] = useState(false);
8085
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -87,8 +92,7 @@ export function CloudConnectionPanel() {
8792
try {
8893
const body = await getJson(`${BASE}/status`);
8994
const data: StatusData = body?.data ?? { environmentId: null, bound: false, connection: null };
90-
setPhase(data.bound ? { kind: 'bound', status: data } : { kind: 'unbound', environmentId: data.environmentId });
91-
if (data.environmentId) setEnvIdInput(data.environmentId);
95+
setPhase(data.bound ? { kind: 'bound', status: data } : { kind: 'unbound' });
9296
} catch (err: any) {
9397
setPhase({ kind: 'error', message: err?.message ?? String(err) });
9498
}
@@ -99,17 +103,17 @@ export function CloudConnectionPanel() {
99103
return stopPolling;
100104
}, [refreshStatus, stopPolling]);
101105

102-
const poll = useCallback((code: DeviceCode, environmentId: string, startedAt: number) => {
106+
const poll = useCallback((code: DeviceCode, startedAt: number) => {
103107
const intervalMs = Math.max(code.interval, 2) * 1000;
104108
const tick = async () => {
105109
if (Date.now() - startedAt > code.expires_in * 1000) {
106-
setPhase({ kind: 'error', message: 'The code expired before it was approved. Start again.' });
110+
setPhase({ kind: 'error', message: 'The request expired before it was approved. Start again.' });
107111
return;
108112
}
109113
try {
110114
const body = await getJson(`${BASE}/bind/poll`, {
111115
method: 'POST',
112-
body: JSON.stringify({ device_code: code.device_code, environment_id: environmentId }),
116+
body: JSON.stringify({ device_code: code.device_code }),
113117
});
114118
if (body?.data?.pending) {
115119
pollTimer.current = setTimeout(tick, intervalMs);
@@ -127,17 +131,24 @@ export function CloudConnectionPanel() {
127131
pollTimer.current = setTimeout(tick, intervalMs);
128132
}, [refreshStatus]);
129133

130-
const connect = useCallback(async (environmentId: string) => {
134+
const connect = useCallback(async () => {
131135
setBusy(true);
132136
try {
133-
const body = await getJson(`${BASE}/bind/start`, {
134-
method: 'POST',
135-
body: JSON.stringify(environmentId ? { environment_id: environmentId } : {}),
136-
});
137+
const body = await getJson(`${BASE}/bind/start`, { method: 'POST', body: '{}' });
137138
const code: DeviceCode = body?.data;
138139
if (!code?.device_code || !code?.user_code) throw new Error('Device code request failed.');
139-
setPhase({ kind: 'waiting', code, environmentId });
140-
poll(code, environmentId, Date.now());
140+
// Auto-open the approval page — the GitHub-login moment. Still within
141+
// the click's transient activation, so popup blockers generally allow
142+
// it; the code display below is the blocked-popup fallback.
143+
const link = code.verification_uri_complete ?? code.verification_uri;
144+
let popupOpened = false;
145+
if (link) {
146+
try {
147+
popupOpened = Boolean(window.open(link, '_blank', 'noopener,width=520,height=720'));
148+
} catch { /* blocked — fallback UI below */ }
149+
}
150+
setPhase({ kind: 'waiting', code, popupOpened });
151+
poll(code, Date.now());
141152
} catch (err: any) {
142153
setPhase({ kind: 'error', message: err?.message ?? String(err) });
143154
} finally {
@@ -196,8 +207,20 @@ export function CloudConnectionPanel() {
196207
<div className="flex flex-col gap-4 rounded-lg border p-6">
197208
<div className="flex items-center gap-2 text-sm text-muted-foreground">
198209
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
199-
Waiting for approval in the cloud console…
210+
{phase.popupOpened
211+
? 'Approve the connection in the window that just opened — this page updates by itself.'
212+
: 'Waiting for approval in the cloud console…'}
200213
</div>
214+
{!phase.popupOpened && link ? (
215+
<a
216+
className="inline-flex items-center gap-1.5 self-start rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
217+
href={link}
218+
target="_blank"
219+
rel="noreferrer"
220+
>
221+
Open the approval page <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
222+
</a>
223+
) : null}
201224
<div className="flex items-center gap-3">
202225
<code className="rounded-md bg-muted px-4 py-2 text-2xl font-semibold tracking-[0.25em]">
203226
{phase.code.user_code}
@@ -211,12 +234,12 @@ export function CloudConnectionPanel() {
211234
</button>
212235
</div>
213236
<p className="text-sm text-muted-foreground">
214-
Enter this code in the cloud console to approve the connection
215-
{link ? (
237+
The code is pre-filled on the approval page
238+
{phase.popupOpened && link ? (
216239
<>
217-
{' '}or{' '}
240+
{' '}if the window did not appear,{' '}
218241
<a className="inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline" href={link} target="_blank" rel="noreferrer">
219-
open the approval page <ExternalLink className="h-3 w-3" aria-hidden="true" />
242+
open it here <ExternalLink className="h-3 w-3" aria-hidden="true" />
220243
</a>
221244
.
222245
</>
@@ -235,15 +258,18 @@ export function CloudConnectionPanel() {
235258

236259
if (phase.kind === 'bound') {
237260
const conn = phase.status.connection ?? {};
261+
const runtimeId = conn.runtime_id ?? phase.status.runtimeId;
238262
return (
239263
<div className="flex flex-col gap-4 rounded-lg border p-6">
240264
<div className="flex items-center gap-2">
241265
<CheckCircle2 className="h-5 w-5 text-emerald-600" aria-hidden="true" />
242266
<span className="font-medium">Connected to ObjectStack Cloud</span>
243267
</div>
244268
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-sm">
269+
{conn.name ? (<><dt className="text-muted-foreground">Runtime</dt><dd>{conn.name}</dd></>) : null}
245270
{conn.organization_id ? (<><dt className="text-muted-foreground">Organization</dt><dd className="font-mono">{conn.organization_id}</dd></>) : null}
246271
{conn.account_email ? (<><dt className="text-muted-foreground">Approved by</dt><dd>{conn.account_email}</dd></>) : null}
272+
{runtimeId ? (<><dt className="text-muted-foreground">Runtime ID</dt><dd className="font-mono text-xs">{runtimeId}</dd></>) : null}
247273
{phase.status.environmentId ? (<><dt className="text-muted-foreground">Environment</dt><dd className="font-mono">{phase.status.environmentId}</dd></>) : null}
248274
{conn.bound_at ? (<><dt className="text-muted-foreground">Since</dt><dd>{new Date(conn.bound_at).toLocaleString()}</dd></>) : null}
249275
</dl>
@@ -262,8 +288,7 @@ export function CloudConnectionPanel() {
262288
);
263289
}
264290

265-
// unbound
266-
const needsEnvId = !phase.environmentId;
291+
// unbound — zero input: no environment id, nothing to paste anywhere.
267292
return (
268293
<div className="flex flex-col gap-4 rounded-lg border p-6">
269294
<div className="flex items-center gap-2">
@@ -272,27 +297,15 @@ export function CloudConnectionPanel() {
272297
</div>
273298
<p className="text-sm text-muted-foreground">
274299
Connect this runtime to an ObjectStack control plane to browse your
275-
organization's private packages and install them here. Approval
276-
happens in the cloud console — no credentials are typed into this page.
300+
organization's private packages and install them here. Approval is a
301+
single click in your cloud account — no ids or credentials are typed
302+
into this page.
277303
</p>
278-
{needsEnvId ? (
279-
<label className="flex flex-col gap-1.5 text-sm">
280-
<span className="text-muted-foreground">
281-
Environment ID (create an environment in the cloud console and paste its id)
282-
</span>
283-
<input
284-
className="w-full max-w-md rounded-md border bg-background px-3 py-2 font-mono text-sm"
285-
placeholder="e.g. 1764f947-6d1a-4b3e-82e4-9ca733f50a56"
286-
value={envIdInput}
287-
onChange={(e) => setEnvIdInput(e.target.value)}
288-
/>
289-
</label>
290-
) : null}
291304
<button
292305
type="button"
293-
disabled={busy || (needsEnvId && !envIdInput.trim())}
306+
disabled={busy}
294307
className="inline-flex items-center gap-1.5 self-start rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
295-
onClick={() => void connect((phase.environmentId ?? envIdInput).trim())}
308+
onClick={() => void connect()}
296309
>
297310
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Cloud className="h-4 w-4" aria-hidden="true" />}
298311
Connect

packages/i18n/src/locales/ar.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,8 @@ const ar = {
11481148
title: "تفويض جهاز جديد",
11491149
subtitle: "وافق على هذا الجهاز لتسجيل الدخول بوصفك {{email}}.",
11501150
userCodeLabel: "رمز الجهاز",
1151+
requesterLabel: "طلب اتصال من",
1152+
approveWarning: "وافق فقط إذا كنت قد بدأت هذا الاتصال بنفسك قبل لحظات. بعد الموافقة، سيتمكن وقت التشغيل هذا من الوصول إلى الحزم الخاصة بمؤسستك.",
11511153
loggedInAs: "مسجل الدخول بوصفك {{email}}",
11521154
approve: "الموافقة على الجهاز",
11531155
approving: "جارٍ الموافقة…",

packages/i18n/src/locales/de.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,8 @@ const de = {
11481148
title: "Neues Gerät autorisieren",
11491149
subtitle: "Genehmigen Sie dieses Gerät für die Anmeldung als {{email}}.",
11501150
userCodeLabel: "Gerätecode",
1151+
requesterLabel: "Verbindungsanfrage von",
1152+
approveWarning: "Genehmigen Sie nur, wenn Sie diese Verbindung soeben selbst gestartet haben. Nach der Genehmigung kann diese Laufzeitumgebung auf die privaten Pakete Ihrer Organisation zugreifen.",
11511153
loggedInAs: "Angemeldet als {{email}}",
11521154
approve: "Gerät genehmigen",
11531155
approving: "Genehmige…",

packages/i18n/src/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,8 @@ const en = {
13861386
title: 'Authorize new device',
13871387
subtitle: 'Approve this device to sign in as {{email}}.',
13881388
userCodeLabel: 'Device code',
1389+
requesterLabel: 'Connection request from',
1390+
approveWarning: 'Only approve if you started this connection yourself a moment ago. Once approved, this runtime can access your organization\'s private packages.',
13891391
loggedInAs: 'Signed in as {{email}}',
13901392
approve: 'Approve device',
13911393
approving: 'Approving…',

packages/i18n/src/locales/es.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,8 @@ const es = {
11481148
title: "Autorizar nuevo dispositivo",
11491149
subtitle: "Apruebe este dispositivo para iniciar sesión como {{email}}.",
11501150
userCodeLabel: "Código del dispositivo",
1151+
requesterLabel: "Solicitud de conexión de",
1152+
approveWarning: "Aprueba solo si tú mismo iniciaste esta conexión hace un momento. Una vez aprobada, este runtime podrá acceder a los paquetes privados de tu organización.",
11511153
loggedInAs: "Conectado como {{email}}",
11521154
approve: "Aprobar dispositivo",
11531155
approving: "Aprobando…",

packages/i18n/src/locales/fr.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,8 @@ const fr = {
11481148
title: "Autoriser un nouvel appareil",
11491149
subtitle: "Approuvez cet appareil pour vous connecter en tant que {{email}}.",
11501150
userCodeLabel: "Code de l'appareil",
1151+
requesterLabel: "Demande de connexion de",
1152+
approveWarning: "N'approuvez que si vous venez de lancer cette connexion vous-même. Une fois approuvée, ce runtime pourra accéder aux packages privés de votre organisation.",
11511153
loggedInAs: "Connecté en tant que {{email}}",
11521154
approve: "Approuver l'appareil",
11531155
approving: "Approbation…",

packages/i18n/src/locales/ja.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,8 @@ const ja = {
11481148
title: "新しいデバイスを認証",
11491149
subtitle: "{{email}} としてサインインするためにこのデバイスを承認します。",
11501150
userCodeLabel: "デバイスコード",
1151+
requesterLabel: "接続リクエスト元",
1152+
approveWarning: "この接続を直前にご自身で開始した場合にのみ承認してください。承認すると、このランタイムは組織のプライベートパッケージにアクセスできます。",
11511153
loggedInAs: "{{email}} としてサインイン中",
11521154
approve: "デバイスを承認",
11531155
approving: "承認中…",

packages/i18n/src/locales/ko.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,8 @@ const ko = {
11481148
title: "새 장치 승인",
11491149
subtitle: "{{email}}으로 로그인하기 위해 이 장치를 승인하세요.",
11501150
userCodeLabel: "장치 코드",
1151+
requesterLabel: "연결 요청 출처",
1152+
approveWarning: "방금 직접 시작한 연결인 경우에만 승인하세요. 승인하면 이 런타임이 조직의 비공개 패키지에 액세스할 수 있습니다.",
11511153
loggedInAs: "{{email}}으로 로그인됨",
11521154
approve: "장치 승인",
11531155
approving: "승인 중…",

0 commit comments

Comments
 (0)