Explorar el Código

feat(orca-cloud): pair via RFC 8628 device flow, replacing the paste-based sign-in

OrcaSlicer shipped a first-class external-app pairing API (OAuth 2.0 Device
Authorization Grant), so the Supabase-PKCE copy-paste flow is replaced end to
end. Connecting is now: click Connect, approve a short code on the Orca Cloud
settings page, done — no redirect, no callback paste, no client secret, works
from a LAN IP / localhost / behind a proxy.

Backend: services/orca_cloud.py rewritten to device-code request + poll (the
four RFC outcomes) + refresh_token grant + introspection + external sync pull;
routes expose /device/start and /device/poll (device_code kept server-side in
the reused orca_cloud_pending_* columns, no migration). Requests sync:read
(read-only feature). Prod endpoint by default, ORCA_CLOUD_API_BASE overrides
to staging. Wired the shared httpx client (fixes a per-request socket leak).

Frontend: device-code connect UI + api client methods; all 11 locales updated.
maziggy hace 1 mes
padre
commit
00251fe808

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [1.2.5b2] - Unreleased
 
+### Changed
+- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
+
 ### Fixed
 - **P2S RTSP timeout could leave the fan-out camera stream permanently stalled (#2580, reported and diagnosed by @ronaldheft, fix shape from PR #2581)** — After an RTSP read timeout, the stream cleanup killed the stalled ffmpeg and then waited *unbounded* for it to be reaped. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily long to exit, so the fan-out stream coroutine sat parked in that wait — in the reported case for 12 hours — while every new viewer attached to the stalled broadcaster and got no frames (snapshots and diagnostics kept working, since those open fresh connections). The post-kill wait is now bounded (2 s): on timeout the stream abandons the zombie — the orphan janitor's /proc scan reaps it on its next pass — and proceeds to its normal reconnect, so live view recovers by itself. The same unbounded wait hid in two more places, both bounded too: the camera *Stop* endpoint (which would hang the very request a user makes to recover a stuck stream) and the periodic orphan-cleanup janitor itself (which is the safety net that recovers stalled streams, and so can least afford to block).
 - **Queue edit showed the sliced-for model as the scheduler target, and a cross-model queue row could dispatch G-code to an incompatible printer (#2578, reporter @Jostxxl)** — Two bugs with one root. The "Any \<model\>" assignment button labeled itself from the file's slice metadata while the scheduler actually used the row's `target_model`, so an X1C-sliced item targeting H2D read "Any X1C" above "Scheduler will assign to first available idle H2D printer". Worse, the mismatch could be *created* silently: the sliced-for model loads asynchronously, and clicking "Any Model" before it arrived pre-selected the first model alphabetically — on a mixed X1C/P1S/H2D farm that's H2D — after which the model dropdown hid itself, leaving no way to see or fix the wrong target. Nothing downstream checked compatibility, so the scheduler would happily hand X1C G-code to an H2D. Now: the target model is never silently defaulted (the dropdown stays visible in model mode, pre-selected to the sliced-for model when available, and back-fills once the metadata loads); the button reflects the actual target; a warning shows when the target differs from the sliced-for model. Compatibility is enforced end-to-end with an explicit G-code interchange family table (X1/X1C/X1E/P1P/P1S interchange; everything else exact-match — files without slice metadata are never blocked): incompatible models are disabled in the dropdown, queue create/update reject a mismatch with a clear 400 (so API-created rows can't sneak in), and the scheduler holds back pre-existing mismatched rows with an actionable waiting reason instead of dispatching them — fix the target via edit and the job flows again.

+ 152 - 148
backend/app/api/routes/orca_cloud.py

@@ -1,31 +1,34 @@
 """
 Orca Cloud API Routes
 
-PKCE-based connect/disconnect + profile sync endpoints for the
-Orca Cloud (Supabase) profile-sync surface.
+Device-pairing (RFC 8628) connect/disconnect + profile sync endpoints for the
+Orca Cloud external-app surface.
 
 Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
 
-    POST /orca-cloud/auth/start
-        Generate PKCE + state, persist them (TTL 10 min), return the auth URL.
-    POST /orca-cloud/auth/finish
-        Parse the pasted callback URL, validate state for CSRF, exchange the
-        code for tokens, persist them atomically.
+    POST /orca-cloud/device/start
+        Request a device code, persist it server-side (TTL 10 min), return the
+        user_code + verification URIs + poll interval.
+    POST /orca-cloud/device/poll
+        One poll of the token endpoint. Returns an in-progress status while the
+        user approves; on approval, persists the token pair and reports
+        connected. The frontend calls this every ``interval`` seconds.
     GET  /orca-cloud/status
-        Connected/disconnected + email + user_id.
+        Connected/disconnected + user_id.
     POST /orca-cloud/logout
-        Clear stored tokens (no Supabase-side revocation — token still
-        survives until its 1h expiry, but Bambuddy has no way to use it).
+        Clear stored tokens (Bambuddy then has no token to use; the user can
+        also disconnect from Orca Cloud's own settings to revoke server-side).
     GET  /orca-cloud/profiles
-        Paginated list of the user's Orca Cloud profiles. JIT-refreshes the
-        access token if it's within the 5-min leeway of expiry.
+        List of the user's Orca Cloud profiles, grouped by type. JIT-refreshes
+        the access token if it's within the refresh leeway of expiry.
     GET  /orca-cloud/profiles/{id}
         Single profile's full content.
 
-Storage shape mirrors the Bambu Cloud surface: per-user columns on
-``users`` when auth is enabled, fallback to global ``settings`` keys when
-auth is disabled. The transient PKCE state (verifier, state, pending_at)
-is stored alongside the tokens — same dual-mode pattern.
+Storage shape mirrors the Bambu Cloud surface: per-user columns on ``users``
+when auth is enabled, fallback to global ``settings`` keys when auth is
+disabled. The transient pending device-code state (device_code, interval,
+started_at) reuses the ``orca_cloud_pending_*`` columns — same dual-mode
+pattern; no schema change from the previous PKCE flow.
 """
 
 from __future__ import annotations
@@ -33,7 +36,7 @@ from __future__ import annotations
 import logging
 from datetime import datetime, timezone
 
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
@@ -43,23 +46,19 @@ from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.schemas.orca_cloud import (
-    OrcaAuthFinishRequest,
-    OrcaAuthPasswordRequest,
-    OrcaAuthStartRequest,
-    OrcaAuthStartResponse,
     OrcaAuthStatusResponse,
+    OrcaDevicePollResponse,
+    OrcaDeviceStartResponse,
     OrcaProfileDetail,
     OrcaProfileListResponse,
     OrcaProfileMeta,
 )
 from backend.app.services.orca_cloud import (
-    PENDING_PKCE_TTL,
+    DEVICE_CODE_TTL,
+    DevicePoll,
     OrcaCloudAuthError,
     OrcaCloudError,
     OrcaCloudService,
-    build_authorize_url,
-    generate_pkce,
-    parse_callback_url,
 )
 
 logger = logging.getLogger(__name__)
@@ -90,9 +89,9 @@ _ORCA_TYPE_TO_BAMBU = {
 
 
 def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
-    """Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``)
-    into a ``SlicerSetting``-shaped row. Returns ``None`` if the content
-    isn't a dict or the type isn't one we render."""
+    """Normalize one Orca profile (``{id, name, content, ...}``) into a
+    ``SlicerSetting``-shaped row. Returns ``None`` if the content isn't a dict
+    or the type isn't one we render."""
     content = orca_profile.get("content") or {}
     if not isinstance(content, dict):
         return None
@@ -130,15 +129,16 @@ def _str_or_none(value: object) -> str | None:
 
 # Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
 # pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
-# settings table see a consistent prefix.
+# settings table see a consistent prefix. The ``pending_*`` keys hold the
+# transient device-code state (device_code / interval / started_at).
 _SETTINGS_KEYS = {
     "token": "orca_cloud_token",
     "refresh_token": "orca_cloud_refresh_token",
     "expires_at": "orca_cloud_expires_at",  # ISO 8601 UTC string
     "email": "orca_cloud_email",
     "user_id": "orca_cloud_user_id",
-    "pending_verifier": "orca_cloud_pending_verifier",
-    "pending_state": "orca_cloud_pending_state",
+    "pending_device_code": "orca_cloud_pending_verifier",  # reused column
+    "pending_interval": "orca_cloud_pending_state",  # reused column
     "pending_at": "orca_cloud_pending_at",  # ISO 8601 UTC string
 }
 
@@ -184,7 +184,11 @@ def _parse_iso(value: str | None) -> datetime | None:
 class _OrcaCredentials:
     """Lightweight bag for stored Orca Cloud credentials. We use a class
     rather than a dataclass so the helpers can mutate it as needed during
-    JIT-refresh without rebuilding the whole object."""
+    JIT-refresh without rebuilding the whole object.
+
+    ``pending_device_code`` / ``pending_interval`` / ``pending_at`` hold the
+    in-flight device-code pairing state (reusing the ``orca_cloud_pending_*``
+    columns that the old PKCE flow used for its verifier/state)."""
 
     __slots__ = (
         "token",
@@ -192,8 +196,8 @@ class _OrcaCredentials:
         "expires_at",
         "email",
         "user_id",
-        "pending_verifier",
-        "pending_state",
+        "pending_device_code",
+        "pending_interval",
         "pending_at",
     )
 
@@ -203,8 +207,8 @@ class _OrcaCredentials:
         self.expires_at: datetime | None = None
         self.email: str | None = None
         self.user_id: str | None = None
-        self.pending_verifier: str | None = None
-        self.pending_state: str | None = None
+        self.pending_device_code: str | None = None
+        self.pending_interval: str | None = None
         self.pending_at: datetime | None = None
 
 
@@ -227,8 +231,8 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent
         creds.expires_at = _as_utc(user.orca_cloud_expires_at)
         creds.email = user.orca_cloud_email
         creds.user_id = user.orca_cloud_user_id
-        creds.pending_verifier = user.orca_cloud_pending_verifier
-        creds.pending_state = user.orca_cloud_pending_state
+        creds.pending_device_code = user.orca_cloud_pending_verifier
+        creds.pending_interval = user.orca_cloud_pending_state
         creds.pending_at = _as_utc(user.orca_cloud_pending_at)
         return creds
 
@@ -239,27 +243,28 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent
     creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
     creds.email = raw.get(_SETTINGS_KEYS["email"])
     creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
-    creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"])
-    creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"])
+    creds.pending_device_code = raw.get(_SETTINGS_KEYS["pending_device_code"])
+    creds.pending_interval = raw.get(_SETTINGS_KEYS["pending_interval"])
     creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
     return creds
 
 
-async def _persist_pending_pkce(
+async def _persist_pending_device(
     db: AsyncSession,
     user: User | None,
-    verifier: str,
-    state: str,
+    device_code: str,
+    interval: int,
     when: datetime,
 ) -> None:
-    """Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``."""
+    """Store the transient device-code state used by ``/device/start`` ->
+    ``/device/poll``. The device_code is a secret kept server-side."""
     if user is not None:
         await db.execute(
             update(User)
             .where(User.id == user.id)
             .values(
-                orca_cloud_pending_verifier=verifier,
-                orca_cloud_pending_state=state,
+                orca_cloud_pending_verifier=device_code,
+                orca_cloud_pending_state=str(interval),
                 orca_cloud_pending_at=when,
             )
         )
@@ -268,13 +273,38 @@ async def _persist_pending_pkce(
     await _upsert_settings(
         db,
         {
-            _SETTINGS_KEYS["pending_verifier"]: verifier,
-            _SETTINGS_KEYS["pending_state"]: state,
+            _SETTINGS_KEYS["pending_device_code"]: device_code,
+            _SETTINGS_KEYS["pending_interval"]: str(interval),
             _SETTINGS_KEYS["pending_at"]: _iso(when),
         },
     )
 
 
+async def _clear_pending_device(db: AsyncSession, user: User | None) -> None:
+    """Wipe just the pending device-code state (on terminal poll outcomes),
+    leaving any existing tokens untouched."""
+    if user is not None:
+        await db.execute(
+            update(User)
+            .where(User.id == user.id)
+            .values(
+                orca_cloud_pending_verifier=None,
+                orca_cloud_pending_state=None,
+                orca_cloud_pending_at=None,
+            )
+        )
+        await db.commit()
+        return
+    await _upsert_settings(
+        db,
+        {
+            _SETTINGS_KEYS["pending_device_code"]: None,
+            _SETTINGS_KEYS["pending_interval"]: None,
+            _SETTINGS_KEYS["pending_at"]: None,
+        },
+    )
+
+
 async def _persist_tokens(
     db: AsyncSession,
     user: User | None,
@@ -285,8 +315,8 @@ async def _persist_tokens(
     user_id: str | None,
 ) -> None:
     """Atomically write the new access/refresh pair to whichever backing store
-    the deployment uses. Also clears the pending PKCE state on the same write,
-    since by this point the handshake is complete."""
+    the deployment uses. Also clears the pending device-code state on the same
+    write, since by this point the pairing is complete."""
     if user is not None:
         await db.execute(
             update(User)
@@ -312,8 +342,8 @@ async def _persist_tokens(
             _SETTINGS_KEYS["expires_at"]: _iso(expires_at),
             _SETTINGS_KEYS["email"]: email,
             _SETTINGS_KEYS["user_id"]: user_id,
-            _SETTINGS_KEYS["pending_verifier"]: None,
-            _SETTINGS_KEYS["pending_state"]: None,
+            _SETTINGS_KEYS["pending_device_code"]: None,
+            _SETTINGS_KEYS["pending_interval"]: None,
             _SETTINGS_KEYS["pending_at"]: None,
         },
     )
@@ -327,7 +357,7 @@ async def _persist_rotated_tokens(
     expires_at: datetime | None,
 ) -> None:
     """Persist tokens after a refresh — does NOT touch email/user_id and does
-    NOT touch the pending PKCE state (refresh happens long after the handshake)."""
+    NOT touch the pending state (refresh happens long after pairing)."""
     if user is not None:
         await db.execute(
             update(User)
@@ -405,7 +435,12 @@ async def _build_authenticated_service(
     """Construct an :class:`OrcaCloudService` pre-populated with stored
     credentials. If the access token is within the refresh-leeway of expiry,
     proactively refresh and persist the new pair BEFORE returning, so the
-    next API call doesn't time out mid-flight on an expired token."""
+    next API call doesn't time out mid-flight on an expired token.
+
+    We don't lock around the refresh: Orca tolerates concurrent refreshes for
+    ~60s (each racer gets its own valid pair on the same connection rather than
+    a revoke), so a lost race here is harmless — last-write-wins on the stored
+    pair, and whichever pair we keep is valid."""
     creds = await _load_credentials(db, user)
     if not creds.token:
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
@@ -439,128 +474,97 @@ async def _build_authenticated_service(
 # ---------------------------------------------------------------------------
 
 
-@router.post("/auth/start", response_model=OrcaAuthStartResponse)
-async def auth_start(
-    payload: OrcaAuthStartRequest = OrcaAuthStartRequest(),
-    db: AsyncSession = Depends(get_db),
-    current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
-):
-    """Generate PKCE state and return the Supabase authorize URL for the
-    requested OAuth provider (google / apple / github). The frontend opens
-    the URL in a new tab; after sign-in the user pastes the callback URL
-    back into ``/auth/finish``.
-
-    ``state`` is generated but NOT sent to Supabase (it would clash with
-    GoTrue's internal redirect_to-tracking state). We still persist it so
-    a future flow change can re-introduce state-based CSRF if needed; CSRF
-    protection today comes from the PKCE verifier itself, which is
-    single-use, server-side, and bound to the caller's user row."""
-    verifier, challenge, state = generate_pkce()
-    await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc))
-    return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider))
-
-
-@router.post("/auth/password", response_model=OrcaAuthStatusResponse)
-async def auth_password(
-    payload: OrcaAuthPasswordRequest,
+@router.post("/device/start", response_model=OrcaDeviceStartResponse)
+async def device_start(
+    request: Request,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Direct email+password sign-in. No browser redirect, no paste flow —
-    Bambuddy POSTs the credentials to Supabase and stores the returned
-    tokens. Whether this succeeds depends on Orca's Supabase project
-    accepting the password grant; if it rejects (the SDK refuses passwords
-    by design, the backend may follow suit), the caller falls back to an
-    OAuth provider via ``/auth/start``."""
+    """Begin device pairing. Requests a device code from Orca, stores it
+    server-side (the device_code is a secret and never leaves the backend),
+    and returns the user_code + verification URIs + poll interval for the
+    frontend to display and poll against."""
     svc = OrcaCloudService()
+    # instance_url/label are display-only anti-phishing context on the approval
+    # card. base_url may be off behind a reverse proxy, but it's harmless if so.
+    instance_url = str(request.base_url).rstrip("/") or None
     try:
-        await svc.password_login(payload.email, payload.password)
+        data = await svc.request_device_code(instance_url=instance_url, instance_label="Bambuddy")
     except OrcaCloudAuthError as e:
-        raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
+        # invalid_client etc. — an operator misconfiguration, not user error.
+        raise HTTPException(status_code=502, detail=f"Orca Cloud pairing is misconfigured: {e}") from e
     except OrcaCloudError as e:
         raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
 
-    email: str | None = None
-    user_id: str | None = None
-    try:
-        user_info = await svc.get_user_info()
-        if isinstance(user_info, dict):
-            email = user_info.get("email")
-            user_id = user_info.get("id")
-    except OrcaCloudError as e:
-        logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e)
-
-    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
-    return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
+    device_code = data.get("device_code")
+    user_code = data.get("user_code")
+    if not device_code or not user_code:
+        raise HTTPException(status_code=502, detail="Orca Cloud returned an incomplete device-code response.")
+
+    interval = int(data.get("interval") or 5)
+    expires_in = int(data.get("expires_in") or DEVICE_CODE_TTL.total_seconds())
+    await _persist_pending_device(db, current_user, device_code, interval, datetime.now(timezone.utc))
+
+    return OrcaDeviceStartResponse(
+        user_code=user_code,
+        verification_uri=str(data.get("verification_uri") or ""),
+        verification_uri_complete=str(data.get("verification_uri_complete") or ""),
+        interval=interval,
+        expires_in=expires_in,
+    )
 
 
-@router.post("/auth/finish", response_model=OrcaAuthStatusResponse)
-async def auth_finish(
-    payload: OrcaAuthFinishRequest,
+@router.post("/device/poll", response_model=OrcaDevicePollResponse)
+async def device_poll(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Complete the PKCE handshake — parse the pasted callback URL, validate
-    state (CSRF), exchange the code for tokens, persist."""
+    """Poll the token endpoint once for the in-flight pairing. Returns an
+    in-progress status while the user approves; on approval persists the token
+    pair (clearing the pending state) and reports connected."""
     creds = await _load_credentials(db, current_user)
-    if not creds.pending_verifier or not creds.pending_state or not creds.pending_at:
+    if not creds.pending_device_code or not creds.pending_at:
         raise HTTPException(
             status_code=400,
-            detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.",
+            detail="No pending Orca Cloud pairing. Click Connect first to start the flow.",
         )
 
     # creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
-    # normalization. Subtracting two aware UTC datetimes gives a real wall-clock
-    # delta with no local-offset shift.
+    # normalization. Subtracting two aware UTC datetimes gives a real delta.
     age = datetime.now(timezone.utc) - creds.pending_at
-    if age > PENDING_PKCE_TTL:
-        # Don't leave the stale state in the DB — clear it so the user has to
-        # restart fresh, which forces a new verifier/state pair.
-        await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc))
-        raise HTTPException(
-            status_code=400,
-            detail=(
-                f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. "
-                "Click Connect again to start over."
-            ),
-        )
-
-    code, _callback_state = parse_callback_url(payload.callback_url)
-    if not code:
-        raise HTTPException(
-            status_code=400,
-            detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.",
-        )
-    # We do NOT validate ``state`` here: Supabase doesn't echo back a state we
-    # don't send (see :func:`build_authorize_url` for why we can't send one).
-    # CSRF is protected by PKCE: the verifier is server-side and single-use,
-    # so an attacker can't complete the exchange with a code they obtained
-    # separately. ``pending_state`` is still stored for forward compatibility
-    # if Supabase ever supports a client-passed state alongside redirect_to.
+    if age > DEVICE_CODE_TTL:
+        await _clear_pending_device(db, current_user)
+        return OrcaDevicePollResponse(status=DevicePoll.EXPIRED, connected=False)
 
     svc = OrcaCloudService()
     try:
-        await svc.exchange_code(code, creds.pending_verifier)
-    except OrcaCloudAuthError as e:
-        raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
+        status, token_data = await svc.poll_token(creds.pending_device_code)
     except OrcaCloudError as e:
         raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
 
