diff --git a/.changeset/cloud-connection-bind-v2-ux.md b/.changeset/cloud-connection-bind-v2-ux.md new file mode 100644 index 000000000..d44f9bb5a --- /dev/null +++ b/.changeset/cloud-connection-bind-v2-ux.md @@ -0,0 +1,7 @@ +--- +"@object-ui/app-shell": minor +"@object-ui/console": patch +"@object-ui/i18n": patch +--- + +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. diff --git a/apps/console/src/pages/auth/DeviceAuthPage.tsx b/apps/console/src/pages/auth/DeviceAuthPage.tsx index dda801199..7851fa59f 100644 --- a/apps/console/src/pages/auth/DeviceAuthPage.tsx +++ b/apps/console/src/pages/auth/DeviceAuthPage.tsx @@ -44,6 +44,11 @@ export function DeviceAuthPage() { const { t } = useObjectTranslation(); const [params] = useSearchParams(); const code = params.get('user_code') ?? params.get('code') ?? ''; + // Device context appended by the requesting runtime's bind/start (ADR + // runtime-identity-binding §2.3). Display-only informed consent — the + // warning copy below is what carries the anti-phishing weight. + const runtimeName = params.get('runtime_name') ?? ''; + const runtimeVersion = params.get('runtime_version') ?? ''; const { user, isLoading } = useAuth(); const navigate = useNavigate(); @@ -211,6 +216,18 @@ export function DeviceAuthPage() { + {runtimeName ? ( +
+

+ {t('auth.device.requesterLabel', { defaultValue: 'Connection request from' })} +

+

{runtimeName}

+ {runtimeVersion ? ( +

objectos {runtimeVersion}

+ ) : null} +
+ ) : null} +

