Skip to content

Commit 0ca2040

Browse files
os-zhuangclaude
andauthored
feat(app-shell): cloud-connection:panel SDUI widget (device-code binding state machine) (#1662)
The Cloud Connection Setup page ships as METADATA with the @objectstack/cloud-connection plugin (cloud ADR-0009 SDUI-first); the console contributes only this registered widget — the genuinely interactive RFC 8628 flow: status → connect (env-id input when未绑) → user-code display + approval link → poll → bound view / disconnect. The runtime credential never reaches the browser (bind/poll persists it server-side and strips it from the response). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 806ee9b commit 0ca2040

3 files changed

Lines changed: 319 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
---
4+
5+
`cloud-connection:panel` SDUI widget — the RFC 8628 device-code binding state machine for the metadata-driven Cloud Connection Setup page (shipped by `@objectstack/cloud-connection`). status → connect → user-code display + approval link → poll → bound/disconnect; the runtime credential never reaches the browser.
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* CloudConnectionPanel — the RFC 8628 device-code binding state machine,
5+
* registered as the SDUI widget `cloud-connection:panel`.
6+
*
7+
* This is deliberately the ONLY React in the Cloud Connection surface:
8+
* the page shell, nav placement and labels ship as metadata WITH the
9+
* `@objectstack/cloud-connection` plugin (cloud ADR-0008 / console
10+
* SDUI-first direction). The widget talks to the runtime's same-origin
11+
* `/api/v1/cloud-connection/*` routes:
12+
*
13+
* status → [Connect] → bind/start → user-code display → bind/poll …
14+
* → bound (org / account / since) → [Disconnect] → unbind
15+
*
16+
* The runtime credential never reaches the browser — bind/poll persists
17+
* it server-side and strips it from the response.
18+
*/
19+
20+
import { useCallback, useEffect, useRef, useState } from 'react';
21+
import {
22+
Cloud,
23+
CloudOff,
24+
Copy,
25+
ExternalLink,
26+
Loader2,
27+
AlertCircle,
28+
CheckCircle2,
29+
Unplug,
30+
} from 'lucide-react';
31+
import { ComponentRegistry } from '@object-ui/core';
32+
33+
const BASE = '/api/v1/cloud-connection';
34+
35+
interface ConnectionView {
36+
organization_id?: string | null;
37+
account_email?: string | null;
38+
bound_at?: string | null;
39+
}
40+
interface StatusData {
41+
environmentId: string | null;
42+
bound: boolean;
43+
connection: ConnectionView | null;
44+
}
45+
interface DeviceCode {
46+
device_code: string;
47+
user_code: string;
48+
verification_uri?: string;
49+
verification_uri_complete?: string;
50+
interval: number;
51+
expires_in: number;
52+
}
53+
54+
type Phase =
55+
| { kind: 'loading' }
56+
| { kind: 'unbound'; environmentId: string | null }
57+
| { kind: 'waiting'; code: DeviceCode; environmentId: string }
58+
| { kind: 'bound'; status: StatusData }
59+
| { kind: 'error'; message: string };
60+
61+
async function getJson(url: string, init?: RequestInit): Promise<any> {
62+
const resp = await fetch(url, {
63+
credentials: 'same-origin',
64+
headers: { 'Content-Type': 'application/json' },
65+
...init,
66+
});
67+
const body = await resp.json().catch(() => ({}));
68+
if (!resp.ok && body?.success !== true) {
69+
const msg = body?.error?.message ?? body?.error?.code ?? body?.error ?? `HTTP ${resp.status}`;
70+
throw new Error(typeof msg === 'string' ? msg : JSON.stringify(msg));
71+
}
72+
return body;
73+
}
74+
75+
export function CloudConnectionPanel() {
76+
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
77+
const [envIdInput, setEnvIdInput] = useState('');
78+
const [busy, setBusy] = useState(false);
79+
const [copied, setCopied] = useState(false);
80+
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
81+
82+
const stopPolling = useCallback(() => {
83+
if (pollTimer.current) { clearTimeout(pollTimer.current); pollTimer.current = null; }
84+
}, []);
85+
86+
const refreshStatus = useCallback(async () => {
87+
try {
88+
const body = await getJson(`${BASE}/status`);
89+
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);
92+
} catch (err: any) {
93+
setPhase({ kind: 'error', message: err?.message ?? String(err) });
94+
}
95+
}, []);
96+
97+
useEffect(() => {
98+
void refreshStatus();
99+
return stopPolling;
100+
}, [refreshStatus, stopPolling]);
101+
102+
const poll = useCallback((code: DeviceCode, environmentId: string, startedAt: number) => {
103+
const intervalMs = Math.max(code.interval, 2) * 1000;
104+
const tick = async () => {
105+
if (Date.now() - startedAt > code.expires_in * 1000) {
106+
setPhase({ kind: 'error', message: 'The code expired before it was approved. Start again.' });
107+
return;
108+
}
109+
try {
110+
const body = await getJson(`${BASE}/bind/poll`, {
111+
method: 'POST',
112+
body: JSON.stringify({ device_code: code.device_code, environment_id: environmentId }),
113+
});
114+
if (body?.data?.pending) {
115+
pollTimer.current = setTimeout(tick, intervalMs);
116+
return;
117+
}
118+
if (body?.data?.bound || body?.success) {
119+
await refreshStatus();
120+
return;
121+
}
122+
setPhase({ kind: 'error', message: body?.error?.code ?? 'Binding failed.' });
123+
} catch (err: any) {
124+
setPhase({ kind: 'error', message: err?.message ?? String(err) });
125+
}
126+
};
127+
pollTimer.current = setTimeout(tick, intervalMs);
128+
}, [refreshStatus]);
129+
130+
const connect = useCallback(async (environmentId: string) => {
131+
setBusy(true);
132+
try {
133+
const body = await getJson(`${BASE}/bind/start`, {
134+
method: 'POST',
135+
body: JSON.stringify(environmentId ? { environment_id: environmentId } : {}),
136+
});
137+
const code: DeviceCode = body?.data;
138+
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());
141+
} catch (err: any) {
142+
setPhase({ kind: 'error', message: err?.message ?? String(err) });
143+
} finally {
144+
setBusy(false);
145+
}
146+
}, [poll]);
147+
148+
const disconnect = useCallback(async () => {
149+
setBusy(true);
150+
try {
151+
await getJson(`${BASE}/unbind`, { method: 'POST', body: '{}' });
152+
await refreshStatus();
153+
} catch (err: any) {
154+
setPhase({ kind: 'error', message: err?.message ?? String(err) });
155+
} finally {
156+
setBusy(false);
157+
}
158+
}, [refreshStatus]);
159+
160+
const copyCode = useCallback(async (code: string) => {
161+
try {
162+
await navigator.clipboard.writeText(code);
163+
setCopied(true);
164+
setTimeout(() => setCopied(false), 1500);
165+
} catch { /* clipboard unavailable — user can select the text */ }
166+
}, []);
167+
168+
if (phase.kind === 'loading') {
169+
return (
170+
<div className="flex items-center gap-2 rounded-lg border p-6 text-sm text-muted-foreground">
171+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> Checking connection…
172+
</div>
173+
);
174+
}
175+
176+
if (phase.kind === 'error') {
177+
return (
178+
<div className="flex flex-col gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-6">
179+
<div className="flex items-center gap-2 text-sm text-destructive">
180+
<AlertCircle className="h-4 w-4" aria-hidden="true" /> {phase.message}
181+
</div>
182+
<button
183+
type="button"
184+
className="self-start rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
185+
onClick={() => { stopPolling(); void refreshStatus(); }}
186+
>
187+
Try again
188+
</button>
189+
</div>
190+
);
191+
}
192+
193+
if (phase.kind === 'waiting') {
194+
const link = phase.code.verification_uri_complete ?? phase.code.verification_uri;
195+
return (
196+
<div className="flex flex-col gap-4 rounded-lg border p-6">
197+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
198+
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
199+
Waiting for approval in the cloud console…
200+
</div>
201+
<div className="flex items-center gap-3">
202+
<code className="rounded-md bg-muted px-4 py-2 text-2xl font-semibold tracking-[0.25em]">
203+
{phase.code.user_code}
204+
</code>
205+
<button
206+
type="button"
207+
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
208+
onClick={() => void copyCode(phase.code.user_code)}
209+
>
210+
<Copy className="h-3.5 w-3.5" aria-hidden="true" /> {copied ? 'Copied' : 'Copy'}
211+
</button>
212+
</div>
213+
<p className="text-sm text-muted-foreground">
214+
Enter this code in the cloud console to approve the connection
215+
{link ? (
216+
<>
217+
{' '}— or{' '}
218+
<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" />
220+
</a>
221+
.
222+
</>
223+
) : '.'}
224+
</p>
225+
<button
226+
type="button"
227+
className="self-start rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
228+
onClick={() => { stopPolling(); void refreshStatus(); }}
229+
>
230+
Cancel
231+
</button>
232+
</div>
233+
);
234+
}
235+
236+
if (phase.kind === 'bound') {
237+
const conn = phase.status.connection ?? {};
238+
return (
239+
<div className="flex flex-col gap-4 rounded-lg border p-6">
240+
<div className="flex items-center gap-2">
241+
<CheckCircle2 className="h-5 w-5 text-emerald-600" aria-hidden="true" />
242+
<span className="font-medium">Connected to ObjectStack Cloud</span>
243+
</div>
244+
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-sm">
245+
{conn.organization_id ? (<><dt className="text-muted-foreground">Organization</dt><dd className="font-mono">{conn.organization_id}</dd></>) : null}
246+
{conn.account_email ? (<><dt className="text-muted-foreground">Approved by</dt><dd>{conn.account_email}</dd></>) : null}
247+
{phase.status.environmentId ? (<><dt className="text-muted-foreground">Environment</dt><dd className="font-mono">{phase.status.environmentId}</dd></>) : null}
248+
{conn.bound_at ? (<><dt className="text-muted-foreground">Since</dt><dd>{new Date(conn.bound_at).toLocaleString()}</dd></>) : null}
249+
</dl>
250+
<p className="text-sm text-muted-foreground">
251+
Your organization's private packages now appear in the Marketplace under “Your organization”.
252+
</p>
253+
<button
254+
type="button"
255+
disabled={busy}
256+
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"
257+
onClick={() => void disconnect()}
258+
>
259+
<Unplug className="h-3.5 w-3.5" aria-hidden="true" /> Disconnect
260+
</button>
261+
</div>
262+
);
263+
}
264+
265+
// unbound
266+
const needsEnvId = !phase.environmentId;
267+
return (
268+
<div className="flex flex-col gap-4 rounded-lg border p-6">
269+
<div className="flex items-center gap-2">
270+
<CloudOff className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
271+
<span className="font-medium">Not connected</span>
272+
</div>
273+
<p className="text-sm text-muted-foreground">
274+
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.
277+
</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}
291+
<button
292+
type="button"
293+
disabled={busy || (needsEnvId && !envIdInput.trim())}
294+
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())}
296+
>
297+
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Cloud className="h-4 w-4" aria-hidden="true" />}
298+
Connect
299+
</button>
300+
</div>
301+
);
302+
}
303+
304+
// SDUI registration: page metadata (shipped by @objectstack/cloud-connection)
305+
// references this widget by type. The renderer passes the component node as
306+
// `schema`; the panel needs no properties today.
307+
ComponentRegistry.register('cloud-connection:panel', () => <CloudConnectionPanel />, {
308+
namespace: 'app-shell',
309+
label: 'Cloud Connection Panel',
310+
category: 'plugin',
311+
inputs: [],
312+
});

packages/app-shell/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ export type { AppComponentRegistryEntry } from './services/componentRegistry';
173173
// Side-effect import: registers built-in admin components
174174
// (metadata:directory, metadata:resource) at module load.
175175
import './services/builtinComponents';
176+
// SDUI widget for the metadata-driven Cloud Connection page (cloud ADR-0008).
177+
import './console/cloud-connection/CloudConnectionPanel';
176178

177179
// Phase 3c — generic metadata admin engine. Re-exported so plugins
178180
// can call `registerMetadataResource()` to override the per-type

0 commit comments

Comments
 (0)