-    # Fetch user info so we can show the connected email in the UI.
-    email: str | None = None
+    if status in DevicePoll.ONGOING:
+        return OrcaDevicePollResponse(status=status, connected=False)
+
+    if status in DevicePoll.TERMINAL:
+        # access_denied / expired_token — the attempt is dead; clear it so the
+        # user starts fresh next time.
+        await _clear_pending_device(db, current_user)
+        return OrcaDevicePollResponse(status=status, connected=False)
+
+    # COMPLETE — tokens issued and applied to svc. Introspect for the user_id
+    # (the external API's /me doesn't return an email, so email stays None).
     user_id: str | None = None
     try:
-        user_info = await svc.get_user_info()
-        if isinstance(user_info, dict):
-            email = user_info.get("email")
-            user_id = user_info.get("id")
+        info = await svc.introspect()
+        if isinstance(info, dict):
+            user_id = _str_or_none(info.get("user_id"))
     except OrcaCloudError as e:
-        # Don't fail the whole connect flow just because the user-info side
-        # call hiccuped — we have valid tokens, that's the load-bearing part.
-        logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e)
+        # Don't fail the whole pairing over the side introspection call — we
+        # have valid tokens, which is the load-bearing part.
+        logger.warning("Orca Cloud introspection failed after successful pairing: %s", e)
 
-    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
-    return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
+    await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, None, user_id)
+    return OrcaDevicePollResponse(status=DevicePoll.COMPLETE, connected=True, email=None, user_id=user_id)
 
 
 @router.get("/status", response_model=OrcaAuthStatusResponse)
@@ -583,9 +587,9 @@ async def logout(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
 ):
-    """Clear stored Orca Cloud credentials. Does not call Supabase's
-    ``/logout`` endpoint (the token would still survive its 1h expiry there
-    either way, and Bambuddy will no longer have it to use)."""
+    """Clear stored Orca Cloud credentials. Does not call Orca's disconnect
+    endpoint (the user can revoke server-side from Orca Cloud's own settings;
+    Bambuddy will no longer have the token to use either way)."""
     await _clear_credentials(db, current_user)
     return {"success": True}
 

+ 7 - 0
backend/app/main.py

@@ -6125,12 +6125,18 @@ async def lifespan(app: FastAPI):
     from backend.app.services.makerworld import (
         set_shared_http_client as set_shared_makerworld_http_client,
     )
+    from backend.app.services.orca_cloud import (
+        set_shared_http_client as set_shared_orca_http_client,
+    )
 
     _shared_cloud_http_client = _httpx.AsyncClient(timeout=30.0)
     set_shared_http_client(_shared_cloud_http_client)
     # Reuse the same connection pool for MakerWorld — different host, same
     # keep-alive pool saves a TLS handshake per request.
     set_shared_makerworld_http_client(_shared_cloud_http_client)
+    # Same for Orca Cloud — without this the per-request OrcaCloudService()
+    # each spun up (and never closed) its own client, leaking sockets.
+    set_shared_orca_http_client(_shared_cloud_http_client)
 
     # Fix queue items stuck with invalid "aborted" status (should be "cancelled").
     # This can happen when a print was cancelled mid-print on versions before this fix.
@@ -6450,6 +6456,7 @@ async def lifespan(app: FastAPI):
     # Drop the shared Bambu Cloud HTTP client we registered at startup.
     set_shared_http_client(None)
     set_shared_makerworld_http_client(None)
+    set_shared_orca_http_client(None)
     await _shared_cloud_http_client.aclose()
 
     # Checkpoint WAL (SQLite only) and close all database connections

+ 27 - 35
backend/app/schemas/orca_cloud.py

@@ -1,50 +1,42 @@
-"""Schemas for Orca Cloud auth + profile sync endpoints."""
+"""Schemas for Orca Cloud device-pairing auth + profile sync endpoints."""
 
 from typing import Literal
 
 from pydantic import BaseModel, Field
 
-# The three OAuth providers Orca's sign-in surface offers. Supabase
-# accepts the bare lowercase provider name in the authorize query string.
-OrcaOAuthProvider = Literal["google", "apple", "github"]
 
+class OrcaDeviceStartResponse(BaseModel):
+    """Returned by ``POST /orca-cloud/device/start``. The frontend shows
+    ``user_code`` and a clickable/QR ``verification_uri_complete``; the user
+    approves in their Orca Cloud settings. The ``device_code`` itself is a
+    secret and stays server-side — it is deliberately NOT in this response."""
 
-class OrcaAuthStartRequest(BaseModel):
-    """Body for ``POST /orca-cloud/auth/start``. Provider defaults to
-    ``google`` so existing clients that send an empty body keep working."""
+    user_code: str = Field(..., description="Short code the user confirms on the approval page")
+    verification_uri: str = Field(..., description="Approval page URL")
+    verification_uri_complete: str = Field(..., description="Approval page URL with the code pre-filled")
+    interval: int = Field(..., description="Seconds the frontend should wait between poll calls")
+    expires_in: int = Field(..., description="Seconds until this pairing attempt expires")
 
-    provider: OrcaOAuthProvider = Field(default="google", description="OAuth provider to use for sign-in")
 
+# Poll outcomes surfaced to the frontend. ``authorization_pending`` /
+# ``slow_down`` mean keep polling; ``access_denied`` / ``expired_token`` are
+# terminal (restart the flow); ``complete`` means paired.
+OrcaDevicePollStatus = Literal[
+    "authorization_pending",
+    "slow_down",
+    "access_denied",
+    "expired_token",
+    "complete",
+]
 
-class OrcaAuthStartResponse(BaseModel):
-    """Returned by ``POST /orca-cloud/auth/start``. The frontend opens
-    ``auth_url`` in a new tab. After the user signs in to Orca, they copy the
-    redirected URL from their address bar and POST it to
-    ``/orca-cloud/auth/finish`` to complete the handshake."""
 
-    auth_url: str = Field(..., description="URL to open for Orca Cloud sign-in")
+class OrcaDevicePollResponse(BaseModel):
+    """Returned by ``POST /orca-cloud/device/poll`` — one poll attempt."""
 
-
-class OrcaAuthFinishRequest(BaseModel):
-    """Submitted by the frontend after the user pastes the callback URL from
-    their browser. The URL contains a Supabase ``code`` (and our ``state``)
-    that we exchange for tokens."""
-
-    callback_url: str = Field(..., description="The full URL the browser was redirected to after sign-in")
-
-
-class OrcaAuthPasswordRequest(BaseModel):
-    """Body for ``POST /orca-cloud/auth/password``. Whether this succeeds
-    depends on Orca's Supabase project — their desktop client refuses
-    password payloads, but the web sign-in offers email+password as one
-    option. We forward the credentials and surface the server's response.
-    ``email`` is plain ``str`` rather than Pydantic's ``EmailStr`` to avoid
-    pulling in the optional ``email-validator`` dependency — Supabase will
-    reject malformed addresses with a clear error itself, and the existing
-    Bambu Cloud login schema uses the same approach."""
-
-    email: str = Field(..., min_length=1)
-    password: str = Field(..., min_length=1)
+    status: OrcaDevicePollStatus
+    connected: bool = False
+    email: str | None = None
+    user_id: str | None = None
 
 
 class OrcaAuthStatusResponse(BaseModel):

+ 235 - 279
backend/app/services/orca_cloud.py

@@ -1,33 +1,42 @@
 """
 Orca Cloud API Service
 
-Handles authentication and profile sync with the Orca Cloud (Supabase-backed).
-
-Auth shape: PKCE flow against ``auth.orcaslicer.com`` with the in-source public
-publishable key. Bambuddy generates the verifier/challenge/state, redirects the
-user's browser to Supabase's ``/auth/v1/authorize`` endpoint with
-``redirect_to=http://localhost:41172/callback``, and the user pastes the
-callback URL back into Bambuddy (the loopback URL is the only ``redirect_to``
-Orca's Supabase project actually honors as of v2.4.0-alpha — see
-OrcaSlicer/OrcaSlicer#14028 for the open feature request asking SoftFever to
-broaden this).
-
-Token shape: short-lived access JWT (1h) + rotating single-use refresh token.
-Every refresh issues a new pair and invalidates the old one — the route layer
-is responsible for atomically swapping the stored pair on each refresh, or a
-mid-refresh crash strands the user.
-
-Cloudflare protects ``api.orcaslicer.com`` with a User-Agent gate; sending an
-honest ``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
+Handles pairing and profile sync with the Orca Cloud external-app surface.
+
+Auth shape: OAuth 2.0 Device Authorization Grant (RFC 8628). Bambuddy is a
+public client (``client_id`` only, no secret) — there is no redirect URL, so
+the flow works from a LAN IP, ``localhost``, or behind a reverse proxy. The
+user approves a short ``user_code`` in their Orca Cloud settings; Bambuddy
+polls the token endpoint until a token pair is issued.
+
+    POST /oauth/device/code   -> {device_code, user_code, verification_uri,
+                                  verification_uri_complete, expires_in, interval}
+    POST /oauth/token         -> poll with grant_type=device_code, then later
+                                  refresh with grant_type=refresh_token
+
+Token shape: opaque ``oc_ext_`` access token (24h) + single-use rotating
+``oc_ext_rt_`` refresh token (90-day, renewed on each rotation). Reuse of a
+consumed refresh token beyond a ~60s server-side grace window revokes the
+whole pairing, so the route layer MUST persist the new pair atomically with
+consuming the old one. Within the grace window a lost refresh race is a no-op
+(each racer gets its own fresh pair), so single-flighting is hygiene, not a
+correctness requirement.
+
+API surface: ``oc_ext_`` tokens authorize ONLY the ``/api/v1/external/*``
+endpoints (introspection + ``/external/sync/*``). The first-party
+``/api/v1/sync/*`` surface used by the old Supabase flow is NOT reachable with
+these tokens.
+
+Cloudflare fronts ``api.orcaslicer.com`` and blocks unusual User-Agents
+(``python-urllib`` gets a ``403 "error code: 1010"``); an honest
+``Bambuddy/<version>`` UA clears it. No TLS-fingerprint matching needed.
 """
 
 from __future__ import annotations
 
-import base64
-import hashlib
 import json
 import logging
-import secrets
+import os
 from datetime import datetime, timedelta, timezone
 from typing import Any
 
@@ -35,45 +44,82 @@ import httpx
 
 logger = logging.getLogger(__name__)
 
-# Auth + API endpoints — extracted verbatim from OrcaCloudServiceAgent.cpp
-# v2.4.0-alpha. The "publishable" key is documented in-source as a public
-# client identifier (Supabase anon-key pattern); embedding it in our client
-# is by-design and not a secret leak.
-ORCA_AUTH_BASE = "https://auth.orcaslicer.com"
-ORCA_API_BASE = "https://api.orcaslicer.com"
-ORCA_ANON_KEY = "sb_publishable_lvVe_whOi80SU9BPSxM1kA_tbt9AbR_"
-
-# Loopback redirect from OrcaCloudServiceAgent.cpp. Supabase's redirect_to
-# allowlist on Orca's project only honors localhost URIs — anything else
-# silently falls through to the project Site URL after the OAuth dance.
-ORCA_REDIRECT_URI = "http://localhost:41172/callback"
-
-# Honest client identity. Same posture as Bambu Cloud: identifies Bambuddy
-# without impersonating Orca's desktop client (which would be CWE-style
-# falsified-identity and was the exact thing called out in Bambu Lab's May 2026
-# blog post about cloud-access etiquette).
+# ---------------------------------------------------------------------------
+# Endpoints + client identity (env-overridable so staging can be targeted
+# without a code change). Defaults point at production.
+# ---------------------------------------------------------------------------
+
+_DEFAULT_API_BASE = "https://api.orcaslicer.com"
+
+# Base for both the OAuth endpoints (/oauth/*) and the external API
+# (/api/v1/external/*). Override with ORCA_CLOUD_API_BASE to point at
+# staging (https://staging-api.orcaslicer.com) during testing.
+ORCA_API_BASE = os.environ.get("ORCA_CLOUD_API_BASE", _DEFAULT_API_BASE).rstrip("/")
+
+# Public client id registered with the Orca Cloud team (see the External App
+# Pairing developer guide). Not a secret — it appears in browser-visible
+# requests — but it must accompany every /oauth/device/code and /oauth/token
+# call (incl. refreshes) or the server returns ``invalid_client``. Overridable
+# only for the (unlikely) case of a separate staging registration.
+ORCA_CLIENT_ID = os.environ.get("ORCA_CLOUD_CLIENT_ID", "oc_app_e873d49ce7dbcc7dca8ba386")
+
+# Scope requested at pairing time. Bambuddy currently only READS the user's
+# Orca Cloud profiles (list + view), so we request the minimum — read-only.
+# ``sync:read`` grants pull + versions; bump to ``sync:write`` here if/when a
+# push-to-cloud feature lands (which forces existing users to re-pair, since
+# the granted scope is baked into the issued token).
+ORCA_SCOPE = os.environ.get("ORCA_CLOUD_SCOPE", "sync:read")
+
+# Honest client identity. Same posture as the Bambu Cloud client: identifies
+# Bambuddy without impersonating Orca's desktop client. Also the thing that
+# clears Cloudflare's User-Agent gate in front of the API.
 _USER_AGENT = "Bambuddy/1.0 (+https://github.com/maziggy/bambuddy)"
 
-# Refresh access tokens when they have less than this much life left, on the
-# theory that a slow downstream API call shouldn't expire the token mid-flight.
+# Refresh the access token when it has less than this much life left, so a
+# slow downstream API call doesn't expire the token mid-flight.
 _REFRESH_LEEWAY = timedelta(minutes=5)
 
-# PKCE handshake state TTL. If the user clicks "Connect" then walks away,
-# the stored verifier+state is invalid after this window — they have to
-# restart. 10 minutes is the OAuth norm for desktop-app PKCE flows.
-PENDING_PKCE_TTL = timedelta(minutes=10)
+# How long a device-code pairing attempt stays valid before the user must
+# restart. The server also enforces this (``expires_in`` on the device-code
+# response is 600s); we mirror it client-side so we stop polling a dead code.
+DEVICE_CODE_TTL = timedelta(minutes=10)
+
+
+# ---------------------------------------------------------------------------
+# Device-poll outcomes
+# ---------------------------------------------------------------------------
+
+
+class DevicePoll:
+    """String outcomes of one :meth:`OrcaCloudService.poll_token` attempt.
+
+    ``PENDING`` / ``SLOW_DOWN`` are non-terminal (keep polling; on SLOW_DOWN
+    widen the interval). ``DENIED`` / ``EXPIRED`` are terminal — the pairing
+    attempt is dead and the user must restart. ``COMPLETE`` means tokens were
+    issued and applied to the service."""
+
+    PENDING = "authorization_pending"
+    SLOW_DOWN = "slow_down"
+    DENIED = "access_denied"
+    EXPIRED = "expired_token"
+    COMPLETE = "complete"
+
+    #: Non-terminal — the frontend should poll again.
+    ONGOING = frozenset({PENDING, SLOW_DOWN})
+    #: Terminal failure — the frontend should restart the flow.
+    TERMINAL = frozenset({DENIED, EXPIRED})
 
 
 class OrcaCloudError(Exception):
-    """Base exception for Orca Cloud errors."""
+    """Base exception for Orca Cloud errors (network / unexpected server)."""
 
     pass
 
 
 class OrcaCloudAuthError(OrcaCloudError):
-    """Authentication / token-related errors. Caller should typically prompt
-    the user to reconnect — neither a fresh access token nor a refresh will
-    recover without re-authentication."""
+    """Authentication / token-related errors. The caller should typically
+    prompt the user to reconnect — neither a fresh access token nor a refresh
+    will recover without re-pairing."""
 
     pass
 
@@ -89,89 +135,18 @@ def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
     _shared_http_client = client
 
 
-# ---------------------------------------------------------------------------
-# PKCE helpers (free functions — no service-instance state needed)
-# ---------------------------------------------------------------------------
-
-
-def _b64url(data: bytes) -> str:
-    """RFC 7636-style base64url encoding, no padding."""
-    return base64.urlsafe_b64encode(data).decode().rstrip("=")
-
-
-def generate_pkce() -> tuple[str, str, str]:
-    """Generate a fresh ``(verifier, challenge, state)`` triple for one PKCE
-    handshake. The verifier is the secret kept by Bambuddy until the code
-    exchange; the challenge is sent to Supabase as ``code_challenge``; the
-    state is the CSRF nonce we'll verify against the callback.
-
-    Verifier = 32 random bytes (43 base64url chars), within RFC 7636's
-    43-128 char range. Challenge = ``base64url(sha256(verifier))``.
-    """
-    verifier = _b64url(secrets.token_bytes(32))
-    challenge = _b64url(hashlib.sha256(verifier.encode()).digest())
-    state = _b64url(secrets.token_bytes(16))
-    return verifier, challenge, state
-
-
-def build_authorize_url(challenge: str, provider: str = "google") -> str:
-    """Construct the URL the user's browser should visit to start the OAuth
-    handshake.
-
-    Notably **does not** pass a ``state`` query parameter. Supabase's GoTrue
-    uses its own internal state encoding to remember which ``redirect_to``
-    belongs to which OAuth session; a client-passed ``state`` overwrites
-    that, GoTrue can no longer decode the redirect_to from Google's
-    callback, and silently falls back to the project Site URL — which is
-    exactly the bug that broke the live test against our deployed integration.
-
-    CSRF is still protected by the PKCE flow itself: the server-side
-    ``code_verifier`` is single-use and bound to the user's session, so an
-    attacker with a code-only URL can't complete the exchange.
-    """
-    from urllib.parse import urlencode
-
-    qs = urlencode(
-        {
-            "provider": provider,
-            "redirect_to": ORCA_REDIRECT_URI,
-            "code_challenge": challenge,
-            "code_challenge_method": "S256",
-        }
-    )
-    return f"{ORCA_AUTH_BASE}/auth/v1/authorize?{qs}"
-
-
-def parse_callback_url(callback_url: str) -> tuple[str | None, str | None]:
-    """Extract ``(code, state)`` from a pasted callback URL. Both query string
-    and fragment are checked — some Supabase configurations put PKCE codes in
-    the fragment rather than the query string. Returns ``(None, None)`` if
-    nothing parses out; the route layer surfaces the user-facing error."""
-    from urllib.parse import parse_qs, urlparse
-
-    parsed = urlparse(callback_url.strip())
-    qsd = parse_qs(parsed.query)
-    code = qsd.get("code", [""])[0] or None
-    state = qsd.get("state", [""])[0] or None
-    if not code:
-        frag = parse_qs(parsed.fragment)
-        code = frag.get("code", [""])[0] or None
-        state = state or (frag.get("state", [""])[0] or None)
-    return code, state
-
-
 # ---------------------------------------------------------------------------
 # Service class
 # ---------------------------------------------------------------------------
 
 
 class OrcaCloudService:
-    """Stateful per-request client for the Orca Cloud API.
+    """Stateful per-request client for the Orca Cloud external API.
 
     Instantiated by the route layer, populated with a stored token via
     :meth:`set_tokens`, then used to call the sync endpoints. Token rotation