{t('auth.device.userCodeLabel', { defaultValue: 'Device code' })} @@ -218,6 +235,14 @@ export function DeviceAuthPage() {

{code}

+

+ {t('auth.device.approveWarning', { + defaultValue: + 'Only approve if you started this connection yourself a moment ago. ' + + "Once approved, this runtime can access your organization's private packages.", + })} +

+

{t('auth.device.loggedInAs', { diff --git a/packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx b/packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx index b69ed4b1b..f113d466a 100644 --- a/packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx +++ b/packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx @@ -8,10 +8,13 @@ * the page shell, nav placement and labels ship as metadata WITH the * `@objectstack/cloud-connection` plugin (cloud ADR-0008 / console * SDUI-first direction). The widget talks to the runtime's same-origin - * `/api/v1/cloud-connection/*` routes: + * `/api/v1/cloud-connection/*` routes. * - * status → [Connect] → bind/start → user-code display → bind/poll … - * → bound (org / account / since) → [Disconnect] → unbind + * Zero-input flow (ADR runtime-identity-binding §2.3): [Connect] → + * bind/start (no environment id — the registration is created cloud-side + * at approval) → the approval page auto-opens in a popup with the code + * pre-filled and the device named → bind/poll … → bound. The visible + * user code is the popup-blocked fallback, not the primary path. * * The runtime credential never reaches the browser — bind/poll persists * it server-side and strips it from the response. @@ -36,9 +39,12 @@ interface ConnectionView { organization_id?: string | null; account_email?: string | null; bound_at?: string | null; + name?: string | null; + runtime_id?: string | null; } interface StatusData { environmentId: string | null; + runtimeId?: string | null; bound: boolean; connection: ConnectionView | null; } @@ -53,8 +59,8 @@ interface DeviceCode { type Phase = | { kind: 'loading' } - | { kind: 'unbound'; environmentId: string | null } - | { kind: 'waiting'; code: DeviceCode; environmentId: string } + | { kind: 'unbound' } + | { kind: 'waiting'; code: DeviceCode; popupOpened: boolean } | { kind: 'bound'; status: StatusData } | { kind: 'error'; message: string }; @@ -74,7 +80,6 @@ async function getJson(url: string, init?: RequestInit): Promise { export function CloudConnectionPanel() { const [phase, setPhase] = useState({ kind: 'loading' }); - const [envIdInput, setEnvIdInput] = useState(''); const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(false); const pollTimer = useRef | null>(null); @@ -87,8 +92,7 @@ export function CloudConnectionPanel() { try { const body = await getJson(`${BASE}/status`); const data: StatusData = body?.data ?? { environmentId: null, bound: false, connection: null }; - setPhase(data.bound ? { kind: 'bound', status: data } : { kind: 'unbound', environmentId: data.environmentId }); - if (data.environmentId) setEnvIdInput(data.environmentId); + setPhase(data.bound ? { kind: 'bound', status: data } : { kind: 'unbound' }); } catch (err: any) { setPhase({ kind: 'error', message: err?.message ?? String(err) }); } @@ -99,17 +103,17 @@ export function CloudConnectionPanel() { return stopPolling; }, [refreshStatus, stopPolling]); - const poll = useCallback((code: DeviceCode, environmentId: string, startedAt: number) => { + const poll = useCallback((code: DeviceCode, startedAt: number) => { const intervalMs = Math.max(code.interval, 2) * 1000; const tick = async () => { if (Date.now() - startedAt > code.expires_in * 1000) { - setPhase({ kind: 'error', message: 'The code expired before it was approved. Start again.' }); + setPhase({ kind: 'error', message: 'The request expired before it was approved. Start again.' }); return; } try { const body = await getJson(`${BASE}/bind/poll`, { method: 'POST', - body: JSON.stringify({ device_code: code.device_code, environment_id: environmentId }), + body: JSON.stringify({ device_code: code.device_code }), }); if (body?.data?.pending) { pollTimer.current = setTimeout(tick, intervalMs); @@ -127,17 +131,24 @@ export function CloudConnectionPanel() { pollTimer.current = setTimeout(tick, intervalMs); }, [refreshStatus]); - const connect = useCallback(async (environmentId: string) => { + const connect = useCallback(async () => { setBusy(true); try { - const body = await getJson(`${BASE}/bind/start`, { - method: 'POST', - body: JSON.stringify(environmentId ? { environment_id: environmentId } : {}), - }); + 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.'); - setPhase({ kind: 'waiting', code, environmentId }); - poll(code, environmentId, Date.now()); + // 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. + const link = code.verification_uri_complete ?? code.verification_uri; + let popupOpened = false; + if (link) { + try { + popupOpened = Boolean(window.open(link, '_blank', 'noopener,width=520,height=720')); + } catch { /* blocked — fallback UI below */ } + } + setPhase({ kind: 'waiting', code, popupOpened }); + poll(code, Date.now()); } catch (err: any) { setPhase({ kind: 'error', message: err?.message ?? String(err) }); } finally { @@ -196,8 +207,20 @@ export function CloudConnectionPanel() {

+ {!phase.popupOpened && link ? ( + + Open the approval page + ) : null}
{phase.code.user_code} @@ -211,12 +234,12 @@ export function CloudConnectionPanel() {

- Enter this code in the cloud console to approve the connection - {link ? ( + The code is pre-filled on the approval page + {phase.popupOpened && link ? ( <> - {' '}— or{' '} + {' '}— if the window did not appear,{' '} - open the approval page . @@ -235,6 +258,7 @@ export function CloudConnectionPanel() { if (phase.kind === 'bound') { const conn = phase.status.connection ?? {}; + const runtimeId = conn.runtime_id ?? phase.status.runtimeId; return (

@@ -242,8 +266,10 @@ export function CloudConnectionPanel() { Connected to ObjectStack Cloud
+ {conn.name ? (<>
Runtime
{conn.name}
) : null} {conn.organization_id ? (<>
Organization
{conn.organization_id}
) : null} {conn.account_email ? (<>
Approved by
{conn.account_email}
) : null} + {runtimeId ? (<>
Runtime ID
{runtimeId}
) : null} {phase.status.environmentId ? (<>
Environment
{phase.status.environmentId}
) : null} {conn.bound_at ? (<>
Since
{new Date(conn.bound_at).toLocaleString()}
) : null}
@@ -262,8 +288,7 @@ export function CloudConnectionPanel() { ); } - // unbound - const needsEnvId = !phase.environmentId; + // unbound — zero input: no environment id, nothing to paste anywhere. return (
@@ -272,27 +297,15 @@ export function CloudConnectionPanel() {

Connect this runtime to an ObjectStack control plane to browse your - organization's private packages and install them here. Approval - happens in the cloud console — no credentials are typed into this page. + 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.

- {needsEnvId ? ( - - ) : null}