|
| 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 | +}); |
0 commit comments