-    on refresh is the route layer's responsibility (see
-    :meth:`refresh` — returns the new pair, doesn't persist).
+    on refresh is the route layer's responsibility (see :meth:`refresh` —
+    mutates ``self`` and returns the new pair, but does NOT persist).
     """
 
     def __init__(self, client: httpx.AsyncClient | None = None):
@@ -227,136 +202,124 @@ class OrcaCloudService:
         self.refresh_token = None
         self.token_expiry = None
 
-    def _auth_headers(self) -> dict[str, str]:
-        """Headers for calls to ``auth.orcaslicer.com``. Always includes the
-        apikey; the ``Authorization`` header is added only if we already have
-        an access token (used by ``/logout``, not by token exchange)."""
-        headers = {
-            "User-Agent": _USER_AGENT,
-            "apikey": ORCA_ANON_KEY,
-            "Content-Type": "application/json",
-        }
-        if self.access_token:
-            headers["Authorization"] = f"Bearer {self.access_token}"
-        return headers
-
     def _api_headers(self) -> dict[str, str]:
-        """Headers for calls to ``api.orcaslicer.com``. Requires a bearer
-        token — callers should ensure the service is authenticated first."""
+        """Headers for calls to the external API. Requires a bearer token —
+        callers should ensure the service is authenticated first."""
         if not self.access_token:
             raise OrcaCloudAuthError("Orca Cloud API requires an access token")
         return {
             "User-Agent": _USER_AGENT,
-            "apikey": ORCA_ANON_KEY,
             "Authorization": f"Bearer {self.access_token}",
             "Accept": "application/json",
         }
 
     # ------------------------------------------------------------------
-    # Token lifecycle
+    # Device authorization grant (RFC 8628)
     # ------------------------------------------------------------------
 
-    async def password_login(self, email: str, password: str) -> dict[str, Any]:
-        """Direct email+password login via ``/auth/v1/token?grant_type=password``.
-
-        Whether this works depends on the Supabase project's auth config —
-        Orca's web sign-in offers email/password as one option, but their
-        desktop client refuses ``{username, password}`` payloads with
-        ``"Username/password login is disabled. Use the Orca cloud PKCE
-        flow."`` (the SDK enforces PKCE regardless of what the backend
-        allows). The actual server behaviour is what matters for Bambuddy
-        — we POST the credentials and surface whatever response we get;
-        an ``OrcaCloudAuthError`` with the verbatim Supabase error message
-        is the right signal for callers to fall back to an OAuth provider.
-        """
-        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=password"
-        payload = {"email": email, "password": password}
+    async def request_device_code(
+        self,
+        scope: str = ORCA_SCOPE,
+        instance_url: str | None = None,
+        instance_label: str | None = None,
+    ) -> dict[str, Any]:
+        """Start a pairing attempt. Returns the raw device-code response
+        (``device_code``, ``user_code``, ``verification_uri``,
+        ``verification_uri_complete``, ``expires_in``, ``interval``).
+
+        ``instance_url`` / ``instance_label`` are display-only fields shown on
+        the user's approval card (anti-phishing context). The ``device_code``
+        is a secret the caller must keep server-side; only ``user_code`` and
+        the verification URIs are safe to show the user."""
+        url = f"{ORCA_API_BASE}/oauth/device/code"
+        form: dict[str, str] = {"client_id": ORCA_CLIENT_ID, "scope": scope}
+        if instance_url:
+            form["instance_url"] = instance_url
+        if instance_label:
+            form["instance_label"] = instance_label
         try:
-            resp = await self._client.post(
-                url,
-                json=payload,
-                headers={
-                    "User-Agent": _USER_AGENT,
-                    "apikey": ORCA_ANON_KEY,
-                    "Content-Type": "application/json",
-                },
-            )
+            resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
         except httpx.HTTPError as e:
-            raise OrcaCloudError(f"Network error during Orca Cloud password login: {e}") from e
+            raise OrcaCloudError(f"Network error requesting Orca Cloud device code: {e}") from e
 
         if resp.status_code >= 400:
             detail = _describe_token_error(resp)
-            if resp.status_code in (400, 401, 403, 422):
-                raise OrcaCloudAuthError(f"Orca Cloud password login rejected: {detail}")
-            raise OrcaCloudError(f"Orca Cloud password login failed ({resp.status_code}): {detail}")
-
-        data = resp.json()
-        self._apply_token_response(data)
-        return data
+            # invalid_client means our client_id is wrong / unregistered — an
+            # operator misconfiguration, not something the user can fix.
+            if resp.status_code in (400, 401, 403):
+                raise OrcaCloudAuthError(f"Orca Cloud rejected the device-code request: {detail}")
+            raise OrcaCloudError(f"Orca Cloud device-code request failed ({resp.status_code}): {detail}")
+        return resp.json()
 
-    async def exchange_code(self, auth_code: str, code_verifier: str) -> dict[str, Any]:
-        """Exchange a PKCE auth code for tokens. Mutates ``self`` so the
-        service is ready for API calls. Returns the raw Supabase token
-        response so the route layer can persist the new credentials."""
-        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=pkce"
-        payload = {"auth_code": auth_code, "code_verifier": code_verifier}
+    async def poll_token(self, device_code: str) -> tuple[str, dict[str, Any] | None]:
+        """Poll the token endpoint once for a pending device-code grant.
+
+        Returns ``(status, data)`` where ``status`` is a :class:`DevicePoll`
+        value. On :data:`DevicePoll.COMPLETE` the service is mutated with the
+        new tokens and ``data`` is the raw token response (so the caller can
+        persist it); otherwise ``data`` is ``None``.
+
+        Raises :class:`OrcaCloudError` only for genuinely unexpected responses
+        (5xx, network, or an unrecognized error code) — the four RFC error
+        codes are returned as statuses, not raised, because they're normal
+        control flow for a polling loop."""
+        url = f"{ORCA_API_BASE}/oauth/token"
+        form = {
+            "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
+            "device_code": device_code,
+            "client_id": ORCA_CLIENT_ID,
+        }
         try:
-            resp = await self._client.post(
-                url,
-                json=payload,
-                headers={
-                    "User-Agent": _USER_AGENT,
-                    "apikey": ORCA_ANON_KEY,
-                    "Content-Type": "application/json",
-                },
-            )
+            resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
         except httpx.HTTPError as e:
-            raise OrcaCloudError(f"Network error during Orca Cloud token exchange: {e}") from e
-
-        if resp.status_code >= 400:
-            # Supabase returns ``{"error":"...", "error_description":"..."}``
-            # on most failures and ``{"msg":"..."}`` on a few. Surface
-            # whatever we can find.
-            detail = _describe_token_error(resp)
-            if resp.status_code in (400, 401, 403):
-                raise OrcaCloudAuthError(f"Orca Cloud token exchange rejected: {detail}")
-            raise OrcaCloudError(f"Orca Cloud token exchange failed ({resp.status_code}): {detail}")
-
-        data = resp.json()
-        self._apply_token_response(data)
-        return data
+            raise OrcaCloudError(f"Network error polling Orca Cloud token endpoint: {e}") from e
+
+        if resp.status_code < 400:
+            data = resp.json()
+            self._apply_token_response(data)
+            return DevicePoll.COMPLETE, data
+
+        # RFC 8628 error bodies: {"error": "authorization_pending" | ...}.
+        error = _error_code(resp)
+        if error == "authorization_pending":
+            return DevicePoll.PENDING, None
+        if error == "slow_down":
+            return DevicePoll.SLOW_DOWN, None
+        if error == "access_denied":
+            return DevicePoll.DENIED, None
+        # expired_token and invalid_grant both mean "this device code is dead,
+        # start over" — collapse them to a single terminal EXPIRED status.
+        if error in ("expired_token", "invalid_grant"):
+            return DevicePoll.EXPIRED, None
+        raise OrcaCloudError(f"Orca Cloud token poll failed ({resp.status_code}): {_describe_token_error(resp)}")
 
     async def refresh(self) -> dict[str, Any]:
         """Use the stored refresh token to obtain a fresh access/refresh pair.
 
-        Supabase issues single-use refresh tokens — the old refresh token is
-        invalidated the moment this call succeeds. The caller MUST persist the
-        new pair atomically with consuming the old one; otherwise a crash
-        between this return and the DB write strands the user. Returns the
-        raw token-response dict so the caller has the full new pair.
-        """
+        Refresh tokens are single-use — the old one is consumed the moment
+        this succeeds. The caller MUST persist the new pair atomically; a
+        crash between this return and the DB write strands the user (though
+        Orca's ~60s grace window means a *replay* of the old token within that
+        window still yields a working pair rather than revoking). Returns the
+        raw token-response dict so the caller has the full new pair."""
         if not self.refresh_token:
             raise OrcaCloudAuthError("Cannot refresh: no refresh token stored")
 
-        url = f"{ORCA_AUTH_BASE}/auth/v1/token?grant_type=refresh_token"
-        payload = {"refresh_token": self.refresh_token}
+        url = f"{ORCA_API_BASE}/oauth/token"
+        form = {
+            "grant_type": "refresh_token",
+            "refresh_token": self.refresh_token,
+            "client_id": ORCA_CLIENT_ID,
+        }
         try:
-            resp = await self._client.post(
-                url,
-                json=payload,
-                headers={
-                    "User-Agent": _USER_AGENT,
-                    "apikey": ORCA_ANON_KEY,
-                    "Content-Type": "application/json",
-                },
-            )
+            resp = await self._client.post(url, data=form, headers={"User-Agent": _USER_AGENT})
         except httpx.HTTPError as e:
             raise OrcaCloudError(f"Network error during Orca Cloud refresh: {e}") from e
 
         if resp.status_code >= 400:
             detail = _describe_token_error(resp)
-            # 400/401 typically means "refresh token rotated or revoked" —
-            # the user has to reconnect. Don't try to recover here.
+            # 400 invalid_grant on refresh = expired / already-used / the user
+            # disconnected us. Unrecoverable — clear and force a re-pair.
             if resp.status_code in (400, 401, 403):
                 self.clear_tokens()
                 raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}")
@@ -368,17 +331,17 @@ class OrcaCloudService:
 
     def _apply_token_response(self, data: dict[str, Any]) -> None:
         """Update ``self.access_token`` / ``self.refresh_token`` /
-        ``self.token_expiry`` from a Supabase token-response payload. Caller
-        is still responsible for persisting the values to the DB."""
+        ``self.token_expiry`` from a token-response payload. Caller is still
+        responsible for persisting the values to the DB."""
         access = data.get("access_token")
         refresh = data.get("refresh_token")
         expires_in = data.get("expires_in")
         if not access:
             raise OrcaCloudAuthError("Orca Cloud token response missing access_token")
         self.access_token = access
-        # Supabase always rotates refresh tokens on /token calls; if the
-        # response omits one we keep the previous value to avoid stranding
-        # the session, but that shouldn't happen in practice.
+        # The token endpoint always rotates the refresh token; if a response
+        # omits one we keep the previous value to avoid stranding the session,
+        # but that shouldn't happen in practice.
         if refresh:
             self.refresh_token = refresh
         if isinstance(expires_in, (int, float)) and expires_in > 0:
@@ -387,64 +350,46 @@ class OrcaCloudService:
             self.token_expiry = None
 
     # ------------------------------------------------------------------
-    # Sync API
+    # External API
     # ------------------------------------------------------------------
 
-    async def get_user_info(self) -> dict[str, Any]:
-        """Return Supabase's user record for the current token (id, email,
-        metadata, ...). Used after token exchange to record the user's email
-        for display in Bambuddy's UI."""
-        url = f"{ORCA_AUTH_BASE}/auth/v1/user"
+    async def introspect(self) -> dict[str, Any]:
+        """Return the pairing's introspection record (``user_id``,
+        ``client_id``, ``connection_id``, ``scope``, ``expires_at``). Used
+        after pairing to record the user's id for display in Bambuddy's UI."""
+        url = f"{ORCA_API_BASE}/api/v1/external-apps/me"
         try:
-            resp = await self._client.get(url, headers=self._auth_headers())
+            resp = await self._client.get(url, headers=self._api_headers())
         except httpx.HTTPError as e:
-            raise OrcaCloudError(f"Network error fetching Orca Cloud user info: {e}") from e
+            raise OrcaCloudError(f"Network error fetching Orca Cloud introspection: {e}") from e
         if resp.status_code == 401:
-            raise OrcaCloudAuthError("Orca Cloud user fetch unauthorized — token expired or revoked")
+            raise OrcaCloudAuthError("Orca Cloud introspection unauthorized — token expired or revoked")
         if resp.status_code >= 400:
-            raise OrcaCloudError(f"Orca Cloud user fetch failed ({resp.status_code}): {resp.text[:200]}")
+            raise OrcaCloudError(f"Orca Cloud introspection failed ({resp.status_code}): {resp.text[:200]}")
         return resp.json()
 
     async def list_profiles(self) -> list[dict[str, Any]]:
-        """Return the user's Orca Cloud profiles as a flat list of
-        ``ProfileUpsert`` entries (``{id, name, content, updated_time,
-        created_time}``) — forwarded verbatim; callers pick the fields they
-        need.
-
-        Uses ``GET /api/v1/sync/pull`` with NO ``?cursor=`` parameter, which
-        is the same "first-sync bootstrap" path OrcaSlicer's own client
-        uses (``OrcaCloudServiceAgent.cpp::sync_pull``):
-
-            std::string path = ORCA_SYNC_PULL_PATH;
-            if (sync_state.last_sync_timestamp != 0) {
-                path += "?cursor=" + std::to_string(sync_state.last_sync_timestamp);
-            }
-            ...
-            // Handle 410 Gone — cursor too old, need full resync
-            if (http_code == 410) {
-                clear_sync_state();
-                path = ORCA_SYNC_PULL_PATH;  // retry without cursor
-                ...
-            }
-
-        Sending ``cursor=0`` explicitly trips ``410 cursor_too_old`` — the
-        server-side sync log doesn't reach back to the Unix epoch. Omitting
-        the parameter entirely is the documented "give me the full snapshot"
-        semantic. The previously-attempted ``/api/v1/sync/profiles`` is
-        declared as a constant in Orca's source but isn't deployed on the
-        production cloud (returns 404).
-
-        The pull response is a ``SyncPullResponse`` (``{next_cursor, upserts,
-        deletes}``); we extract ``upserts`` and ignore ``deletes`` (no prior
-        state on the client side to invalidate).
-        """
-        url = f"{ORCA_API_BASE}/api/v1/sync/pull"
+        """Return the user's Orca Cloud profiles as a flat list of profile
+        entries (``{id, name, content, updated_time, created_time}``) —
+        forwarded verbatim; callers pick the fields they need.
+
+        Uses ``GET /api/v1/external/sync/pull`` with NO ``?cursor=`` parameter,
+        the documented "full snapshot" bootstrap. Sending ``cursor=0`` instead
+        trips ``410 cursor_too_old`` (the sync log doesn't reach back to the
+        Unix epoch). The pull response is ``{next_cursor, upserts, deletes}``;
+        we return ``upserts`` and ignore the rest (no prior client state to
+        invalidate on a read-only list)."""
+        url = f"{ORCA_API_BASE}/api/v1/external/sync/pull"
         try:
             resp = await self._client.get(url, headers=self._api_headers())
         except httpx.HTTPError as e:
             raise OrcaCloudError(f"Network error listing Orca Cloud profiles: {e}") from e
         if resp.status_code == 401:
             raise OrcaCloudAuthError("Orca Cloud profile list unauthorized — token expired or revoked")
+        if resp.status_code == 410:
+            # cursor_too_old on a no-cursor request would be surprising, but
+            # surface it clearly rather than as an opaque 502.
+            raise OrcaCloudError("Orca Cloud sync cursor too old — a full resync is required")
         if resp.status_code >= 400:
             raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}")
         data = resp.json()
@@ -452,24 +397,21 @@ class OrcaCloudService:
             upserts = data.get("upserts")
             if isinstance(upserts, list):
                 return upserts
-            # Tolerate the shape we'd see if Orca ever rolls out a flat-list
-            # endpoint at this path — forward whatever array is on the dict.
+            # Tolerate a flat-list shape if Orca ever rolls one out here.
             for key in ("profiles", "data"):
                 value = data.get(key)
                 if isinstance(value, list):
                     return value
         if isinstance(data, list):
             return data
-        logger.warning("Orca Cloud /sync/pull returned unexpected shape: %r", type(data).__name__)
+        logger.warning("Orca Cloud /external/sync/pull returned unexpected shape: %r", type(data).__name__)
         return []
 
     async def get_profile(self, profile_id: str) -> dict[str, Any]:
-        """Fetch a single profile's full content. Orca's sync API doesn't
-        expose a per-profile GET, so we list and filter. For small profile
-        counts (the realistic case) this is fine; if it becomes a hot path
-        we'll add client-side caching at the route layer rather than hammer
-        the list endpoint.
-        """
+        """Fetch a single profile's full content. The external sync API has no
+        per-profile GET, so we list and filter. For the realistic profile
+        counts this is fine; if it becomes a hot path we'll add caching at the
+        route layer rather than hammer the pull endpoint."""
         profiles = await self.list_profiles()
         for profile in profiles:
             if str(profile.get("id")) == str(profile_id):
@@ -487,10 +429,24 @@ class OrcaCloudService:
             await self._client.aclose()
 
 
+def _error_code(resp: httpx.Response) -> str | None:
+    """Extract the RFC-style ``error`` code from a token-endpoint error body,
+    or ``None`` if the body doesn't parse as ``{"error": "..."}``."""
+    try:
+        data = resp.json()
+    except (json.JSONDecodeError, ValueError):
+        return None
+    if isinstance(data, dict):
+        err = data.get("error")
+        if isinstance(err, str) and err:
+            return err
+    return None
+
+
 def _describe_token_error(resp: httpx.Response) -> str:
-    """Best-effort extraction of a user-facing message from a Supabase token
-    endpoint error response. Tries JSON fields in order; falls back to the
-    raw body (truncated) if nothing parses."""
+    """Best-effort extraction of a user-facing message from a token-endpoint
+    error response. Tries JSON fields in order; falls back to the raw body
+    (truncated) if nothing parses."""
     try:
         data = resp.json()
     except (json.JSONDecodeError, ValueError):

+ 176 - 0
backend/tests/integration/test_orca_cloud_device.py

