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
7 changes: 7 additions & 0 deletions .changeset/cloud-connection-bind-v2-ux.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions apps/console/src/pages/auth/DeviceAuthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -211,13 +216,33 @@ export function DeviceAuthPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{runtimeName ? (
<div className="rounded-md border bg-background px-4 py-3 text-center">
<p className="mb-1 text-xs text-muted-foreground">
{t('auth.device.requesterLabel', { defaultValue: 'Connection request from' })}
</p>
<p className="font-medium">{runtimeName}</p>
{runtimeVersion ? (
<p className="text-xs text-muted-foreground">objectos {runtimeVersion}</p>
) : null}
</div>
) : null}

<div className="rounded-md border bg-background px-4 py-3 text-center">
<p className="mb-1 text-xs text-muted-foreground">
{t('auth.device.userCodeLabel', { defaultValue: 'Device code' })}
</p>
<p className="font-mono text-lg font-semibold tracking-widest">{code}</p>
</div>

<p className="text-center text-xs text-muted-foreground">
{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.",
})}
</p>

<div className="space-y-4">
<p className="text-center text-sm text-muted-foreground">
{t('auth.device.loggedInAs', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
}
Expand All @@ -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 };

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

export function CloudConnectionPanel() {
const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
const [envIdInput, setEnvIdInput] = useState('');
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All @@ -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) });
}
Expand All @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -196,8 +207,20 @@ export function CloudConnectionPanel() {
<div className="flex flex-col gap-4 rounded-lg border p-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Waiting for approval in the cloud console…
{phase.popupOpened
? 'Approve the connection in the window that just opened — this page updates by itself.'
: 'Waiting for approval in the cloud console…'}
</div>
{!phase.popupOpened && link ? (
<a
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"
href={link}
target="_blank"
rel="noreferrer"
>
Open the approval page <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
) : null}
<div className="flex items-center gap-3">
<code className="rounded-md bg-muted px-4 py-2 text-2xl font-semibold tracking-[0.25em]">
{phase.code.user_code}
Expand All @@ -211,12 +234,12 @@ export function CloudConnectionPanel() {
</button>
</div>
<p className="text-sm text-muted-foreground">
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,{' '}
<a className="inline-flex items-center gap-1 text-primary underline-offset-2 hover:underline" href={link} target="_blank" rel="noreferrer">
open the approval page <ExternalLink className="h-3 w-3" aria-hidden="true" />
open it here <ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
.
</>
Expand All @@ -235,15 +258,18 @@ export function CloudConnectionPanel() {

if (phase.kind === 'bound') {
const conn = phase.status.connection ?? {};
const runtimeId = conn.runtime_id ?? phase.status.runtimeId;
return (
<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>
</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}
</dl>
Expand All @@ -262,8 +288,7 @@ export function CloudConnectionPanel() {
);
}

// unbound
const needsEnvId = !phase.environmentId;
// unbound — zero input: no environment id, nothing to paste anywhere.
return (
<div className="flex flex-col gap-4 rounded-lg border p-6">
<div className="flex items-center gap-2">
Expand All @@ -272,27 +297,15 @@ export function CloudConnectionPanel() {
</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
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.
</p>
{needsEnvId ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-muted-foreground">
Environment ID (create an environment in the cloud console and paste its id)
</span>
<input
className="w-full max-w-md rounded-md border bg-background px-3 py-2 font-mono text-sm"
placeholder="e.g. 1764f947-6d1a-4b3e-82e4-9ca733f50a56"
value={envIdInput}
onChange={(e) => setEnvIdInput(e.target.value)}
/>
</label>
) : null}
<button
type="button"
disabled={busy || (needsEnvId && !envIdInput.trim())}
disabled={busy}
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"
onClick={() => void connect((phase.environmentId ?? envIdInput).trim())}
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
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,8 @@ const ar = {
title: "تفويض جهاز جديد",
subtitle: "وافق على هذا الجهاز لتسجيل الدخول بوصفك {{email}}.",
userCodeLabel: "رمز الجهاز",
requesterLabel: "طلب اتصال من",
approveWarning: "وافق فقط إذا كنت قد بدأت هذا الاتصال بنفسك قبل لحظات. بعد الموافقة، سيتمكن وقت التشغيل هذا من الوصول إلى الحزم الخاصة بمؤسستك.",
loggedInAs: "مسجل الدخول بوصفك {{email}}",
approve: "الموافقة على الجهاز",
approving: "جارٍ الموافقة…",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,8 @@ const de = {
title: "Neues Gerät autorisieren",
subtitle: "Genehmigen Sie dieses Gerät für die Anmeldung als {{email}}.",
userCodeLabel: "Gerätecode",
requesterLabel: "Verbindungsanfrage von",
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.",
loggedInAs: "Angemeldet als {{email}}",
approve: "Gerät genehmigen",
approving: "Genehmige…",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,8 @@ const en = {
title: 'Authorize new device',
subtitle: 'Approve this device to sign in as {{email}}.',
userCodeLabel: 'Device code',
requesterLabel: 'Connection request from',
approveWarning: 'Only approve if you started this connection yourself a moment ago. Once approved, this runtime can access your organization\'s private packages.',
loggedInAs: 'Signed in as {{email}}',
approve: 'Approve device',
approving: 'Approving…',
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,8 @@ const es = {
title: "Autorizar nuevo dispositivo",
subtitle: "Apruebe este dispositivo para iniciar sesión como {{email}}.",
userCodeLabel: "Código del dispositivo",
requesterLabel: "Solicitud de conexión de",
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.",
loggedInAs: "Conectado como {{email}}",
approve: "Aprobar dispositivo",
approving: "Aprobando…",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,8 @@ const fr = {
title: "Autoriser un nouvel appareil",
subtitle: "Approuvez cet appareil pour vous connecter en tant que {{email}}.",
userCodeLabel: "Code de l'appareil",
requesterLabel: "Demande de connexion de",
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.",
loggedInAs: "Connecté en tant que {{email}}",
approve: "Approuver l'appareil",
approving: "Approbation…",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,8 @@ const ja = {
title: "新しいデバイスを認証",
subtitle: "{{email}} としてサインインするためにこのデバイスを承認します。",
userCodeLabel: "デバイスコード",
requesterLabel: "接続リクエスト元",
approveWarning: "この接続を直前にご自身で開始した場合にのみ承認してください。承認すると、このランタイムは組織のプライベートパッケージにアクセスできます。",
loggedInAs: "{{email}} としてサインイン中",
approve: "デバイスを承認",
approving: "承認中…",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,8 @@ const ko = {
title: "새 장치 승인",
subtitle: "{{email}}으로 로그인하기 위해 이 장치를 승인하세요.",
userCodeLabel: "장치 코드",
requesterLabel: "연결 요청 출처",
approveWarning: "방금 직접 시작한 연결인 경우에만 승인하세요. 승인하면 이 런타임이 조직의 비공개 패키지에 액세스할 수 있습니다.",
loggedInAs: "{{email}}으로 로그인됨",
approve: "장치 승인",
approving: "승인 중…",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,8 @@ const pt = {
title: "Autorizar novo dispositivo",
subtitle: "Aprove este dispositivo para fazer login como {{email}}.",
userCodeLabel: "Código do dispositivo",
requesterLabel: "Solicitação de conexão de",
approveWarning: "Aprove somente se você mesmo iniciou esta conexão há instantes. Depois de aprovada, este runtime poderá acessar os pacotes privados da sua organização.",
loggedInAs: "Conectado como {{email}}",
approve: "Aprovar dispositivo",
approving: "Aprovando…",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1148,6 +1148,8 @@ const ru = {
title: "Авторизовать новое устройство",
subtitle: "Одобрите это устройство для входа как {{email}}.",
userCodeLabel: "Код устройства",
requesterLabel: "Запрос на подключение от",
approveWarning: "Подтверждайте только если вы сами только что начали это подключение. После подтверждения эта среда выполнения получит доступ к приватным пакетам вашей организации.",
loggedInAs: "Вошли как {{email}}",
approve: "Одобрить устройство",
approving: "Одобрение…",
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,8 @@ const zh = {
title: '授权新设备',
subtitle: '批准此设备以 {{email}} 的身份登录。',
userCodeLabel: '设备代码',
requesterLabel: '连接请求来自',
approveWarning: '仅当这是你刚刚自己发起的连接时才批准。批准后,该运行时将能访问你所在组织的私有软件包。',
loggedInAs: '已以 {{email}} 身份登录',
approve: '批准设备',
approving: '批准中…',
Expand Down
Loading