@@ -0,0 +1,176 @@
+"""Integration tests for the Orca Cloud device-pairing routes.
+
+Covers the /device/start -> /device/poll pairing loop, its terminal outcomes,
+token persistence, and status/logout — all in auth-disabled mode (the global
+Settings-table fallback), with the service's network calls patched out.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services import orca_cloud as orca_service
+from backend.app.services.orca_cloud import DevicePoll, OrcaCloudService
+
+AUTH_DISABLED = "backend.app.core.auth.is_auth_enabled"
+
+
+@pytest.fixture(autouse=True)
+def _dummy_shared_client():
+    """Register a throwaway shared HTTP client so per-request
+    OrcaCloudService() instances don't spin up (and leak) a real one — the
+    network methods are patched anyway."""
+    from unittest.mock import MagicMock
+
+    orca_service.set_shared_http_client(MagicMock())
+    yield
+    orca_service.set_shared_http_client(None)
+
+
+_DEVICE_CODE_RESPONSE = {
+    "device_code": "DEV-SECRET-1",
+    "user_code": "ABCD-EF12",
+    "verification_uri": "https://cloud.orcaslicer.com/app/settings",
+    "verification_uri_complete": "https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12",
+    "expires_in": 600,
+    "interval": 5,
+}
+
+
+async def _start(async_client: AsyncClient):
+    with (
+        patch(AUTH_DISABLED, return_value=False),
+        patch.object(OrcaCloudService, "request_device_code", return_value=dict(_DEVICE_CODE_RESPONSE)),
+    ):
+        return await async_client.post("/api/v1/orca-cloud/device/start")
+
+
+class TestDeviceStart:
+    @pytest.mark.asyncio
+    async def test_start_returns_user_code_and_hides_device_code(self, async_client: AsyncClient):
+        resp = await _start(async_client)
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["user_code"] == "ABCD-EF12"
+        assert body["interval"] == 5
+        assert body["verification_uri_complete"].endswith("user_code=ABCD-EF12")
+        # The device_code is a secret and must NOT be echoed to the client.
+        assert "device_code" not in body
+
+
+class TestDevicePoll:
+    @pytest.mark.asyncio
+    async def test_poll_without_pending_is_400(self, async_client: AsyncClient):
+        with patch(AUTH_DISABLED, return_value=False):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    async def test_poll_pending_reports_in_progress(self, async_client: AsyncClient):
+        await _start(async_client)
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch.object(OrcaCloudService, "poll_token", return_value=(DevicePoll.PENDING, None)),
+        ):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["status"] == DevicePoll.PENDING
+        assert body["connected"] is False
+
+    @pytest.mark.asyncio
+    async def test_poll_complete_persists_tokens_and_connects(self, async_client: AsyncClient):
+        await _start(async_client)
+
+        async def fake_complete(self, device_code):
+            assert device_code == "DEV-SECRET-1"  # the stored secret is used
+            self.access_token = "oc_ext_new"
+            self.refresh_token = "oc_ext_rt_new"
+            self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=86400)
+            return DevicePoll.COMPLETE, {"access_token": "oc_ext_new"}
+
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch.object(OrcaCloudService, "poll_token", new=fake_complete),
+            patch.object(OrcaCloudService, "introspect", return_value={"user_id": "user-123"}),
+        ):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+            assert resp.status_code == 200
+            body = resp.json()
+            assert body["status"] == DevicePoll.COMPLETE
+            assert body["connected"] is True
+            assert body["user_id"] == "user-123"
+
+            # Status now reflects the connection, and the pending state is
+            # cleared (a fresh poll finds nothing pending -> 400).
+            status = await async_client.get("/api/v1/orca-cloud/status")
+            assert status.json()["connected"] is True
+            again = await async_client.post("/api/v1/orca-cloud/device/poll")
+            assert again.status_code == 400
+
+    @pytest.mark.asyncio
+    async def test_poll_denied_clears_pending(self, async_client: AsyncClient):
+        await _start(async_client)
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch.object(OrcaCloudService, "poll_token", return_value=(DevicePoll.DENIED, None)),
+        ):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert resp.json()["status"] == DevicePoll.DENIED
+        # Pending cleared -> next poll has nothing to poll.
+        with patch(AUTH_DISABLED, return_value=False):
+            again = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert again.status_code == 400
+
+    @pytest.mark.asyncio
+    async def test_poll_expires_by_ttl_without_network(self, async_client: AsyncClient):
+        """A pending code older than DEVICE_CODE_TTL is reported expired
+        without even calling the token endpoint. Shrinking the TTL to a
+        negative window makes any just-created pending state 'stale'."""
+        await _start(async_client)
+
+        # poll_token must NOT be called; if it were, this would blow up.
+        def _boom(*a, **k):
+            raise AssertionError("poll_token should not be called for an expired code")
+
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch("backend.app.api.routes.orca_cloud.DEVICE_CODE_TTL", timedelta(seconds=-1)),
+            patch.object(OrcaCloudService, "poll_token", new=_boom),
+        ):
+            resp = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert resp.status_code == 200
+        assert resp.json()["status"] == DevicePoll.EXPIRED
+        # And the expired pending state is cleared.
+        with patch(AUTH_DISABLED, return_value=False):
+            again = await async_client.post("/api/v1/orca-cloud/device/poll")
+        assert again.status_code == 400
+
+
+class TestLogout:
+    @pytest.mark.asyncio
+    async def test_logout_clears_connection(self, async_client: AsyncClient):
+        await _start(async_client)
+
+        async def fake_complete(self, device_code):
+            self.access_token = "oc_ext_new"
+            self.refresh_token = "oc_ext_rt_new"
+            self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=86400)
+            return DevicePoll.COMPLETE, {"access_token": "oc_ext_new"}
+
+        with (
+            patch(AUTH_DISABLED, return_value=False),
+            patch.object(OrcaCloudService, "poll_token", new=fake_complete),
+            patch.object(OrcaCloudService, "introspect", return_value={"user_id": "u"}),
+        ):
+            await async_client.post("/api/v1/orca-cloud/device/poll")
+
+        with patch(AUTH_DISABLED, return_value=False):
+            out = await async_client.post("/api/v1/orca-cloud/logout")
+            assert out.status_code == 200
+            status = await async_client.get("/api/v1/orca-cloud/status")
+            assert status.json()["connected"] is False

+ 236 - 255
backend/tests/unit/services/test_orca_cloud.py

@@ -1,130 +1,29 @@
-"""Tests for the Orca Cloud service — PKCE generation, authorize URL shape,
-token exchange / refresh round-trip, single-use refresh token rotation,
-and Cloudflare-cleaning User-Agent header."""
+"""Tests for the Orca Cloud device-pairing service — device-code request,
+token poll (the four RFC 8628 outcomes + success), single-use refresh
+rotation, external-API headers (bearer, no apikey), and profile pull."""
 
 from __future__ import annotations
 
-import base64
-import hashlib
 import json
 from datetime import datetime, timedelta, timezone
-from unittest.mock import AsyncMock, MagicMock, patch
-from urllib.parse import parse_qs, urlparse
+from unittest.mock import AsyncMock, MagicMock
 
 import httpx
 import pytest
 
-from backend.app.services import orca_cloud
 from backend.app.services.orca_cloud import (
-    ORCA_ANON_KEY,
-    ORCA_AUTH_BASE,
-    ORCA_REDIRECT_URI,
+    ORCA_CLIENT_ID,
+    DevicePoll,
     OrcaCloudAuthError,
     OrcaCloudError,
     OrcaCloudService,
-    build_authorize_url,
-    generate_pkce,
-    parse_callback_url,
 )
 
-# ---------------------------------------------------------------------------
-# PKCE primitives
-# ---------------------------------------------------------------------------
-
-
-class TestPkce:
-    def test_challenge_is_sha256_of_verifier(self):
-        """The challenge must be base64url(sha256(verifier)) — this is the
-        RFC 7636 invariant Supabase will check on the exchange step. A bug
-        here means the exchange always fails with code_verifier mismatch."""
-        verifier, challenge, _state = generate_pkce()
-        expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
-        assert challenge == expected
-
-    def test_verifier_length_in_rfc_range(self):
-        verifier, _challenge, _state = generate_pkce()
-        # 32 random bytes -> 43 chars after base64url-no-pad; RFC 7636
-        # requires 43-128.
-        assert 43 <= len(verifier) <= 128
-
-    def test_state_is_unique_per_call(self):
-        """Two consecutive calls must not share state — otherwise a stolen
-        state from one flow could be replayed against another in-flight one."""
-        _, _, s1 = generate_pkce()
-        _, _, s2 = generate_pkce()
-        assert s1 != s2
-
-    def test_characters_are_url_safe(self):
-        """Both verifier and challenge must be URL-safe base64 (no padding,
-        no + or /) so they can be sent as query-string values without
-        re-encoding."""
-        verifier, challenge, state = generate_pkce()
-        for value in (verifier, challenge, state):
-            assert all(c.isalnum() or c in ("-", "_") for c in value), value
-
-
-class TestAuthorizeUrl:
-    def test_url_targets_authorize_endpoint(self):
-        url = build_authorize_url("CHALLENGE")
-        assert url.startswith(f"{ORCA_AUTH_BASE}/auth/v1/authorize?")
-
-    def test_url_contains_required_pkce_params(self):
-        """The four PKCE params Supabase needs at authorize time. Missing any
-        of these = Supabase 400s the request before redirecting to Google."""
-        url = build_authorize_url("CHALLENGE")
-        params = parse_qs(urlparse(url).query)
-        assert params["provider"] == ["google"]
-        assert params["redirect_to"] == [ORCA_REDIRECT_URI]
-        assert params["code_challenge"] == ["CHALLENGE"]
-        assert params["code_challenge_method"] == ["S256"]
-
-    def test_url_does_not_pass_state(self):
-        """Regression guard against re-introducing the bug we hit in the
-        first deployed integration: passing ``state`` to GoTrue's authorize
-        endpoint silently overrides its internal redirect_to tracking, so
-        the user lands at the project Site URL instead of our localhost
-        callback. CSRF is protected by PKCE alone — verifier is server-side
-        and single-use."""
-        url = build_authorize_url("CHALLENGE")
-        params = parse_qs(urlparse(url).query)
-        assert "state" not in params
-
-
-class TestParseCallback:
-    def test_extracts_code_and_state_from_query(self):
-        code, state = parse_callback_url("http://localhost:41172/callback?code=ABC&state=XYZ")
-        assert code == "ABC"
-        assert state == "XYZ"
-
-    def test_falls_back_to_fragment(self):
-        """Some Supabase configurations put PKCE codes in the URL fragment
-        rather than the query (depends on response_mode setting). Both must
-        be handled or some users get a confusing 'no code in URL' error."""
-        code, state = parse_callback_url("http://localhost:41172/callback#code=ABC&state=XYZ")
-        assert code == "ABC"
-        assert state == "XYZ"
-
-    def test_returns_none_when_no_code(self):
-        code, state = parse_callback_url("http://localhost:41172/callback?error=denied")
-        assert code is None
-        assert state is None
-
-    def test_handles_whitespace_padding(self):
-        """Users paste from address bars and sometimes accidentally include
-        a leading/trailing space — the parser must be forgiving."""
-        code, _state = parse_callback_url("  http://localhost:41172/callback?code=ABC&state=XYZ  ")
-        assert code == "ABC"
-
-
-# ---------------------------------------------------------------------------
-# Token exchange + refresh
-# ---------------------------------------------------------------------------
-
 
 def _mock_response(
     *,
     status_code: int = 200,
-    json_data: dict | None = None,
+    json_data: dict | list | None = None,
     text_body: str = "",
 ) -> MagicMock:
     """Build an httpx-like response mock with the only attributes the
@@ -145,132 +44,184 @@ def svc() -> OrcaCloudService:
     return OrcaCloudService(client=MagicMock(spec=httpx.AsyncClient))
 
 
-class TestExchangeCode:
+# ---------------------------------------------------------------------------
+# Device-code request
+# ---------------------------------------------------------------------------
+
+
+class TestRequestDeviceCode:
     @pytest.mark.asyncio
-    async def test_success_populates_tokens_and_expiry(self, svc):
-        token_resp = _mock_response(
+    async def test_success_returns_device_code_payload(self, svc):
+        resp = _mock_response(
             json_data={
-                "access_token": "ACCESS-1",
-                "refresh_token": "REFRESH-1",
-                "expires_in": 3600,
-                "token_type": "bearer",
+                "device_code": "DEV-1",
+                "user_code": "ABCD-EF12",
+                "verification_uri": "https://cloud.orcaslicer.com/app/settings",
+                "verification_uri_complete": "https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12",
+                "expires_in": 600,
+                "interval": 5,
             }
         )
-        svc._client.post = AsyncMock(return_value=token_resp)
+        svc._client.post = AsyncMock(return_value=resp)
 
-        await svc.exchange_code("CODE", "VERIFIER")
+        data = await svc.request_device_code()
 
-        assert svc.access_token == "ACCESS-1"
-        assert svc.refresh_token == "REFRESH-1"
-        assert svc.token_expiry is not None
-        # Expiry should be approximately now + 3600s (within a 60s window).
-        delta = svc.token_expiry - datetime.now(timezone.utc)
-        assert timedelta(seconds=3540) <= delta <= timedelta(seconds=3660)
+        assert data["user_code"] == "ABCD-EF12"
+        assert data["device_code"] == "DEV-1"
 
     @pytest.mark.asyncio
-    async def test_sends_apikey_and_user_agent_headers(self, svc):
-        """Two load-bearing headers: the publishable apikey (Supabase
-        requires it) and a non-default User-Agent (Cloudflare 1010s
-        ``Python-urllib/X.Y`` so an honest ``Bambuddy/<v>`` UA is needed)."""
-        token_resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 3600})
-        svc._client.post = AsyncMock(return_value=token_resp)
+    async def test_sends_client_id_scope_and_user_agent(self, svc):
+        """The device-code request is form-encoded (NOT JSON) with our public
+        client_id and requested scope, and carries the Cloudflare-clearing
+        User-Agent. No ``apikey`` header (that was the old Supabase flow)."""
+        resp = _mock_response(json_data={"device_code": "D", "user_code": "U", "interval": 5, "expires_in": 600})
+        svc._client.post = AsyncMock(return_value=resp)
 
-        await svc.exchange_code("CODE", "VERIFIER")
+        await svc.request_device_code()
 
         _args, kwargs = svc._client.post.call_args
-        headers = kwargs["headers"]
-        assert headers["apikey"] == ORCA_ANON_KEY
-        assert headers["User-Agent"].startswith("Bambuddy/")
-        assert headers["Content-Type"] == "application/json"
+        assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID
+        assert kwargs["data"]["scope"]  # a scope is always sent
+        assert kwargs["headers"]["User-Agent"].startswith("Bambuddy/")
+        assert "apikey" not in kwargs["headers"]
 
     @pytest.mark.asyncio
-    async def test_400_raises_auth_error_not_generic(self, svc):
-        """400 from Supabase usually means a bad verifier or stale code —
-        the user has to restart sign-in. Raising auth-specific exception
-        lets the route map to a sensible 400 with a 'click Connect again'
-        message rather than a generic 502."""
-        err_resp = _mock_response(
-            status_code=400,
-            json_data={"error": "invalid_grant", "error_description": "code expired"},
-        )
-        svc._client.post = AsyncMock(return_value=err_resp)
+    async def test_forwards_instance_fields_when_given(self, svc):
+        resp = _mock_response(json_data={"device_code": "D", "user_code": "U", "interval": 5, "expires_in": 600})
+        svc._client.post = AsyncMock(return_value=resp)
+
+        await svc.request_device_code(instance_url="http://192.168.1.50:8080", instance_label="Garage")
+
+        _args, kwargs = svc._client.post.call_args
+        assert kwargs["data"]["instance_url"] == "http://192.168.1.50:8080"
+        assert kwargs["data"]["instance_label"] == "Garage"
 
-        with pytest.raises(OrcaCloudAuthError) as exc:
-            await svc.exchange_code("CODE", "VERIFIER")
-        assert "code expired" in str(exc.value)
+    @pytest.mark.asyncio
+    async def test_invalid_client_raises_auth_error(self, svc):
+        """A wrong/unregistered client_id returns ``invalid_client`` — an
+        operator misconfiguration surfaced as an auth error so the route can
+        map it distinctly from a transient outage."""
+        resp = _mock_response(status_code=400, json_data={"error": "invalid_client"})
+        svc._client.post = AsyncMock(return_value=resp)
+        with pytest.raises(OrcaCloudAuthError, match="invalid_client"):
+            await svc.request_device_code()
 
     @pytest.mark.asyncio
-    async def test_network_error_wraps_as_orca_error(self, svc):
+    async def test_network_error_wraps(self, svc):
         svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
         with pytest.raises(OrcaCloudError):
-            await svc.exchange_code("CODE", "VERIFIER")
+            await svc.request_device_code()
 
 
-class TestPasswordLogin:
+# ---------------------------------------------------------------------------
+# Token poll (RFC 8628 device_code grant)
+# ---------------------------------------------------------------------------
+
+
+class TestPollToken:
     @pytest.mark.asyncio
-    async def test_success_populates_tokens(self, svc):
+    async def test_success_applies_tokens_and_returns_complete(self, svc):
         resp = _mock_response(
             json_data={
-                "access_token": "PWD-A",
-                "refresh_token": "PWD-R",
-                "expires_in": 3600,
+                "access_token": "oc_ext_A",
+                "refresh_token": "oc_ext_rt_R",
+                "expires_in": 86400,
+                "token_type": "Bearer",
             }
         )
         svc._client.post = AsyncMock(return_value=resp)
 
-        await svc.password_login("user@example.com", "secret")
+        status, data = await svc.poll_token("DEV-1")
 
-        assert svc.access_token == "PWD-A"
-        assert svc.refresh_token == "PWD-R"
+        assert status == DevicePoll.COMPLETE
+        assert data["access_token"] == "oc_ext_A"
+        assert svc.access_token == "oc_ext_A"
+        assert svc.refresh_token == "oc_ext_rt_R"
+        assert svc.token_expiry is not None
 
     @pytest.mark.asyncio
-    async def test_disabled_provider_raises_auth_error_not_generic(self, svc):
-        """Whether Orca's Supabase project accepts password grant is config-
-        dependent. When it doesn't (their desktop SDK refuses passwords by
-        design, the backend may follow suit), the failure mode is a 400 /
-        422 with an error like ``email_provider_disabled``. The caller maps
-        ``OrcaCloudAuthError`` to a 400 with a "use OAuth instead" hint —
-        a 502 would imply Orca is down, which would be wrong UX."""
-        err = _mock_response(
-            status_code=422,
-            json_data={"error": "email_provider_disabled", "error_description": "Email logins are disabled"},
-        )
-        svc._client.post = AsyncMock(return_value=err)
-        with pytest.raises(OrcaCloudAuthError, match="Email logins are disabled"):
-            await svc.password_login("user@example.com", "secret")
+    async def test_sends_device_code_grant_and_client_id(self, svc):
+        resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 86400})
+        svc._client.post = AsyncMock(return_value=resp)
+
+        await svc.poll_token("DEV-1")
+
+        _args, kwargs = svc._client.post.call_args
+        assert kwargs["data"]["grant_type"] == "urn:ietf:params:oauth:grant-type:device_code"
+        assert kwargs["data"]["device_code"] == "DEV-1"
+        assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID
 
     @pytest.mark.asyncio
-    async def test_invalid_credentials_raises_auth_error(self, svc):
-        err = _mock_response(
-            status_code=400,
-            json_data={"error": "invalid_grant", "error_description": "Invalid login credentials"},
-        )
-        svc._client.post = AsyncMock(return_value=err)
-        with pytest.raises(OrcaCloudAuthError, match="Invalid login credentials"):
-            await svc.password_login("user@example.com", "wrong")
+    @pytest.mark.parametrize(
+        "error_code,expected",
+        [
+            ("authorization_pending", DevicePoll.PENDING),
+            ("slow_down", DevicePoll.SLOW_DOWN),
+            ("access_denied", DevicePoll.DENIED),
+            ("expired_token", DevicePoll.EXPIRED),
+            ("invalid_grant", DevicePoll.EXPIRED),  # collapsed to EXPIRED
+        ],
+    )
+    async def test_rfc_error_codes_map_to_statuses(self, svc, error_code, expected):
+        """The four RFC error codes (plus invalid_grant) are normal polling
+        control flow — returned as statuses, never raised."""
+        resp = _mock_response(status_code=400, json_data={"error": error_code})
+        svc._client.post = AsyncMock(return_value=resp)
+
+        status, data = await svc.poll_token("DEV-1")
+
+        assert status == expected
+        assert data is None
+
+    @pytest.mark.asyncio
+    async def test_unknown_error_raises(self, svc):
+        """An unrecognized error body is a real problem, not a poll state —
+        raise so it doesn't silently masquerade as 'still pending' forever."""
+        resp = _mock_response(status_code=400, json_data={"error": "teapot"})
+        svc._client.post = AsyncMock(return_value=resp)
+        with pytest.raises(OrcaCloudError):
+            await svc.poll_token("DEV-1")
+
+    @pytest.mark.asyncio
+    async def test_network_error_wraps(self, svc):
+        svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom"))
+        with pytest.raises(OrcaCloudError):
+            await svc.poll_token("DEV-1")
+
+
+# ---------------------------------------------------------------------------
+# Refresh
+# ---------------------------------------------------------------------------
 
 
 class TestRefresh:
     @pytest.mark.asyncio
     async def test_rotates_refresh_token(self, svc):
-        """Supabase refresh tokens are single-use — every successful refresh
-        returns a NEW refresh token and invalidates the old. If the service
-        kept the old one, the next refresh would 400 and the user would be
-        force-logged-out."""
-        svc.refresh_token = "REFRESH-1"
+        """Refresh tokens are single-use — every successful refresh returns a
+        NEW pair. Keeping the old refresh token would 400 the next refresh."""
+        svc.refresh_token = "oc_ext_rt_1"
         resp = _mock_response(
-            json_data={
-                "access_token": "ACCESS-2",
-                "refresh_token": "REFRESH-2",
-                "expires_in": 3600,
-            }
+            json_data={"access_token": "oc_ext_2", "refresh_token": "oc_ext_rt_2", "expires_in": 86400}
         )
         svc._client.post = AsyncMock(return_value=resp)
 
         await svc.refresh()
 
-        assert svc.access_token == "ACCESS-2"
-        assert svc.refresh_token == "REFRESH-2"
+        assert svc.access_token == "oc_ext_2"
+        assert svc.refresh_token == "oc_ext_rt_2"
+
+    @pytest.mark.asyncio
+    async def test_sends_refresh_grant_and_client_id(self, svc):
+        svc.refresh_token = "oc_ext_rt_1"
+        resp = _mock_response(json_data={"access_token": "A", "refresh_token": "R", "expires_in": 86400})
+        svc._client.post = AsyncMock(return_value=resp)
+
+        await svc.refresh()
+
+        _args, kwargs = svc._client.post.call_args
+        assert kwargs["data"]["grant_type"] == "refresh_token"
+        assert kwargs["data"]["refresh_token"] == "oc_ext_rt_1"
+        assert kwargs["data"]["client_id"] == ORCA_CLIENT_ID
 
     @pytest.mark.asyncio
     async def test_no_refresh_token_raises_auth_error(self, svc):
@@ -280,18 +231,14 @@ class TestRefresh:
 
     @pytest.mark.asyncio
     async def test_rejected_refresh_clears_tokens(self, svc):
-        """If Supabase rejects the refresh token (revoked / rotated out from
-        under us / hit by a token-replay defense), the service must clear
-        the now-useless stored credentials so the UI can flip to the
-        disconnected state rather than retrying forever."""
-        svc.access_token = "OLD-ACCESS"
-        svc.refresh_token = "OLD-REFRESH"
+        """A rejected refresh (revoked / already-used / disconnected) is
+        unrecoverable — clear the stale credentials so the UI flips to
+        disconnected rather than retrying forever."""
+        svc.access_token = "OLD"
+        svc.refresh_token = "oc_ext_rt_old"
         svc.token_expiry = datetime.now(timezone.utc)
-        err = _mock_response(
-            status_code=401,
-            json_data={"error": "invalid_grant", "error_description": "refresh token rotated"},
-        )
-        svc._client.post = AsyncMock(return_value=err)
+        resp = _mock_response(status_code=400, json_data={"error": "invalid_grant"})
+        svc._client.post = AsyncMock(return_value=resp)
 
         with pytest.raises(OrcaCloudAuthError):
             await svc.refresh()
@@ -301,39 +248,46 @@ class TestRefresh:
         assert svc.token_expiry is None
 
 
+# ---------------------------------------------------------------------------
+# is_authenticated
+# ---------------------------------------------------------------------------
+
+
 class TestIsAuthenticated:
     def test_no_token_means_not_authenticated(self, svc):
         assert svc.is_authenticated is False
 
     def test_no_expiry_means_not_authenticated(self, svc):
-        """Pessimistic default: if we don't know when the token expires,
-        treat it as expired so the next API call triggers a refresh
-        rather than fails halfway through."""
-        svc.access_token = "ACCESS"
+        svc.access_token = "A"
         svc.token_expiry = None
         assert svc.is_authenticated is False
 
     def test_within_refresh_leeway_is_not_authenticated(self, svc):
-        """The 5-minute leeway prevents a long-running API call from timing
-        out mid-flight on a token that was technically still valid when the
-        call started."""
-        svc.access_token = "ACCESS"
+        svc.access_token = "A"
         svc.token_expiry = datetime.now(timezone.utc) + timedelta(minutes=2)
         assert svc.is_authenticated is False
 
     def test_with_comfortable_expiry_is_authenticated(self, svc):
-        svc.access_token = "ACCESS"
-        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc.access_token = "A"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
         assert svc.is_authenticated is True
 
 
+# ---------------------------------------------------------------------------
+# External-API headers
+# ---------------------------------------------------------------------------
+
+
 class TestApiHeaders:
-    def test_api_headers_include_apikey_and_bearer(self, svc):
-        svc.access_token = "ACCESS-123"
+    def test_api_headers_include_bearer_and_ua_no_apikey(self, svc):
+        """External API auth is a plain bearer token — the old Supabase
+        ``apikey`` header must NOT be sent (the ``oc_ext_`` token is the whole
+        credential)."""
+        svc.access_token = "oc_ext_123"
         headers = svc._api_headers()
-        assert headers["apikey"] == ORCA_ANON_KEY
-        assert headers["Authorization"] == "Bearer ACCESS-123"
+        assert headers["Authorization"] == "Bearer oc_ext_123"
         assert headers["User-Agent"].startswith("Bambuddy/")
+        assert "apikey" not in headers
 
     def test_api_headers_without_token_raises(self, svc):
         svc.access_token = None
@@ -341,14 +295,41 @@ class TestApiHeaders:
             svc._api_headers()
 
 
+# ---------------------------------------------------------------------------
+# Introspection
+# ---------------------------------------------------------------------------
+
+
+class TestIntrospect:
+    @pytest.mark.asyncio
+    async def test_returns_record(self, svc):
+        svc.access_token = "oc_ext_A"
+        svc._client.get = AsyncMock(
+            return_value=_mock_response(
+                json_data={"user_id": "u-1", "client_id": ORCA_CLIENT_ID, "connection_id": "c-1"}
+            )
+        )
+        info = await svc.introspect()
+        assert info["user_id"] == "u-1"
+
+    @pytest.mark.asyncio
+    async def test_401_raises_auth_error(self, svc):
+        svc.access_token = "oc_ext_A"
+        svc._client.get = AsyncMock(return_value=_mock_response(status_code=401, text_body="unauthorized"))
+        with pytest.raises(OrcaCloudAuthError):
+            await svc.introspect()
+
+
+# ---------------------------------------------------------------------------
+# Profile pull
+# ---------------------------------------------------------------------------
+
+
 class TestListProfiles:
     @pytest.mark.asyncio
     async def test_pull_response_upserts_extracted(self, svc):
-        """The bare-cursor /sync/pull returns a ``SyncPullResponse`` shape;
-        we extract the ``upserts`` list and ignore ``next_cursor`` / ``deletes``
-        (no prior client state to invalidate)."""
-        svc.access_token = "ACCESS"
-        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc.access_token = "oc_ext_A"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
         svc._client.get = AsyncMock(
             return_value=_mock_response(
                 json_data={
@@ -358,51 +339,55 @@ class TestListProfiles:
                         {"id": "b", "name": "B", "content": {"x": 2}},
                     ],
                     "deletes": ["zzz"],
-                },
+                }
             )
         )
         result = await svc.list_profiles()
         assert [p["id"] for p in result] == ["a", "b"]
 
     @pytest.mark.asyncio
-    async def test_pull_hits_path_without_cursor(self, svc):
-        """Regression guard: ``cursor=0`` trips ``410 cursor_too_old`` on
-        the production endpoint. The first-sync bootstrap must hit
-        ``/api/v1/sync/pull`` with no ``?cursor=`` parameter — same behaviour
-        as OrcaSlicer's own client."""
-        svc.access_token = "ACCESS"
-        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
-        svc._client.get = AsyncMock(
-            return_value=_mock_response(json_data={"upserts": [], "deletes": []}),
-        )
+    async def test_pull_hits_external_path_without_cursor(self, svc):
+        """Regression guard: the list must hit the EXTERNAL sync path
+        (``/api/v1/external/sync/pull``, not the first-party ``/api/v1/sync``)
+        with no ``?cursor=`` — ``cursor=0`` trips ``410 cursor_too_old``."""
+        svc.access_token = "oc_ext_A"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
+        svc._client.get = AsyncMock(return_value=_mock_response(json_data={"upserts": [], "deletes": []}))
         await svc.list_profiles()
         called_url = svc._client.get.call_args.args[0]
-        assert called_url.endswith("/api/v1/sync/pull")
+        assert called_url.endswith("/api/v1/external/sync/pull")
         assert "cursor" not in called_url
-        # And no ``params`` kwarg either, which would be a second way to
-        # smuggle the cursor in.
         assert "params" not in svc._client.get.call_args.kwargs
 
+    @pytest.mark.asyncio
+    async def test_401_raises_auth_error(self, svc):
+        svc.access_token = "oc_ext_A"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
+        svc._client.get = AsyncMock(return_value=_mock_response(status_code=401, text_body="nope"))
+        with pytest.raises(OrcaCloudAuthError):
+            await svc.list_profiles()
+
+    @pytest.mark.asyncio
+    async def test_410_cursor_too_old_raises(self, svc):
+        svc.access_token = "oc_ext_A"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
+        svc._client.get = AsyncMock(return_value=_mock_response(status_code=410, json_data={"error": "cursor_too_old"}))
+        with pytest.raises(OrcaCloudError, match="cursor too old"):
+            await svc.list_profiles()
+
     @pytest.mark.asyncio
     async def test_bare_list_response_tolerated(self, svc):
-        """If the server ever rolls out a flat-list response shape, we
-        forward it verbatim rather than logging-and-empty."""
-        svc.access_token = "ACCESS"
-        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
-        svc._client.get = AsyncMock(
-            return_value=_mock_response(json_data=[{"id": "a", "name": "A"}]),
-        )
+        svc.access_token = "oc_ext_A"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
+        svc._client.get = AsyncMock(return_value=_mock_response(json_data=[{"id": "a", "name": "A"}]))
         assert [p["id"] for p in await svc.list_profiles()] == ["a"]
 
 
 class TestGetProfile:
     @pytest.mark.asyncio
     async def test_returns_matching_profile_with_content(self, svc):
-        """``get_profile`` lists then filters since Orca has no dedicated
-        per-profile GET — verify the matched entry returns with full
-        content, not stripped to metadata."""
-        svc.access_token = "ACCESS"
-        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc.access_token = "oc_ext_A"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
         svc._client.get = AsyncMock(
             return_value=_mock_response(
                 json_data={
@@ -411,23 +396,19 @@ class TestGetProfile:
                         {"id": "target", "name": "Target", "content": {"hit": True}},
                     ],
                     "deletes": [],
-                },
+                }
             )
         )
-
         profile = await svc.get_profile("target")
-
         assert profile["id"] == "target"
         assert profile["content"] == {"hit": True}
 
     @pytest.mark.asyncio
     async def test_not_found_raises(self, svc):
-        svc.access_token = "ACCESS"
-        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=1)
+        svc.access_token = "oc_ext_A"
+        svc.token_expiry = datetime.now(timezone.utc) + timedelta(hours=12)
         svc._client.get = AsyncMock(
-            return_value=_mock_response(
-                json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []},
-            ),
+            return_value=_mock_response(json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []})
         )
         with pytest.raises(OrcaCloudError, match="not found"):
             await svc.get_profile("missing")

+ 61 - 124
frontend/src/__tests__/components/OrcaCloudView.test.tsx

@@ -1,9 +1,9 @@
 /**
- * Tests for OrcaCloudView component — covers the four UI phases of the
- * paste-based PKCE handshake: disconnected, awaiting-paste, connected,
- * and disconnect.
+ * Tests for OrcaCloudView — the RFC 8628 device-pairing flow: disconnected
+ * (Connect button), pairing (code + approval link + waiting), a completed
+ * poll flipping to connected, a denied poll surfacing an error, and disconnect.
  */
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect } from 'vitest';
 import { screen, waitFor, fireEvent } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 
@@ -11,174 +11,111 @@ import { server } from '../mocks/server';
 import { render } from '../utils';
 import { OrcaCloudView } from '../../components/OrcaCloudView';
 
-// JSDOM doesn't implement window.open; the connect flow opens the auth URL
-// in a new tab so we stub it to capture the call.
-beforeEach(() => {
-  vi.stubGlobal('open', vi.fn());
-});
+const DEVICE_START = {
+  user_code: 'ABCD-EF12',
+  verification_uri: 'https://cloud.orcaslicer.com/app/settings',
+  verification_uri_complete: 'https://cloud.orcaslicer.com/app/settings?user_code=ABCD-EF12',
+  interval: 5,
+  expires_in: 600,
+};
+
+const NO_PROFILES = { filament: [], printer: [], process: [] };
 
-const noProfilesResponse = { profiles: [] };
+const disconnectedStatus = () =>
+  http.get('/api/v1/orca-cloud/status', () =>
+    HttpResponse.json({ connected: false, email: null, user_id: null }),
+  );
 
 describe('OrcaCloudView', () => {
-  it('shows all four sign-in options when not connected', async () => {
-    server.use(
-      http.get('/api/v1/orca-cloud/status', () =>
-        HttpResponse.json({ connected: false, email: null, user_id: null }),
-      ),
-    );
+  it('shows the Connect button when not connected', async () => {
+    server.use(disconnectedStatus());
     render(<OrcaCloudView />);
 
     await waitFor(() => {
-      expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument();
     });
-    expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
-    expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument();
-    expect(screen.getByRole('button', { name: /Sign in with GitHub/i })).toBeInTheDocument();
-    expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument();
   });
 
-  it('passes the selected OAuth provider to auth/start', async () => {
-    let receivedProvider: string | undefined;
+  it('shows the pairing code and approval link after clicking Connect', async () => {
     server.use(
-      http.get('/api/v1/orca-cloud/status', () =>
-        HttpResponse.json({ connected: false, email: null, user_id: null }),
+      disconnectedStatus(),
+      http.post('/api/v1/orca-cloud/device/start', () => HttpResponse.json(DEVICE_START)),
+      http.post('/api/v1/orca-cloud/device/poll', () =>
+        HttpResponse.json({ status: 'authorization_pending', connected: false, email: null, user_id: null }),
       ),
-      http.post('/api/v1/orca-cloud/auth/start', async ({ request }) => {
-        const body = (await request.json()) as { provider?: string };
-        receivedProvider = body.provider;
-        return HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' });
-      }),
     );
     render(<OrcaCloudView />);
 
     await waitFor(() => {
-      expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument();
     });
-    fireEvent.click(screen.getByRole('button', { name: /Sign in with Apple/i }));
+    fireEvent.click(screen.getByRole('button', { name: /Connect Orca Cloud/i }));
 
     await waitFor(() => {
-      expect(receivedProvider).toBe('apple');
+      expect(screen.getByText('ABCD-EF12')).toBeInTheDocument();
     });
-    expect(window.open).toHaveBeenCalledWith(
-      'https://auth.orcaslicer.com/auth/v1/authorize?test=1',
-      '_blank',
-      'noopener,noreferrer',
+    expect(screen.getByText(/Waiting for you to approve/i)).toBeInTheDocument();
+    // The approval link points at the verification_uri_complete.
+    expect(screen.getByRole('link', { name: /Open Orca Cloud approval page/i })).toHaveAttribute(
+      'href',
+      DEVICE_START.verification_uri_complete,
     );
   });
 
-  it('connects via email and password without the paste flow', async () => {
+  it('flips to connected once a poll returns complete', async () => {
     let connected = false;
-    let receivedCreds: { email?: string; password?: string } = {};
     server.use(
       http.get('/api/v1/orca-cloud/status', () =>
         HttpResponse.json(
           connected
-            ? { connected: true, email: 'martin@example.com', user_id: 'u1' }
+            ? { connected: true, email: null, user_id: 'user-123' }
             : { connected: false, email: null, user_id: null },
         ),
       ),
-      http.post('/api/v1/orca-cloud/auth/password', async ({ request }) => {
-        receivedCreds = (await request.json()) as { email?: string; password?: string };
+      http.post('/api/v1/orca-cloud/device/start', () => HttpResponse.json(DEVICE_START)),
+      http.post('/api/v1/orca-cloud/device/poll', () => {
         connected = true;
-        return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' });
+        return HttpResponse.json({ status: 'complete', connected: true, email: null, user_id: 'user-123' });
       }),
-      http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
-    );
-    render(<OrcaCloudView />);
-
-    await waitFor(() => {
-      expect(screen.getByRole('button', { name: /Sign in with email and password/i })).toBeInTheDocument();
-    });
-    fireEvent.click(screen.getByRole('button', { name: /Sign in with email and password/i }));
-
-    // The password form replaces the provider picker.
-    await waitFor(() => {
-      expect(screen.getByLabelText(/^Email$/i)).toBeInTheDocument();
-    });
-    fireEvent.change(screen.getByLabelText(/^Email$/i), { target: { value: 'martin@example.com' } });
-    fireEvent.change(screen.getByLabelText(/^Password$/i), { target: { value: 'hunter2' } });
-    // Click the submit button inside the form (not the picker's email button).
-    const submitButtons = screen.getAllByRole('button', { name: /^Sign in$/i });
-    fireEvent.click(submitButtons[submitButtons.length - 1]);
-
-    await waitFor(() => {
-      expect(screen.getByText('martin@example.com')).toBeInTheDocument();
-    });
-    expect(receivedCreds).toEqual({ email: 'martin@example.com', password: 'hunter2' });
-  });
-
-  it('rejects a URL without a code parameter with a client-side error', async () => {
-    server.use(
-      http.get('/api/v1/orca-cloud/status', () =>
-        HttpResponse.json({ connected: false, email: null, user_id: null }),
-      ),
-      http.post('/api/v1/orca-cloud/auth/start', () =>
-        HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }),
-      ),
+      http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(NO_PROFILES)),
     );
     render(<OrcaCloudView />);
 
     await waitFor(() => {
-      expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
-    });
-    fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i }));
-    await waitFor(() => {
-      expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument();
     });
+    fireEvent.click(screen.getByRole('button', { name: /Connect Orca Cloud/i }));
 
-    const textarea = screen.getByPlaceholderText(/http:\/\/localhost:41172/i);
-    // Paste something with no ?code= — the client-side guard should fire
-    // before we hit the server.
-    fireEvent.change(textarea, { target: { value: 'http://localhost:41172/callback?error=denied' } });
-    fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i }));
-
+    // The immediate first poll returns complete → status invalidates and the
+    // connected view (with the Disconnect control) appears. We key off the
+    // Disconnect button rather than the banner text, since the success toast
+    // also renders "Connected to Orca Cloud".
     await waitFor(() => {
-      expect(screen.getByText(/does not look like an Orca Cloud callback/i)).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
     });
   });
 
-  it('shows the connected state with the email after a successful paste', async () => {
-    // Start disconnected; after a successful finish the status query refetches
-    // and returns connected. MSW lets us swap handlers mid-test.
-    let connected = false;
+  it('surfaces an error and returns to Connect when the pairing is denied', async () => {
     server.use(
-      http.get('/api/v1/orca-cloud/status', () => {
-        return HttpResponse.json(
-          connected
-            ? { connected: true, email: 'martin@example.com', user_id: 'u1' }
-            : { connected: false, email: null, user_id: null },
-        );
-      }),
-      http.post('/api/v1/orca-cloud/auth/start', () =>
-        HttpResponse.json({ auth_url: 'https://auth.orcaslicer.com/auth/v1/authorize?test=1' }),
+      disconnectedStatus(),
+      http.post('/api/v1/orca-cloud/device/start', () => HttpResponse.json(DEVICE_START)),
+      http.post('/api/v1/orca-cloud/device/poll', () =>
+        HttpResponse.json({ status: 'access_denied', connected: false, email: null, user_id: null }),
       ),
-      http.post('/api/v1/orca-cloud/auth/finish', () => {
-        connected = true;
-        return HttpResponse.json({ connected: true, email: 'martin@example.com', user_id: 'u1' });
-      }),
-      http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
     );
     render(<OrcaCloudView />);
 
     await waitFor(() => {
-      expect(screen.getByRole('button', { name: /Sign in with Google/i })).toBeInTheDocument();
-    });
-    fireEvent.click(screen.getByRole('button', { name: /Sign in with Google/i }));
-    await waitFor(() => {
-      expect(screen.getByPlaceholderText(/http:\/\/localhost:41172/i)).toBeInTheDocument();
-    });
-
-    fireEvent.change(screen.getByPlaceholderText(/http:\/\/localhost:41172/i), {
-      target: { value: 'http://localhost:41172/callback?code=ABC&state=XYZ' },
+      expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument();
     });
-    fireEvent.click(screen.getByRole('button', { name: /Finish connecting/i }));
+    fireEvent.click(screen.getByRole('button', { name: /Connect Orca Cloud/i }));
 
-    // After the finish call resolves, the status query is invalidated and
-    // refetches connected=true → the connection banner appears with the email.
     await waitFor(() => {
-      expect(screen.getByText('martin@example.com')).toBeInTheDocument();
+      expect(screen.getByText(/denied/i)).toBeInTheDocument();
     });
-    expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
+    // Back on the Connect card, not stuck on the waiting screen.
+    expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument();
+    expect(screen.queryByText(/Waiting for you to approve/i)).not.toBeInTheDocument();
   });
 
   it('clears the connection on Disconnect', async () => {
@@ -187,11 +124,11 @@ describe('OrcaCloudView', () => {
       http.get('/api/v1/orca-cloud/status', () =>
         HttpResponse.json(
           connected
-            ? { connected: true, email: 'martin@example.com', user_id: 'u1' }
+            ? { connected: true, email: null, user_id: 'user-123' }
             : { connected: false, email: null, user_id: null },
         ),
       ),
-      http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)),
+      http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(NO_PROFILES)),
       http.post('/api/v1/orca-cloud/logout', () => {
         connected = false;
         return HttpResponse.json({ success: true });
@@ -200,12 +137,12 @@ describe('OrcaCloudView', () => {
     render(<OrcaCloudView />);
 
     await waitFor(() => {
-      expect(screen.getByText('martin@example.com')).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument();
     });
     fireEvent.click(screen.getByRole('button', { name: /Disconnect/i }));
 
     await waitFor(() => {
-      expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Connect Orca Cloud/i })).toBeInTheDocument();
     });
   });
 });

+ 28 - 19
frontend/src/api/client.ts

@@ -1320,10 +1320,26 @@ export interface CloudLoginResponse {
 // Orca Cloud types — paste-flow PKCE handshake against auth.orcaslicer.com.
 // See backend/app/services/orca_cloud.py for the deep dive on why this
 // flow is paste-based rather than callback-based.
-export type OrcaOAuthProvider = 'google' | 'apple' | 'github';
-
-export interface OrcaAuthStartResponse {
-  auth_url: string;
+export interface OrcaDeviceStartResponse {
+  user_code: string;
+  verification_uri: string;
+  verification_uri_complete: string;
+  interval: number;
+  expires_in: number;
+}
+
+export type OrcaDevicePollStatus =
+  | 'authorization_pending'
+  | 'slow_down'
+  | 'access_denied'
+  | 'expired_token'
+  | 'complete';
+
+export interface OrcaDevicePollResponse {
+  status: OrcaDevicePollStatus;
+  connected: boolean;
+  email: string | null;
+  user_id: string | null;
 }
 
 export interface OrcaAuthStatusResponse {
@@ -4802,24 +4818,17 @@ export const api = {
   cloudLogout: () =>
     request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
 
-  // Orca Cloud — paste-based PKCE flow for OAuth (Google/Apple/GitHub),
-  // direct credentials for email+password. start() returns an auth URL the
-  // user opens in their browser; after sign-in they paste the callback URL
-  // back via finish(). password() skips the dance entirely.
-  orcaCloudStartAuth: (provider: OrcaOAuthProvider = 'google') =>
-    request<OrcaAuthStartResponse>('/orca-cloud/auth/start', {
-      method: 'POST',
-      body: JSON.stringify({ provider }),
-    }),
-  orcaCloudFinishAuth: (callback_url: string) =>
-    request<OrcaAuthStatusResponse>('/orca-cloud/auth/finish', {
+  // Orca Cloud — RFC 8628 device pairing. deviceStart() returns a short
+  // user_code + verification link; the user approves it in their Orca Cloud
+  // settings while the frontend polls devicePoll() every `interval` seconds
+  // until the status flips to 'complete' (or a terminal deny/expire).
+  orcaCloudDeviceStart: () =>
+    request<OrcaDeviceStartResponse>('/orca-cloud/device/start', {
       method: 'POST',
-      body: JSON.stringify({ callback_url }),
     }),
-  orcaCloudPasswordLogin: (email: string, password: string) =>
-    request<OrcaAuthStatusResponse>('/orca-cloud/auth/password', {
+  orcaCloudDevicePoll: () =>
+    request<OrcaDevicePollResponse>('/orca-cloud/device/poll', {
       method: 'POST',
-      body: JSON.stringify({ email, password }),
     }),
   orcaCloudStatus: () =>
     request<OrcaAuthStatusResponse>('/orca-cloud/status'),

+ 146 - 354
frontend/src/components/OrcaCloudView.tsx

@@ -1,10 +1,10 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { Cloud, ExternalLink, LogOut, Loader2, AlertCircle, AlertTriangle, Check, Mail, ArrowLeft } from 'lucide-react';
+import { Cloud, ExternalLink, LogOut, Loader2, AlertCircle, Check } from 'lucide-react';
 
 import { api } from '../api/client';
-import type { OrcaOAuthProvider } from '../api/client';
+import type { OrcaDeviceStartResponse, OrcaDevicePollStatus } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
@@ -14,16 +14,12 @@ import { OrcaCloudProfilesView } from './OrcaCloudProfilesView';
 /**
  * Orca Cloud profile sync tab.
  *
- * Auth uses a paste-based PKCE handshake: backend generates the verifier and
- * authorize URL, the user opens it in a new tab and signs in, the browser
- * redirects to ``http://localhost:41172/callback`` (which fails to load since
- * Bambuddy isn't on the user's localhost), and the user copies the URL from
- * their address bar back into the paste textarea below. The backend extracts
- * the code, validates state for CSRF, and exchanges for tokens.
- *
- * See OrcaSlicer/OrcaSlicer#14028 for the open feature request asking
- * SoftFever to broaden the Supabase redirect_to allowlist so we could ship
- * a clean OAuth callback instead.
+ * Auth uses the RFC 8628 device-authorization grant: the backend requests a
+ * device code from Orca and returns a short user_code plus a verification link.
+ * The user opens the link, approves the code in their Orca Cloud settings, and
+ * Bambuddy polls the backend (which polls Orca's token endpoint) until the
+ * pairing completes. No redirect URL, no callback paste, no client secret —
+ * see backend/app/services/orca_cloud.py for the deep dive.
  */
 export function OrcaCloudView() {
   const { t } = useTranslation();
@@ -32,17 +28,11 @@ export function OrcaCloudView() {
   const { hasPermission } = useAuth();
   const canManage = hasPermission('orca_cloud:auth');
 
-  // Paste-flow local state: once the user clicks an OAuth provider, we hold
-  // the returned auth_url so the same URL stays clickable while they go
-  // fetch the callback URL from their browser. ``mode`` drives which
-  // sub-form is showing: picker → OAuth paste-flow → email/password form.
-  const [mode, setMode] = useState<'picker' | 'paste' | 'password'>('picker');
-  const [authUrl, setAuthUrl] = useState<string | null>(null);
-  const [pastedUrl, setPastedUrl] = useState('');
-  const [pasteError, setPasteError] = useState<string | null>(null);
-  const [passwordEmail, setPasswordEmail] = useState('');
-  const [passwordValue, setPasswordValue] = useState('');
-  const [passwordError, setPasswordError] = useState<string | null>(null);
+  // Pairing sub-state: null until the user clicks Connect, then the device
+  // response (code + link) that we display while polling.
+  const [pairing, setPairing] = useState<OrcaDeviceStartResponse | null>(null);
+  const [pollIntervalMs, setPollIntervalMs] = useState(5000);
+  const [connectError, setConnectError] = useState<string | null>(null);
 
   const { data: status, isLoading: statusLoading } = useQuery({
     queryKey: ['orcaCloudStatus'],
@@ -80,54 +70,26 @@ export function OrcaCloudView() {
     if (profilesUpdatedAt) setLastSyncTime(new Date(profilesUpdatedAt));
   }, [profilesUpdatedAt]);
 
-  const startAuthMutation = useMutation({
-    mutationFn: (provider: OrcaOAuthProvider) => api.orcaCloudStartAuth(provider),
-    onSuccess: (data) => {
-      setAuthUrl(data.auth_url);
-      setPastedUrl('');
-      setPasteError(null);
-      setMode('paste');
-      // Open in a new tab so the user can keep Bambuddy open in their
-      // current tab while they sign in.
-      window.open(data.auth_url, '_blank', 'noopener,noreferrer');
-    },
-    onError: (err: Error) => {
-      showToast(err.message || t('profiles.orcaCloud.errors.startFailed'), 'error');
-    },
-  });
+  const finishPairing = () => {
+    setPairing(null);
+    setPollIntervalMs(5000);
+  };
 
-  const finishAuthMutation = useMutation({
-    mutationFn: (url: string) => api.orcaCloudFinishAuth(url),
-    onSuccess: (data) => {
-      setAuthUrl(null);
-      setPastedUrl('');
-      setPasteError(null);
-      setMode('picker');
-      queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
-      queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
-      showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' }));
-    },
-    onError: (err: Error) => {
-      // Surface the backend's error message in the paste-error slot so the
-      // user can fix the input (rather than a transient toast they might miss).
-      setPasteError(err.message || t('profiles.orcaCloud.errors.finishFailed'));
-    },
-  });
+  const handleTerminal = (status: OrcaDevicePollStatus) => {
+    if (status === 'access_denied') setConnectError(t('profiles.orcaCloud.errors.denied'));
+    else if (status === 'expired_token') setConnectError(t('profiles.orcaCloud.errors.expired'));
+    finishPairing();
+  };
 
-  const passwordLoginMutation = useMutation({
-    mutationFn: ({ email, password }: { email: string; password: string }) =>
-      api.orcaCloudPasswordLogin(email, password),
+  const startMutation = useMutation({
+    mutationFn: api.orcaCloudDeviceStart,
     onSuccess: (data) => {
-      setPasswordEmail('');
-      setPasswordValue('');
-      setPasswordError(null);
-      setMode('picker');
-      queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
-      queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
-      showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' }));
+      setConnectError(null);
+      setPollIntervalMs(Math.max(1, data.interval) * 1000);
+      setPairing(data);
     },
     onError: (err: Error) => {
-      setPasswordError(err.message || t('profiles.orcaCloud.errors.passwordFailed'));
+      setConnectError(err.message || t('profiles.orcaCloud.errors.startFailed'));
     },
   });
 
@@ -140,41 +102,60 @@ export function OrcaCloudView() {
     },
   });
 
-  const handleSubmitPaste = (e: React.FormEvent) => {
-    e.preventDefault();
-    setPasteError(null);
-    const trimmed = pastedUrl.trim();
-    if (!trimmed) {
-      setPasteError(t('profiles.orcaCloud.errors.emptyPaste'));
+  // Poll the backend while a pairing is in flight. react-query drives the
+  // cadence; the effect below reacts to each poll result. refetchInterval
+  // returns false once we stop (pairing cleared), which halts polling.
+  const { data: pollData, error: pollError } = useQuery({
+    // Scope the cache per pairing attempt so a fresh Connect never re-consumes
+    // a previous attempt's cached 'complete'/terminal result.
+    queryKey: ['orcaCloudDevicePoll', pairing?.user_code ?? 'none'],
+    queryFn: api.orcaCloudDevicePoll,
+    enabled: pairing !== null,
+    gcTime: 0,
+    retry: false,
+    refetchOnWindowFocus: false,
+    refetchInterval: pairing !== null ? pollIntervalMs : false,
+  });
+
+  // A ref so the poll-result effect can act exactly once per new result
+  // without re-running when unrelated state (interval, etc.) changes.
+  const lastHandledStatus = useRef<OrcaDevicePollStatus | null>(null);
+  useEffect(() => {
+    if (!pairing || !pollData) return;
+    const s = pollData.status;
+    if (s === 'slow_down') {
+      // Back off as the RFC prescribes, then keep waiting.
+      setPollIntervalMs((ms) => ms + 5000);
       return;
     }
-    if (!trimmed.includes('code=')) {
-      setPasteError(t('profiles.orcaCloud.errors.noCode'));
-      return;
+    if (s === 'authorization_pending') return;
+    if (lastHandledStatus.current === s) return;
+    lastHandledStatus.current = s;
+    if (s === 'complete') {
+      finishPairing();
+      queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
+      queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
+      showToast(t('profiles.orcaCloud.connectedShort'));
+    } else {
+      handleTerminal(s);
     }
-    finishAuthMutation.mutate(trimmed);
-  };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [pollData, pairing]);
 
-  const handleSubmitPassword = (e: React.FormEvent) => {
-    e.preventDefault();
-    setPasswordError(null);
-    const email = passwordEmail.trim();
-    if (!email || !passwordValue) {
-      setPasswordError(t('profiles.orcaCloud.errors.passwordEmpty'));
-      return;
+  // A poll HTTP error (e.g. the pending state vanished server-side) ends the
+  // flow rather than spinning forever.
+  useEffect(() => {
+    if (pairing && pollError) {
+      setConnectError(t('profiles.orcaCloud.errors.pollFailed'));
+      finishPairing();
     }
-    passwordLoginMutation.mutate({ email, password: passwordValue });
-  };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [pollError, pairing]);
 
-  const resetToPicker = () => {
-    setMode('picker');
-    setAuthUrl(null);
-    setPastedUrl('');
-    setPasteError(null);
-    setPasswordEmail('');
-    setPasswordValue('');
-    setPasswordError(null);
-  };
+  // Reset the one-shot guard whenever a new pairing starts.
+  useEffect(() => {
+    if (pairing) lastHandledStatus.current = null;
+  }, [pairing]);
 
   if (statusLoading) {
     return (
@@ -191,8 +172,13 @@ export function OrcaCloudView() {
           <div className="flex items-center gap-3">
             <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
             <span className="text-sm text-bambu-gray">
-              {t('profiles.orcaCloud.connectedAs')}{' '}
-              <span className="text-white">{status?.email}</span>
+              {status?.email ? (
+                <>
+                  {t('profiles.orcaCloud.connectedAs')} <span className="text-white">{status.email}</span>
+                </>
+              ) : (
+                <span className="text-white">{t('profiles.orcaCloud.connectedShort')}</span>
+              )}
             </span>
           </div>
           <Button
@@ -209,28 +195,12 @@ export function OrcaCloudView() {
       )}
 
       {!connected ? (
-        <ConnectFlow
-          mode={mode}
-          authUrl={authUrl}
-          pastedUrl={pastedUrl}
-          setPastedUrl={setPastedUrl}
-          pasteError={pasteError}
-          passwordEmail={passwordEmail}
-          setPasswordEmail={setPasswordEmail}
-          passwordValue={passwordValue}
-          setPasswordValue={setPasswordValue}
-          passwordError={passwordError}
-          onPickProvider={(provider) => startAuthMutation.mutate(provider)}
-          onPickPassword={() => {
-            setMode('password');
-            setPasswordError(null);
-          }}
-          onSubmitPaste={handleSubmitPaste}
-          onSubmitPassword={handleSubmitPassword}
-          onBack={resetToPicker}
-          isStarting={startAuthMutation.isPending}
-          isFinishing={finishAuthMutation.isPending}
-          isPasswordLoading={passwordLoginMutation.isPending}
+        <ConnectCard
+          pairing={pairing}
+          connectError={connectError}
+          onConnect={() => startMutation.mutate()}
+          onCancel={finishPairing}
+          isStarting={startMutation.isPending}
           canManage={canManage}
           t={t}
         />
@@ -257,258 +227,80 @@ export function OrcaCloudView() {
   );
 }
 
-interface ConnectFlowProps {
-  mode: 'picker' | 'paste' | 'password';
-  authUrl: string | null;
-  pastedUrl: string;
-  setPastedUrl: (v: string) => void;
-  pasteError: string | null;
-  passwordEmail: string;
-  setPasswordEmail: (v: string) => void;
-  passwordValue: string;
-  setPasswordValue: (v: string) => void;
-  passwordError: string | null;
-  onPickProvider: (provider: OrcaOAuthProvider) => void;
-  onPickPassword: () => void;
-  onSubmitPaste: (e: React.FormEvent) => void;
-  onSubmitPassword: (e: React.FormEvent) => void;
-  onBack: () => void;
+interface ConnectCardProps {
+  pairing: OrcaDeviceStartResponse | null;
+  connectError: string | null;
+  onConnect: () => void;
+  onCancel: () => void;
   isStarting: boolean;
-  isFinishing: boolean;
-  isPasswordLoading: boolean;
   canManage: boolean;
   t: (key: string, opts?: Record<string, string>) => string;
 }
 
-function ConnectFlow(props: ConnectFlowProps) {
-  if (props.mode === 'paste' && props.authUrl) {
-    return <PasteCard {...props} authUrl={props.authUrl} />;
-  }
-  if (props.mode === 'password') {
-    return <PasswordCard {...props} />;
+function ConnectCard({ pairing, connectError, onConnect, onCancel, isStarting, canManage, t }: ConnectCardProps) {
+  // While pairing is in flight, show the code + approval link + waiting spinner.
+  if (pairing) {
+    return (
+      <Card>
+        <CardContent className="p-8 text-center max-w-md mx-auto">
+          <Cloud className="w-12 h-12 text-bambu-green mx-auto mb-4" />
+          <h2 className="text-xl font-bold text-white mb-2">{t('profiles.orcaCloud.device.title')}</h2>
+          <p className="text-bambu-gray mb-6">{t('profiles.orcaCloud.device.instruction')}</p>
+
+          <p className="text-xs uppercase tracking-wide text-bambu-gray mb-2">
+            {t('profiles.orcaCloud.device.codeLabel')}
+          </p>
+          <div className="mb-6 py-3 px-4 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
+            <span className="text-2xl font-mono font-bold tracking-[0.3em] text-white select-all">
+              {pairing.user_code}
+            </span>
+          </div>
+
+          <a href={pairing.verification_uri_complete} target="_blank" rel="noopener noreferrer">
+            <Button className="w-full mb-3">
+              <ExternalLink className="w-4 h-4" />
+              {t('profiles.orcaCloud.device.openButton')}
+            </Button>
+          </a>
+          <p className="text-xs text-bambu-gray break-all mb-6">
+            {t('profiles.orcaCloud.device.manualHint', { url: pairing.verification_uri })}
+          </p>
+
+          <div className="flex items-center justify-center gap-2 text-sm text-bambu-gray mb-4">
+            <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />
+            {t('profiles.orcaCloud.device.waiting')}
+          </div>
+          <button type="button" onClick={onCancel} className="text-bambu-gray hover:text-white text-sm">
+            {t('profiles.orcaCloud.device.cancel')}
+          </button>
+        </CardContent>
+      </Card>
+    );
   }
-  return <PickerCard {...props} />;
-}
 
-function PickerCard({
-  onPickProvider,
-  onPickPassword,
-  isStarting,
-  canManage,
-  t,
-}: ConnectFlowProps) {
-  // Orca's web sign-in offers four options: Google, Apple, GitHub (all
-  // OAuth, paste-flow) and email+password (direct). We mirror that surface
-  // so users with a non-Google account aren't blocked.
   return (
     <Card>
       <CardContent className="p-8 text-center">
         <Cloud className="w-12 h-12 text-bambu-green mx-auto mb-4" />
-        <h2 className="text-xl font-bold text-white mb-2">
-          {t('profiles.orcaCloud.connect.title')}
-        </h2>
-        <p className="text-bambu-gray mb-6 max-w-xl mx-auto">
-          {t('profiles.orcaCloud.connect.description')}
-        </p>
-        <div className="flex flex-col gap-2 max-w-sm mx-auto">
-          <Button
-            onClick={onPickPassword}
-            disabled={isStarting || !canManage}
-            title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
-          >
-            <Mail className="w-4 h-4" />
-            {t('profiles.orcaCloud.providers.email')}
-          </Button>
-          <Button
-            variant="secondary"
-            onClick={() => onPickProvider('google')}
-            disabled={isStarting || !canManage}
-            title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
-          >
-            {isStarting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ExternalLink className="w-4 h-4" />}
-            {t('profiles.orcaCloud.providers.google')}
-          </Button>
-          <Button
-            variant="secondary"
-            onClick={() => onPickProvider('github')}
-            disabled={isStarting || !canManage}
-            title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
-          >
-            <ExternalLink className="w-4 h-4" />
-            {t('profiles.orcaCloud.providers.github')}
-          </Button>
+        <h2 className="text-xl font-bold text-white mb-2">{t('profiles.orcaCloud.connect.title')}</h2>
+        <p className="text-bambu-gray mb-6 max-w-xl mx-auto">{t('profiles.orcaCloud.connect.description')}</p>
+        <div className="max-w-sm mx-auto">
           <Button
-            variant="secondary"
-            onClick={() => onPickProvider('apple')}
+            onClick={onConnect}
             disabled={isStarting || !canManage}
             title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
+            className="w-full"
           >
-            <ExternalLink className="w-4 h-4" />
-            {t('profiles.orcaCloud.providers.apple')}
+            {isStarting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+            {t('profiles.orcaCloud.connectButton')}
           </Button>
-        </div>
-      </CardContent>
-    </Card>
-  );
-}
-
-function PasteCard({
-  authUrl,
-  pastedUrl,
-  setPastedUrl,
-  pasteError,
-  onSubmitPaste,
-  onBack,
-  isFinishing,
-  t,
-}: ConnectFlowProps & { authUrl: string }) {
-  return (
-    <Card>
-      <CardContent className="p-6">
-        <button
-          type="button"
-          onClick={onBack}
-          className="text-bambu-gray hover:text-white text-sm flex items-center gap-1 mb-4"
-        >
-          <ArrowLeft className="w-4 h-4" />
-          {t('profiles.orcaCloud.back')}
-        </button>
-        <h2 className="text-xl font-bold text-white mb-4">
-          {t('profiles.orcaCloud.paste.title')}
-        </h2>
-
-        {/* Numbered-step list with prominent visual treatment. Step 2 carries
-            the critical "the page failing is expected" message inside an
-            amber callout so users don't read the connection-refused page
-            as a Bambuddy error. */}
-        <ol className="space-y-3 mb-6">
-          <li className="flex gap-3">
-            <span className="flex-shrink-0 w-7 h-7 rounded-full bg-bambu-dark-tertiary text-white text-sm font-bold flex items-center justify-center">1</span>
-            <p className="text-base text-white pt-0.5">{t('profiles.orcaCloud.paste.step1')}</p>
-          </li>
-          <li className="flex gap-3">
-            <span className="flex-shrink-0 w-7 h-7 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-sm font-bold flex items-center justify-center">2</span>
-            <div className="flex-1 p-3 bg-amber-500/10 border border-amber-500/40 rounded">
-              <div className="flex items-start gap-2">
-                <AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
-                <p className="text-base text-white font-medium">{t('profiles.orcaCloud.paste.step2')}</p>
-              </div>
-            </div>
-          </li>
-          <li className="flex gap-3">
-            <span className="flex-shrink-0 w-7 h-7 rounded-full bg-bambu-dark-tertiary text-white text-sm font-bold flex items-center justify-center">3</span>
-            <p className="text-base text-white pt-0.5">{t('profiles.orcaCloud.paste.step3')}</p>
-          </li>
-        </ol>
-
-        <div className="mb-4 p-3 bg-bambu-dark rounded border border-bambu-dark-tertiary">
-          <p className="text-xs text-bambu-gray mb-1">{t('profiles.orcaCloud.paste.signInUrl')}</p>
-          <a
-            href={authUrl}
-            target="_blank"
-            rel="noopener noreferrer"
-            className="text-bambu-green text-sm break-all hover:underline"
-          >
-            {authUrl}
-          </a>
-        </div>
-        <form onSubmit={onSubmitPaste}>
-          <label htmlFor="orca-callback-url" className="block text-sm text-bambu-gray mb-2">
-            {t('profiles.orcaCloud.paste.label')}
-          </label>
-          <textarea
-            id="orca-callback-url"
-            value={pastedUrl}
-            onChange={(e) => setPastedUrl(e.target.value)}
-            placeholder={t('profiles.orcaCloud.paste.placeholder')}
-            className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono resize-none focus:outline-none focus:border-bambu-green"
-            rows={3}
-            disabled={isFinishing}
-          />
-          {pasteError && (
-            <p className="mt-2 text-sm text-red-700 dark:text-red-400 flex items-center gap-2">
-              <AlertCircle className="w-4 h-4" />
-              {pasteError}
-            </p>
-          )}
-          <div className="mt-4 flex items-center gap-3">
-            <Button type="submit" disabled={isFinishing || !pastedUrl.trim()}>
-              {isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
-              {t('profiles.orcaCloud.paste.submit')}
-            </Button>
-          </div>
-        </form>
-      </CardContent>
-    </Card>
-  );
-}
-
-function PasswordCard({
-  passwordEmail,
-  setPasswordEmail,
-  passwordValue,
-  setPasswordValue,
-  passwordError,
-  onSubmitPassword,
-  onBack,
-  isPasswordLoading,
-  t,
-}: ConnectFlowProps) {
-  return (
-    <Card>
-      <CardContent className="p-6 max-w-md mx-auto">
-        <button
-          type="button"
-          onClick={onBack}
-          className="text-bambu-gray hover:text-white text-sm flex items-center gap-1 mb-4"
-        >
-          <ArrowLeft className="w-4 h-4" />
-          {t('profiles.orcaCloud.back')}
-        </button>
-        <h2 className="text-xl font-bold text-white mb-4">
-          {t('profiles.orcaCloud.password.title')}
-        </h2>
-        <form onSubmit={onSubmitPassword} className="space-y-4">
-          <div>
-            <label htmlFor="orca-password-email" className="block text-sm text-bambu-gray mb-1">
-              {t('profiles.orcaCloud.password.email')}
-            </label>
-            <input
-              id="orca-password-email"
-              type="email"
-              value={passwordEmail}
-              onChange={(e) => setPasswordEmail(e.target.value)}
-              placeholder={t('profiles.orcaCloud.password.emailPlaceholder')}
-              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
-              disabled={isPasswordLoading}
-              autoComplete="email"
-            />
-          </div>
-          <div>
-            <label htmlFor="orca-password-value" className="block text-sm text-bambu-gray mb-1">
-              {t('profiles.orcaCloud.password.password')}
-            </label>
-            <input
-              id="orca-password-value"
-              type="password"
-              value={passwordValue}
-              onChange={(e) => setPasswordValue(e.target.value)}
-              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
-              disabled={isPasswordLoading}
-              autoComplete="current-password"
-            />
-          </div>
-          {passwordError && (
-            <p className="text-sm text-red-700 dark:text-red-400 flex items-center gap-2">
-              <AlertCircle className="w-4 h-4" />
-              {passwordError}
+          {connectError && (
+            <p className="mt-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-center gap-2">
+              <AlertCircle className="w-4 h-4 flex-shrink-0" />
+              {connectError}
             </p>
           )}
-          <Button type="submit" disabled={isPasswordLoading || !passwordEmail.trim() || !passwordValue}>
-            {isPasswordLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
-            {t('profiles.orcaCloud.password.submit')}
-          </Button>
-        </form>
+        </div>
       </CardContent>
     </Card>
   );

+ 13 - 29
frontend/src/i18n/locales/de.ts

@@ -3255,37 +3255,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: 'Verbunden als',
+      connectedShort: 'Mit Orca Cloud verbunden',
       logout: 'Trennen',
       noLogoutPermission: 'Sie haben keine Berechtigung zum Trennen',
       noConnectPermission: 'Sie haben keine Berechtigung, sich mit Orca Cloud zu verbinden',
       retry: 'Erneut versuchen',
-      back: 'Andere Anmeldemethode verwenden',
+      connectButton: 'Orca Cloud verbinden',
       connect: {
         title: 'Mit Orca Cloud verbinden',
         description: 'Melden Sie sich bei Ihrem Orca Cloud-Konto an, um Ihre Slicer-Profile in Bambuddy zu synchronisieren.',
       },
-      providers: {
-        google: 'Mit Google anmelden',
-        apple: 'Mit Apple anmelden',
-        github: 'Mit GitHub anmelden',
-        email: 'Mit E-Mail und Passwort anmelden',
-      },
-      password: {
-        title: 'Mit E-Mail und Passwort anmelden',
-        email: 'E-Mail',
-        emailPlaceholder: 'sie@beispiel.de',
-        password: 'Passwort',
-        submit: 'Anmelden',
-      },
-      paste: {
-        title: 'Anmeldung abschließen',
-        step1: 'Ein neuer Tab wurde mit der Orca Cloud-Anmeldeseite geöffnet. Melden Sie sich mit Ihrem Orca-Konto an.',
-        step2: 'Ihr Browser wird zu einer "localhost"-URL umgeleitet, die nicht geladen werden kann. Das ist normal — die URL ist es, was wir brauchen.',
-        step3: 'Kopieren Sie die gesamte URL aus der Adressleiste Ihres Browsers und fügen Sie sie unten ein.',
-        signInUrl: 'Falls sich der Anmelde-Tab nicht geöffnet hat, klicken Sie auf diese URL:',
-        label: 'Callback-URL hier einfügen',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: 'Verbindung abschließen',
+      device: {
+        title: 'Bambuddy in Orca Cloud genehmigen',
+        instruction: 'Öffnen Sie Orca Cloud und genehmigen Sie diesen Code. Bambuddy verbindet sich automatisch, sobald Sie genehmigt haben.',
+        codeLabel: 'Ihr Kopplungscode',
+        openButton: 'Orca Cloud-Genehmigungsseite öffnen',
+        manualHint: 'Oder gehen Sie zu {{url}} und geben Sie den obigen Code ein.',
+        waiting: 'Warte auf Ihre Genehmigung…',
+        cancel: 'Abbrechen',
       },
       profiles: {
         title: 'Ihre Orca Cloud-Profile ({{count}})',
@@ -3293,16 +3280,13 @@ export default {
         empty: 'Noch keine Profile in Ihrem Orca Cloud-Konto gefunden.',
       },
       toast: {
-        connected: 'Mit Orca Cloud verbunden als {{email}}',
         disconnected: 'Verbindung zu Orca Cloud getrennt',
       },
       errors: {
         startFailed: 'Anmeldevorgang für Orca Cloud konnte nicht gestartet werden.',
-        finishFailed: 'Orca Cloud-Anmeldung konnte nicht abgeschlossen werden.',
-        passwordFailed: 'Anmeldung mit dieser E-Mail und diesem Passwort fehlgeschlagen.',
-        passwordEmpty: 'Bitte geben Sie sowohl E-Mail als auch Passwort ein.',
-        emptyPaste: 'Bitte fügen Sie die Callback-URL aus Ihrem Browser ein.',
-        noCode: 'Diese URL sieht nicht wie ein Orca Cloud-Callback aus (kein code-Parameter). Kopieren Sie die vollständige URL aus der Adressleiste.',
+        denied: 'Die Kopplung wurde in Orca Cloud abgelehnt.',
+        expired: 'Der Kopplungscode ist abgelaufen. Klicken Sie auf Verbinden, um es erneut zu versuchen.',
+        pollFailed: 'Die Verbindung ging beim Warten auf die Genehmigung verloren. Bitte versuchen Sie es erneut.',
       },
     },
     localProfiles: {

+ 14 - 30
frontend/src/i18n/locales/en.ts

@@ -3284,37 +3284,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: 'Connected as',
+      connectedShort: 'Connected to Orca Cloud',
       logout: 'Disconnect',
       noLogoutPermission: 'You do not have permission to disconnect',
       noConnectPermission: 'You do not have permission to connect to Orca Cloud',
       retry: 'Retry',
-      back: 'Use a different sign-in method',
+      connectButton: 'Connect Orca Cloud',
       connect: {
         title: 'Connect to Orca Cloud',
         description: 'Sign in to your Orca Cloud account to sync your slicer profiles into Bambuddy.',
       },
-      providers: {
-        google: 'Sign in with Google',
-        apple: 'Sign in with Apple',
-        github: 'Sign in with GitHub',
-        email: 'Sign in with email and password',
-      },
-      password: {
-        title: 'Sign in with email and password',
-        email: 'Email',
-        emailPlaceholder: 'you@example.com',
-        password: 'Password',
-        submit: 'Sign in',
-      },
-      paste: {
-        title: 'Finish signing in',
-        step1: 'A new tab opened with the Orca Cloud sign-in page. Sign in with your Orca account.',
-        step2: 'Your browser will be redirected to a "localhost" URL that fails to load. That is expected — the URL is what we need.',
-        step3: 'Copy the entire URL from your browser\'s address bar and paste it below.',
-        signInUrl: 'If the sign-in tab did not open, click this URL:',
-        label: 'Paste the callback URL here',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: 'Finish connecting',
+      device: {
+        title: 'Approve Bambuddy in Orca Cloud',
+        instruction: 'Open Orca Cloud and approve this code. Bambuddy connects automatically once you approve.',
+        codeLabel: 'Your pairing code',
+        openButton: 'Open Orca Cloud approval page',
+        manualHint: 'Or go to {{url}} and enter the code above.',
+        waiting: 'Waiting for you to approve…',
+        cancel: 'Cancel',
       },
       profiles: {
         title: 'Your Orca Cloud profiles ({{count}})',
@@ -3322,16 +3309,13 @@ export default {
         empty: 'No profiles found in your Orca Cloud account yet.',
       },
       toast: {
-        connected: 'Connected to Orca Cloud as {{email}}',
         disconnected: 'Disconnected from Orca Cloud',
       },
       errors: {
-        startFailed: 'Could not start the Orca Cloud sign-in flow.',
-        finishFailed: 'Could not finish the Orca Cloud sign-in.',
-        passwordFailed: 'Could not sign in with that email and password.',
-        passwordEmpty: 'Please enter both your email and password.',
-        emptyPaste: 'Please paste the callback URL from your browser.',
-        noCode: 'That URL does not look like an Orca Cloud callback (no code parameter). Copy the full URL from your address bar.',
+        startFailed: 'Could not start Orca Cloud pairing.',
+        denied: 'The pairing was denied in Orca Cloud.',
+        expired: 'The pairing code expired. Click Connect to try again.',
+        pollFailed: 'Lost the connection while waiting for approval. Please try again.',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/es.ts

@@ -3258,37 +3258,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: 'Conectado como',
+      connectedShort: 'Conectado a Orca Cloud',
       logout: 'Desconectar',
       noLogoutPermission: 'No tienes permiso para desconectar',
       noConnectPermission: 'No tienes permiso para conectar a Orca Cloud',
       retry: 'Reintentar',
-      back: 'Usar otro método de inicio de sesión',
+      connectButton: 'Conectar Orca Cloud',
       connect: {
         title: 'Conectar a Orca Cloud',
         description: 'Inicia sesión en tu cuenta Orca Cloud para sincronizar tus perfiles de slicer en Bambuddy.',
       },
-      providers: {
-        google: 'Iniciar sesión con Google',
-        apple: 'Iniciar sesión con Apple',
-        github: 'Iniciar sesión con GitHub',
-        email: 'Iniciar sesión con correo y contraseña',
-      },
-      password: {
-        title: 'Iniciar sesión con correo y contraseña',
-        email: 'Correo electrónico',
-        emailPlaceholder: 'tu@ejemplo.com',
-        password: 'Contraseña',
-        submit: 'Iniciar sesión',
-      },
-      paste: {
-        title: 'Finalizar inicio de sesión',
-        step1: 'Se abrió una nueva pestaña con la página de inicio de sesión de Orca Cloud. Inicia sesión con tu cuenta de Orca.',
-        step2: 'Tu navegador será redirigido a una URL "localhost" que no se cargará. Es lo esperado — esa URL es lo que necesitamos.',
-        step3: 'Copia la URL completa desde la barra de direcciones del navegador y pégala abajo.',
-        signInUrl: 'Si la pestaña de inicio de sesión no se abrió, haz clic en esta URL:',
-        label: 'Pega aquí la URL de callback',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: 'Finalizar conexión',
+      device: {
+        title: 'Aprueba Bambuddy en Orca Cloud',
+        instruction: 'Abre Orca Cloud y aprueba este código. Bambuddy se conecta automáticamente en cuanto lo apruebes.',
+        codeLabel: 'Tu código de emparejamiento',
+        openButton: 'Abrir la página de aprobación de Orca Cloud',
+        manualHint: 'O ve a {{url}} e introduce el código de arriba.',
+        waiting: 'Esperando a que apruebes…',
+        cancel: 'Cancelar',
       },
       profiles: {
         title: 'Tus perfiles de Orca Cloud ({{count}})',
@@ -3296,16 +3283,13 @@ export default {
         empty: 'Aún no hay perfiles en tu cuenta de Orca Cloud.',
       },
       toast: {
-        connected: 'Conectado a Orca Cloud como {{email}}',
         disconnected: 'Desconectado de Orca Cloud',
       },
       errors: {
         startFailed: 'No se pudo iniciar el inicio de sesión de Orca Cloud.',
-        finishFailed: 'No se pudo finalizar el inicio de sesión de Orca Cloud.',
-        passwordFailed: 'No se pudo iniciar sesión con ese correo y contraseña.',
-        passwordEmpty: 'Por favor introduce tanto tu correo como tu contraseña.',
-        emptyPaste: 'Pega la URL de callback desde tu navegador.',
-        noCode: 'Esa URL no parece un callback de Orca Cloud (sin parámetro code). Copia la URL completa desde la barra de direcciones.',
+        denied: 'El emparejamiento fue rechazado en Orca Cloud.',
+        expired: 'El código de emparejamiento caducó. Haz clic en Conectar para intentarlo de nuevo.',
+        pollFailed: 'Se perdió la conexión mientras se esperaba la aprobación. Inténtalo de nuevo.',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/fr.ts

@@ -3244,37 +3244,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: 'Connecté en tant que',
+      connectedShort: 'Connecté à Orca Cloud',
       logout: 'Déconnecter',
       noLogoutPermission: 'Vous n\'avez pas la permission de vous déconnecter',
       noConnectPermission: 'Vous n\'avez pas la permission de vous connecter à Orca Cloud',
       retry: 'Réessayer',
-      back: 'Utiliser une autre méthode de connexion',
+      connectButton: 'Connecter Orca Cloud',
       connect: {
         title: 'Se connecter à Orca Cloud',
         description: 'Connectez-vous à votre compte Orca Cloud pour synchroniser vos profils de slicer dans Bambuddy.',
       },
-      providers: {
-        google: 'Se connecter avec Google',
-        apple: 'Se connecter avec Apple',
-        github: 'Se connecter avec GitHub',
-        email: 'Se connecter avec e-mail et mot de passe',
-      },
-      password: {
-        title: 'Se connecter avec e-mail et mot de passe',
-        email: 'E-mail',
-        emailPlaceholder: 'vous@exemple.fr',
-        password: 'Mot de passe',
-        submit: 'Se connecter',
-      },
-      paste: {
-        title: 'Terminer la connexion',
-        step1: 'Un nouvel onglet s\'est ouvert avec la page de connexion Orca Cloud. Connectez-vous avec votre compte Orca.',
-        step2: 'Votre navigateur sera redirigé vers une URL "localhost" qui ne se chargera pas. C\'est normal — c\'est cette URL qu\'il nous faut.',
-        step3: 'Copiez l\'URL complète depuis la barre d\'adresse de votre navigateur et collez-la ci-dessous.',
-        signInUrl: 'Si l\'onglet de connexion ne s\'est pas ouvert, cliquez sur cette URL :',
-        label: 'Collez l\'URL de rappel ici',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: 'Terminer la connexion',
+      device: {
+        title: 'Approuver Bambuddy dans Orca Cloud',
+        instruction: 'Ouvrez Orca Cloud et approuvez ce code. Bambuddy se connecte automatiquement dès que vous l\'approuvez.',
+        codeLabel: 'Votre code d\'appairage',
+        openButton: 'Ouvrir la page d\'approbation d\'Orca Cloud',
+        manualHint: 'Ou rendez-vous sur {{url}} et saisissez le code ci-dessus.',
+        waiting: 'En attente de votre approbation…',
+        cancel: 'Annuler',
       },
       profiles: {
         title: 'Vos profils Orca Cloud ({{count}})',
@@ -3282,16 +3269,13 @@ export default {
         empty: 'Aucun profil trouvé dans votre compte Orca Cloud pour le moment.',
       },
       toast: {
-        connected: 'Connecté à Orca Cloud en tant que {{email}}',
         disconnected: 'Déconnecté d\'Orca Cloud',
       },
       errors: {
         startFailed: 'Impossible de démarrer la connexion à Orca Cloud.',
-        finishFailed: 'Impossible de terminer la connexion à Orca Cloud.',
-        passwordFailed: 'Impossible de se connecter avec cet e-mail et ce mot de passe.',
-        passwordEmpty: 'Veuillez saisir à la fois votre e-mail et votre mot de passe.',
-        emptyPaste: 'Veuillez coller l\'URL de rappel depuis votre navigateur.',
-        noCode: 'Cette URL ne ressemble pas à un rappel Orca Cloud (aucun paramètre code). Copiez l\'URL complète depuis la barre d\'adresse.',
+        denied: 'L\'appairage a été refusé dans Orca Cloud.',
+        expired: 'Le code d\'appairage a expiré. Cliquez sur Connecter pour réessayer.',
+        pollFailed: 'La connexion a été perdue pendant l\'attente de l\'approbation. Veuillez réessayer.',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/it.ts

@@ -3243,37 +3243,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: 'Connesso come',
+      connectedShort: 'Connesso a Orca Cloud',
       logout: 'Disconnetti',
       noLogoutPermission: 'Non hai il permesso di disconnetterti',
       noConnectPermission: 'Non hai il permesso di connetterti a Orca Cloud',
       retry: 'Riprova',
-      back: 'Usa un altro metodo di accesso',
+      connectButton: 'Connetti Orca Cloud',
       connect: {
         title: 'Connetti a Orca Cloud',
         description: 'Accedi al tuo account Orca Cloud per sincronizzare i profili dello slicer in Bambuddy.',
       },
-      providers: {
-        google: 'Accedi con Google',
-        apple: 'Accedi con Apple',
-        github: 'Accedi con GitHub',
-        email: 'Accedi con email e password',
-      },
-      password: {
-        title: 'Accedi con email e password',
-        email: 'Email',
-        emailPlaceholder: 'tu@esempio.it',
-        password: 'Password',
-        submit: 'Accedi',
-      },
-      paste: {
-        title: 'Completa l\'accesso',
-        step1: 'Si è aperta una nuova scheda con la pagina di accesso di Orca Cloud. Accedi con il tuo account Orca.',
-        step2: 'Il browser verrà reindirizzato a un URL "localhost" che non riuscirà a caricarsi. È normale — è proprio quell\'URL che ci serve.',
-        step3: 'Copia l\'intero URL dalla barra degli indirizzi del browser e incollalo qui sotto.',
-        signInUrl: 'Se la scheda di accesso non si è aperta, clicca su questo URL:',
-        label: 'Incolla qui l\'URL di callback',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: 'Completa la connessione',
+      device: {
+        title: 'Approva Bambuddy in Orca Cloud',
+        instruction: 'Apri Orca Cloud e approva questo codice. Bambuddy si connette automaticamente non appena approvi.',
+        codeLabel: 'Il tuo codice di associazione',
+        openButton: 'Apri la pagina di approvazione di Orca Cloud',
+        manualHint: 'Oppure vai su {{url}} e inserisci il codice qui sopra.',
+        waiting: 'In attesa della tua approvazione…',
+        cancel: 'Annulla',
       },
       profiles: {
         title: 'I tuoi profili Orca Cloud ({{count}})',
@@ -3281,16 +3268,13 @@ export default {
         empty: 'Nessun profilo trovato nel tuo account Orca Cloud.',
       },
       toast: {
-        connected: 'Connesso a Orca Cloud come {{email}}',
         disconnected: 'Disconnesso da Orca Cloud',
       },
       errors: {
         startFailed: 'Impossibile avviare l\'accesso a Orca Cloud.',
-        finishFailed: 'Impossibile completare l\'accesso a Orca Cloud.',
-        passwordFailed: 'Impossibile accedere con quell\'email e password.',
-        passwordEmpty: 'Inserisci sia l\'email che la password.',
-        emptyPaste: 'Incolla l\'URL di callback dal tuo browser.',
-        noCode: 'Questo URL non sembra un callback di Orca Cloud (manca il parametro code). Copia l\'URL completo dalla barra degli indirizzi.',
+        denied: 'L\'associazione è stata rifiutata in Orca Cloud.',
+        expired: 'Il codice di associazione è scaduto. Fai clic su Connetti per riprovare.',
+        pollFailed: 'Connessione persa durante l\'attesa dell\'approvazione. Riprova.',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/ja.ts

@@ -3255,37 +3255,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: '接続中',
+      connectedShort: 'Orca Cloudに接続済み',
       logout: '切断',
       noLogoutPermission: '切断する権限がありません',
       noConnectPermission: 'Orca Cloudに接続する権限がありません',
       retry: '再試行',
-      back: '別のサインイン方法を使用',
+      connectButton: 'Orca Cloudに接続',
       connect: {
         title: 'Orca Cloudに接続',
         description: 'Orca Cloudアカウントにサインインして、スライサープロファイルをBambuddyに同期します。',
       },
-      providers: {
-        google: 'Googleでサインイン',
-        apple: 'Appleでサインイン',
-        github: 'GitHubでサインイン',
-        email: 'メールとパスワードでサインイン',
-      },
-      password: {
-        title: 'メールとパスワードでサインイン',
-        email: 'メールアドレス',
-        emailPlaceholder: 'you@example.com',
-        password: 'パスワード',
-        submit: 'サインイン',
-      },
-      paste: {
-        title: 'サインインを完了',
-        step1: 'Orca Cloudのサインインページが新しいタブで開きました。Orcaアカウントでサインインしてください。',
-        step2: 'ブラウザは「localhost」のURLにリダイレクトされ、読み込みに失敗します。それは想定通りです — そのURLが必要です。',
-        step3: 'ブラウザのアドレスバーからURL全体をコピーして、下に貼り付けてください。',
-        signInUrl: 'サインインタブが開かなかった場合は、このURLをクリックしてください:',
-        label: 'コールバックURLをここに貼り付け',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: '接続を完了',
+      device: {
+        title: 'Orca CloudでBambuddyを承認',
+        instruction: 'Orca Cloudを開いてこのコードを承認してください。承認するとBambuddyが自動的に接続されます。',
+        codeLabel: 'ペアリングコード',
+        openButton: 'Orca Cloud承認ページを開く',
+        manualHint: 'または {{url}} にアクセスして、上記のコードを入力してください。',
+        waiting: '承認をお待ちしています…',
+        cancel: 'キャンセル',
       },
       profiles: {
         title: 'Orca Cloudプロファイル ({{count}})',
@@ -3293,16 +3280,13 @@ export default {
         empty: 'Orca Cloudアカウントにまだプロファイルがありません。',
       },
       toast: {
-        connected: '{{email}}としてOrca Cloudに接続しました',
         disconnected: 'Orca Cloudから切断しました',
       },
       errors: {
         startFailed: 'Orca Cloudのサインインを開始できませんでした。',
-        finishFailed: 'Orca Cloudのサインインを完了できませんでした。',
-        passwordFailed: 'そのメールとパスワードでサインインできませんでした。',
-        passwordEmpty: 'メールアドレスとパスワードの両方を入力してください。',
-        emptyPaste: 'ブラウザからコールバックURLを貼り付けてください。',
-        noCode: 'このURLはOrca Cloudのコールバックではないようです (codeパラメータがありません)。アドレスバーから完全なURLをコピーしてください。',
+        denied: 'Orca Cloudでペアリングが拒否されました。',
+        expired: 'ペアリングコードの有効期限が切れました。「接続」をクリックしてもう一度お試しください。',
+        pollFailed: '承認の待機中に接続が失われました。もう一度お試しください。',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/ko.ts

@@ -3079,37 +3079,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: '연결됨',
+      connectedShort: 'Orca Cloud에 연결됨',
       logout: '연결 해제',
       noLogoutPermission: '연결을 해제할 권한이 없습니다',
       noConnectPermission: 'Orca Cloud에 연결할 권한이 없습니다',
       retry: '다시 시도',
-      back: '다른 로그인 방법 사용',
+      connectButton: 'Orca Cloud 연결',
       connect: {
         title: 'Orca Cloud에 연결',
         description: 'Orca Cloud 계정에 로그인하여 슬라이서 프로필을 Bambuddy에 동기화하세요.',
       },
-      providers: {
-        google: 'Google로 로그인',
-        apple: 'Apple로 로그인',
-        github: 'GitHub로 로그인',
-        email: '이메일과 비밀번호로 로그인',
-      },
-      password: {
-        title: '이메일과 비밀번호로 로그인',
-        email: '이메일',
-        emailPlaceholder: 'you@example.com',
-        password: '비밀번호',
-        submit: '로그인',
-      },
-      paste: {
-        title: '로그인 완료',
-        step1: '새 탭에서 Orca Cloud 로그인 페이지가 열렸습니다. Orca 계정으로 로그인하세요.',
-        step2: '브라우저가 로드되지 않는 "localhost" URL로 리디렉션됩니다. 이것은 정상입니다 — 우리에게 필요한 것은 그 URL입니다.',
-        step3: '브라우저의 주소 표시줄에서 전체 URL을 복사하여 아래에 붙여넣으세요.',
-        signInUrl: '로그인 탭이 열리지 않은 경우 이 URL을 클릭하세요:',
-        label: '여기에 콜백 URL 붙여넣기',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: '연결 완료',
+      device: {
+        title: 'Orca Cloud에서 Bambuddy 승인',
+        instruction: 'Orca Cloud를 열고 이 코드를 승인하세요. 승인하면 Bambuddy가 자동으로 연결됩니다.',
+        codeLabel: '페어링 코드',
+        openButton: 'Orca Cloud 승인 페이지 열기',
+        manualHint: '또는 {{url}}(으)로 이동하여 위 코드를 입력하세요.',
+        waiting: '승인을 기다리는 중…',
+        cancel: '취소',
       },
       profiles: {
         title: 'Orca Cloud 프로필 ({{count}})',
@@ -3117,16 +3104,13 @@ export default {
         empty: 'Orca Cloud 계정에 아직 프로필이 없습니다.',
       },
       toast: {
-        connected: '{{email}}로 Orca Cloud에 연결됨',
         disconnected: 'Orca Cloud 연결 해제됨',
       },
       errors: {
         startFailed: 'Orca Cloud 로그인 흐름을 시작할 수 없습니다.',
-        finishFailed: 'Orca Cloud 로그인을 완료할 수 없습니다.',
-        passwordFailed: '해당 이메일과 비밀번호로 로그인할 수 없습니다.',
-        passwordEmpty: '이메일과 비밀번호를 모두 입력하세요.',
-        emptyPaste: '브라우저에서 콜백 URL을 붙여넣으세요.',
-        noCode: '해당 URL은 Orca Cloud 콜백이 아닌 것 같습니다 (code 매개변수 없음). 주소 표시줄에서 전체 URL을 복사하세요.',
+        denied: 'Orca Cloud에서 페어링이 거부되었습니다.',
+        expired: '페어링 코드가 만료되었습니다. 연결을 클릭하여 다시 시도하세요.',
+        pollFailed: '승인을 기다리는 중 연결이 끊겼습니다. 다시 시도하세요.',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/pt-BR.ts

@@ -3243,37 +3243,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: 'Conectado como',
+      connectedShort: 'Conectado ao Orca Cloud',
       logout: 'Desconectar',
       noLogoutPermission: 'Você não tem permissão para desconectar',
       noConnectPermission: 'Você não tem permissão para conectar ao Orca Cloud',
       retry: 'Tentar novamente',
-      back: 'Usar outro método de login',
+      connectButton: 'Conectar Orca Cloud',
       connect: {
         title: 'Conectar ao Orca Cloud',
         description: 'Entre na sua conta Orca Cloud para sincronizar seus perfis de slicer no Bambuddy.',
       },
-      providers: {
-        google: 'Entrar com Google',
-        apple: 'Entrar com Apple',
-        github: 'Entrar com GitHub',
-        email: 'Entrar com e-mail e senha',
-      },
-      password: {
-        title: 'Entrar com e-mail e senha',
-        email: 'E-mail',
-        emailPlaceholder: 'voce@exemplo.com.br',
-        password: 'Senha',
-        submit: 'Entrar',
-      },
-      paste: {
-        title: 'Concluir login',
-        step1: 'Uma nova aba abriu com a página de login do Orca Cloud. Entre com sua conta Orca.',
-        step2: 'Seu navegador será redirecionado para uma URL "localhost" que não carregará. Isso é esperado — é dessa URL que precisamos.',
-        step3: 'Copie a URL completa da barra de endereços do navegador e cole abaixo.',
-        signInUrl: 'Se a aba de login não abriu, clique nesta URL:',
-        label: 'Cole a URL de callback aqui',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: 'Concluir conexão',
+      device: {
+        title: 'Aprovar o Bambuddy no Orca Cloud',
+        instruction: 'Abra o Orca Cloud e aprove este código. O Bambuddy se conecta automaticamente assim que você aprovar.',
+        codeLabel: 'Seu código de pareamento',
+        openButton: 'Abrir a página de aprovação do Orca Cloud',
+        manualHint: 'Ou acesse {{url}} e insira o código acima.',
+        waiting: 'Aguardando sua aprovação…',
+        cancel: 'Cancelar',
       },
       profiles: {
         title: 'Seus perfis do Orca Cloud ({{count}})',
@@ -3281,16 +3268,13 @@ export default {
         empty: 'Nenhum perfil encontrado na sua conta Orca Cloud ainda.',
       },
       toast: {
-        connected: 'Conectado ao Orca Cloud como {{email}}',
         disconnected: 'Desconectado do Orca Cloud',
       },
       errors: {
         startFailed: 'Não foi possível iniciar o login do Orca Cloud.',
-        finishFailed: 'Não foi possível concluir o login do Orca Cloud.',
-        passwordFailed: 'Não foi possível entrar com esse e-mail e senha.',
-        passwordEmpty: 'Insira o e-mail e a senha.',
-        emptyPaste: 'Cole a URL de callback do seu navegador.',
-        noCode: 'Essa URL não parece ser um callback do Orca Cloud (sem parâmetro code). Copie a URL completa da barra de endereços.',
+        denied: 'O pareamento foi negado no Orca Cloud.',
+        expired: 'O código de pareamento expirou. Clique em Conectar para tentar novamente.',
+        pollFailed: 'A conexão foi perdida enquanto aguardava a aprovação. Tente novamente.',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/tr.ts

@@ -3259,37 +3259,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: 'Bağlı kullanıcı',
+      connectedShort: 'Orca Cloud\'a bağlanıldı',
       logout: 'Bağlantıyı kes',
       noLogoutPermission: 'Bağlantıyı kesme izniniz yok',
       noConnectPermission: 'Orca Cloud\'a bağlanma izniniz yok',
       retry: 'Yeniden dene',
-      back: 'Farklı bir giriş yöntemi kullan',
+      connectButton: 'Orca Cloud\'a bağlan',
       connect: {
         title: 'Orca Cloud\'a bağlan',
         description: 'Dilimleyici profillerinizi Bambuddy ile senkronize etmek için Orca Cloud hesabınıza giriş yapın.',
       },
-      providers: {
-        google: 'Google ile giriş yap',
-        apple: 'Apple ile giriş yap',
-        github: 'GitHub ile giriş yap',
-        email: 'E-posta ve şifre ile giriş yap',
-      },
-      password: {
-        title: 'E-posta ve şifre ile giriş yap',
-        email: 'E-posta',
-        emailPlaceholder: 'sen@ornek.com',
-        password: 'Şifre',
-        submit: 'Giriş yap',
-      },
-      paste: {
-        title: 'Girişi tamamla',
-        step1: 'Yeni bir sekme Orca Cloud giriş sayfasıyla açıldı. Orca hesabınızla giriş yapın.',
-        step2: 'Tarayıcınız yüklenemeyen bir "localhost" URL\'sine yönlendirilecektir. Bu beklenen bir durumdur — bize gereken URL budur.',
-        step3: 'Tarayıcınızın adres çubuğundaki URL\'nin tamamını kopyalayın ve aşağıya yapıştırın.',
-        signInUrl: 'Giriş sekmesi açılmadıysa bu URL\'ye tıklayın:',
-        label: 'Geri çağrı URL\'sini buraya yapıştırın',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: 'Bağlantıyı tamamla',
+      device: {
+        title: 'Orca Cloud\'da Bambuddy\'yi onayla',
+        instruction: 'Orca Cloud\'u açın ve bu kodu onaylayın. Onayladığınızda Bambuddy otomatik olarak bağlanır.',
+        codeLabel: 'Eşleştirme kodunuz',
+        openButton: 'Orca Cloud onay sayfasını aç',
+        manualHint: 'Ya da {{url}} adresine gidin ve yukarıdaki kodu girin.',
+        waiting: 'Onayınız bekleniyor…',
+        cancel: 'İptal',
       },
       profiles: {
         title: 'Orca Cloud profilleriniz ({{count}})',
@@ -3297,16 +3284,13 @@ export default {
         empty: 'Orca Cloud hesabınızda henüz profil bulunamadı.',
       },
       toast: {
-        connected: '{{email}} olarak Orca Cloud\'a bağlanıldı',
         disconnected: 'Orca Cloud bağlantısı kesildi',
       },
       errors: {
         startFailed: 'Orca Cloud giriş akışı başlatılamadı.',
-        finishFailed: 'Orca Cloud girişi tamamlanamadı.',
-        passwordFailed: 'Bu e-posta ve şifreyle giriş yapılamadı.',
-        passwordEmpty: 'Lütfen hem e-postanızı hem de şifrenizi girin.',
-        emptyPaste: 'Lütfen tarayıcınızdan geri çağrı URL\'sini yapıştırın.',
-        noCode: 'Bu URL bir Orca Cloud geri çağrısına benzemiyor (code parametresi yok). Tam URL\'yi adres çubuğundan kopyalayın.',
+        denied: 'Eşleştirme Orca Cloud\'da reddedildi.',
+        expired: 'Eşleştirme kodunun süresi doldu. Tekrar denemek için Bağlan\'a tıklayın.',
+        pollFailed: 'Onay beklenirken bağlantı kesildi. Lütfen tekrar deneyin.',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/zh-CN.ts

@@ -3243,37 +3243,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: '已连接',
+      connectedShort: '已连接到 Orca Cloud',
       logout: '断开连接',
       noLogoutPermission: '您没有断开连接的权限',
       noConnectPermission: '您没有连接到 Orca Cloud 的权限',
       retry: '重试',
-      back: '使用其他登录方式',
+      connectButton: '连接 Orca Cloud',
       connect: {
         title: '连接到 Orca Cloud',
         description: '登录您的 Orca Cloud 账户,将切片机配置同步到 Bambuddy。',
       },
-      providers: {
-        google: '使用 Google 登录',
-        apple: '使用 Apple 登录',
-        github: '使用 GitHub 登录',
-        email: '使用邮箱和密码登录',
-      },
-      password: {
-        title: '使用邮箱和密码登录',
-        email: '邮箱',
-        emailPlaceholder: 'you@example.com',
-        password: '密码',
-        submit: '登录',
-      },
-      paste: {
-        title: '完成登录',
-        step1: '已在新标签页中打开 Orca Cloud 登录页面。请使用您的 Orca 账户登录。',
-        step2: '您的浏览器将被重定向到一个 "localhost" URL,该 URL 无法加载。这是正常的 — 我们需要的就是这个 URL。',
-        step3: '从浏览器的地址栏复制整个 URL,粘贴到下方。',
-        signInUrl: '如果登录标签页未打开,请点击此 URL:',
-        label: '在此处粘贴回调 URL',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: '完成连接',
+      device: {
+        title: '在 Orca Cloud 中批准 Bambuddy',
+        instruction: '打开 Orca Cloud 并批准此代码。批准后 Bambuddy 会自动连接。',
+        codeLabel: '您的配对代码',
+        openButton: '打开 Orca Cloud 批准页面',
+        manualHint: '或前往 {{url}} 并输入上方的代码。',
+        waiting: '正在等待您批准…',
+        cancel: '取消',
       },
       profiles: {
         title: '您的 Orca Cloud 配置文件 ({{count}})',
@@ -3281,16 +3268,13 @@ export default {
         empty: '您的 Orca Cloud 账户中尚无配置文件。',
       },
       toast: {
-        connected: '已以 {{email}} 身份连接到 Orca Cloud',
         disconnected: '已从 Orca Cloud 断开连接',
       },
       errors: {
         startFailed: '无法启动 Orca Cloud 登录流程。',
-        finishFailed: '无法完成 Orca Cloud 登录。',
-        passwordFailed: '无法使用该邮箱和密码登录。',
-        passwordEmpty: '请输入邮箱和密码。',
-        emptyPaste: '请从浏览器粘贴回调 URL。',
-        noCode: '该 URL 不像 Orca Cloud 回调 (缺少 code 参数)。请从地址栏复制完整 URL。',
+        denied: '配对在 Orca Cloud 中被拒绝。',
+        expired: '配对代码已过期。请点击"连接"重试。',
+        pollFailed: '等待批准时连接中断。请重试。',
       },
     },
     localProfiles: {

+ 13 - 29
frontend/src/i18n/locales/zh-TW.ts

@@ -3243,37 +3243,24 @@ export default {
     },
     orcaCloud: {
       connectedAs: '已連接',
+      connectedShort: '已連接到 Orca Cloud',
       logout: '中斷連線',
       noLogoutPermission: '您沒有中斷連線的權限',
       noConnectPermission: '您沒有連接到 Orca Cloud 的權限',
       retry: '重試',
-      back: '使用其他登入方式',
+      connectButton: '連接 Orca Cloud',
       connect: {
         title: '連接到 Orca Cloud',
         description: '登入您的 Orca Cloud 帳號,將切片機設定檔同步到 Bambuddy。',
       },
-      providers: {
-        google: '使用 Google 登入',
-        apple: '使用 Apple 登入',
-        github: '使用 GitHub 登入',
-        email: '使用電子郵件和密碼登入',
-      },
-      password: {
-        title: '使用電子郵件和密碼登入',
-        email: '電子郵件',
-        emailPlaceholder: 'you@example.com',
-        password: '密碼',
-        submit: '登入',
-      },
-      paste: {
-        title: '完成登入',
-        step1: '已在新分頁中開啟 Orca Cloud 登入頁面。請使用您的 Orca 帳號登入。',
-        step2: '您的瀏覽器將被重新導向到一個 "localhost" URL,該 URL 無法載入。這是正常的 — 我們需要的就是這個 URL。',
-        step3: '從瀏覽器的網址列複製整個 URL,貼到下方。',
-        signInUrl: '若登入分頁未開啟,請點擊此 URL:',
-        label: '在此貼上回呼 URL',
-        placeholder: 'http://localhost:41172/callback?code=...&state=...',
-        submit: '完成連接',
+      device: {
+        title: '在 Orca Cloud 中核准 Bambuddy',
+        instruction: '開啟 Orca Cloud 並核准此代碼。核准後 Bambuddy 會自動連接。',
+        codeLabel: '您的配對代碼',
+        openButton: '開啟 Orca Cloud 核准頁面',
+        manualHint: '或前往 {{url}} 並輸入上方的代碼。',
+        waiting: '正在等待您核准…',
+        cancel: '取消',
       },
       profiles: {
         title: '您的 Orca Cloud 設定檔 ({{count}})',
@@ -3281,16 +3268,13 @@ export default {
         empty: '您的 Orca Cloud 帳號中尚無設定檔。',
       },
       toast: {
-        connected: '已以 {{email}} 身分連接到 Orca Cloud',
         disconnected: '已從 Orca Cloud 中斷連線',
       },
       errors: {
         startFailed: '無法啟動 Orca Cloud 登入流程。',
-        finishFailed: '無法完成 Orca Cloud 登入。',
-        passwordFailed: '無法使用該電子郵件和密碼登入。',
-        passwordEmpty: '請輸入電子郵件和密碼。',
-        emptyPaste: '請從瀏覽器貼上回呼 URL。',
-        noCode: '該 URL 不像 Orca Cloud 回呼 (缺少 code 參數)。請從網址列複製完整 URL。',
+        denied: '配對在 Orca Cloud 中被拒絕。',
+        expired: '配對代碼已過期。請點擊「連接」重試。',
+        pollFailed: '等待核准時連線中斷。請重試。',
       },
     },
     localProfiles: {

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-0K1eW7FA.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 0
static/assets/index-BikDm6kr.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 1
static/assets/index-UoLGEHs-.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Cf3DYu3Y.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-UoLGEHs-.css">
+    <script type="module" crossorigin src="/assets/index-0K1eW7FA.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-BikDm6kr.css">
   </head>
   <body>
     <div id="root"></div>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio