From 18d534c9457a596a96a26401cbb3489081a4a060 Mon Sep 17 00:00:00 2001 From: maziggy Date: Thu, 4 Jun 2026 15:50:44 +0200 Subject: [PATCH] feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_ to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension) --- CHANGELOG.md | 3 + README.md | 3 +- backend/app/api/routes/orca_cloud.py | 666 +++++++++++++ backend/app/api/routes/slicer_presets.py | 158 ++- backend/app/core/auth.py | 6 + backend/app/core/database.py | 21 + backend/app/core/permissions.py | 2 + backend/app/main.py | 2 + backend/app/models/user.py | 14 + backend/app/schemas/orca_cloud.py | 94 ++ backend/app/schemas/slicer.py | 17 +- backend/app/schemas/slicer_presets.py | 20 +- backend/app/services/orca_cloud.py | 504 ++++++++++ backend/app/services/preset_resolver.py | 62 ++ .../tests/unit/services/test_orca_cloud.py | 433 +++++++++ .../unit/services/test_preset_resolver.py | 77 ++ backend/tests/unit/test_slicer_presets.py | 137 ++- frontend/public/sw.js | 41 +- frontend/scripts/check-i18n-parity.mjs | 11 + .../components/OrcaCloudView.test.tsx | 211 ++++ .../__tests__/components/SliceModal.test.tsx | 10 +- frontend/src/api/client.ts | 81 +- .../src/components/ConfigureAmsSlotModal.tsx | 160 ++- .../src/components/OrcaCloudProfilesView.tsx | 399 ++++++++ frontend/src/components/OrcaCloudView.tsx | 515 ++++++++++ frontend/src/components/SliceModal.tsx | 80 +- frontend/src/components/SpoolFormModal.tsx | 47 +- frontend/src/i18n/locales/de.ts | 65 +- frontend/src/i18n/locales/en.ts | 65 +- frontend/src/i18n/locales/es.ts | 65 +- frontend/src/i18n/locales/fr.ts | 65 +- frontend/src/i18n/locales/it.ts | 65 +- frontend/src/i18n/locales/ja.ts | 65 +- frontend/src/i18n/locales/ko.ts | 69 +- frontend/src/i18n/locales/pt-BR.ts | 65 +- frontend/src/i18n/locales/tr.ts | 65 +- frontend/src/i18n/locales/zh-CN.ts | 65 +- frontend/src/i18n/locales/zh-TW.ts | 65 +- frontend/src/pages/ProfilesPage.tsx | 21 +- .../spoolbuddy/SpoolBuddyWriteTagPage.tsx | 52 +- .../{index-CYiRVzBv.js => index-6Wj7SYfZ.js} | 908 +++++++++--------- static/assets/index-C3FyyVE7.css | 1 - static/assets/index-Df3XYvpK.css | 1 + static/index.html | 4 +- static/sw.js | 41 +- 45 files changed, 4891 insertions(+), 630 deletions(-) create mode 100644 backend/app/api/routes/orca_cloud.py create mode 100644 backend/app/schemas/orca_cloud.py create mode 100644 backend/app/services/orca_cloud.py create mode 100644 backend/tests/unit/services/test_orca_cloud.py create mode 100644 frontend/src/__tests__/components/OrcaCloudView.test.tsx create mode 100644 frontend/src/components/OrcaCloudProfilesView.tsx create mode 100644 frontend/src/components/OrcaCloudView.tsx rename static/assets/{index-CYiRVzBv.js => index-6Wj7SYfZ.js} (62%) delete mode 100644 static/assets/index-C3FyyVE7.css create mode 100644 static/assets/index-Df3XYvpK.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d404fe78..c311d3d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.5b1] - Unreleased +### Added +- **Orca Cloud profile sync — end-to-end integration with the slicer + SpoolBuddy surfaces (OrcaSlicer/OrcaSlicer#14028 filed for upstream allowlist broadening)** — Bambuddy now reads, lists, and slices with profiles from your Orca Cloud account alongside the existing Bambu Cloud integration. OrcaSlicer 2.4.0-alpha shipped its own cloud (Supabase-backed at `auth.orcaslicer.com` / `api.orcaslicer.com`); this integrates with it using the in-source publishable client key, a standard PKCE handshake, and the `/api/v1/sync/pull` profile-sync endpoint. **Four sign-in providers**: Google, Apple, GitHub (paste-flow PKCE) and email+password (direct grant — Orca's web sign-in offers it even though their desktop SDK refuses); UI defaults to password with the three OAuth options listed below. **UX shape**: the Cloud Profiles tab is now two — "Bambu Cloud" (existing, unchanged) and "Orca Cloud" (new); the paste flow's "page will fail to load — that's expected" instruction is rendered as a prominent amber callout so the connection-refused page isn't mistaken for a Bambuddy error. The Orca Cloud tab renders the same rich profile-browser layout as Bambu Cloud (search + 5 filter dropdowns + 3-column grouped grid + click-to-detail) via a parallel `OrcaCloudProfilesView` component. We chose paste-flow rather than a clean OAuth callback because Orca's Supabase project only honors localhost in its `redirect_to` allowlist. **Slicer integration**: the unified-presets endpoint surfaces Orca Cloud as a 4th tier above Bambu Cloud > local > standard; `_dedupe_by_name` and the SliceModal dropdowns both updated to walk all 4 tiers. The dedicated `_fetch_orca_cloud_presets` extracts `filament_type` and `default_filament_colour` inline from each profile's content (cheap because `/sync/pull` returns full content per profile — no rate-limit dance like Bambu Cloud's per-setting fetch), so multi-color pre-pick scoring works against Orca presets too. A separate `CloudStatusBanner` instance shows Orca Cloud's auth status independently of Bambu's. **AMS slot integration**: `ConfigureAmsSlotModal` accepts `orca_cloud` as a new preset source (prefixed `orca_` to match the existing `local_*` / `builtin_*` convention), gracefully tolerating raw UUIDs from historical saves; Orca presets are treated like local imports for `tray_info_idx` derivation (no Bambu setting_id, generic filament-ID map by parsed material). Slot mapping persisted with `preset_source='orca_cloud'`. **SpoolBuddy integration**: `SpoolFormModal` and `SpoolBuddyWriteTagPage` fetch Bambu + Orca filaments in parallel via `Promise.allSettled` and concat; `ConfigureAmsSlotModal` opens from `SpoolBuddyAmsPage`'s Configure flow with Orca presets surfaced first. **Storage**: 8 new columns on `users` (5 persistent + 3 transient PKCE state with 10-min TTL), dialect-branched DATETIME / TIMESTAMP, verified on SQLite and Postgres. Auth-disabled mode falls back to global Settings table. **Refresh rotation**: Supabase issues single-use refresh tokens; service refreshes just-in-time (<5min leeway) and persists the new pair BEFORE the downstream call so a mid-flight crash doesn't strand the user. **Cloudflare**: `api.orcaslicer.com` is behind a UA-only gate; `Bambuddy/` clears it (no TLS-fingerprint games). Per the [[bambu-compliance-outreach]] posture we identify honestly. **Preset resolver**: `PresetRef.source` extended to `'orca_cloud' | 'cloud' | 'local' | 'standard'`; `_resolve_orca_cloud` lists, filters, and forwards profile content. **Permissions**: new explicit `orca_cloud:auth` flag (per [[feedback_specific_scopes_over_folding]]); folded into the existing `can_access_cloud` API-key scope (same trust dimension as Bambu Cloud — extending automatically rather than requiring a per-key opt-in). The orca_cloud router carries the same `_cloud_api_key_gate` + `cloud_caller()` deps as the Bambu Cloud router — a copy-paste miss caught only when the SpoolBuddy kiosk's API-keyed requests came back with empty preset lists from `/orca-cloud/profiles` because the plain `require_permission_if_auth_enabled` dep returns `None` for API-key callers, falling through to the global Settings table that doesn't carry per-user Orca tokens. **Load-bearing gotchas surfaced and fixed during the build** (captured in the `orca-cloud-integration` project-memory file so future contributors don't re-discover them): (a) Supabase silently falls back to the project Site URL when a client passes its own `state` to `/auth/v1/authorize` — overrides GoTrue's internal redirect_to tracking, browser lands at cloud.orcaslicer.com instead of localhost; we don't send state, PKCE alone gives CSRF protection. (b) `cursor=0` returns `410 cursor_too_old`; bare `/sync/pull` with no cursor parameter is the first-sync bootstrap, same as Orca's own client. (c) The `/api/v1/sync/profiles` constant is declared in source but isn't deployed — returns 404. (d) Orca's `content.type` vocabulary is `printer` / `print` / `filament`, not the BambuStudio `machine` / `process` / `filament` triplet you'd guess from the wider source; without alias mapping every printer + process profile gets silently dropped (caught against a real account showing 54 filament + 0 process + 0 printer instead of 54+18+3). (e) Naive datetimes from Postgres `TIMESTAMP WITHOUT TIME ZONE` columns get `.astimezone()` interpreted as local time on the read path, shifting freshly-stored pending PKCE state by the host's TZ offset and instant-firing the 10-min TTL — `_as_utc` normalises on load. **Tests**: 32 unit tests on the OrcaCloudService (PKCE / token exchange / single-use refresh rotation / rejected-refresh-clears-tokens / JIT refresh / profile walk + content.type mapping); 6 preset-resolver orca tier tests (permission gate, content unwrap, auth error 401, not-found 400, dispatcher routing); 6 new orca-fetch tests in test_slicer_presets.py paralleling the Bambu Cloud fetcher (status vocabulary, permission shortcut, cache hit, type vocabulary); existing SliceModal vitest updated for the 4-tier shape; 6 frontend OrcaCloudView tests (all four sign-in providers + paste flow + connected + disconnect). **i18n**: ~35 new keys translated in all 10 non-English locales (de/es/fr/it/ja/ko/pt-BR/tr/zh-CN/zh-TW); brand-name "Bambu Cloud" / "Orca Cloud" cognates allowlisted in the parity check; existing `tier.cloud` relabelled from "Cloud" to "Bambu Cloud" everywhere it was previously generic. **Service worker**: bumped to v29/v28 with a forced reload-on-activate so the SpoolBuddy kiosk (Pi + Chromium + locked into kiosk mode, no devtools, no way to navigate or refresh) picks up the new bundle on a single restart instead of needing two. **Verified**: backend ruff clean; full pytest pass at 5648 across the suite (-n 30 in 84s); frontend eslint + build + vitest 2051 clean; i18n parity green at 5054 leaves × 11 locales. + ### Changed - **Empty AMS units no longer trigger hourly humidity/temperature notifications (#1619)** — The hourly AMS sensor recorder in `backend/app/main.py::record_ams_history` fanned out humidity and temperature alarms for every AMS unit above threshold without checking whether the unit was actually loaded with filament. Empty AMS units still report ambient sensor readings, so users with one loaded AMS and one empty one got useful alarms for the loaded unit and steady noise for the empty one every hour. The reporter's workaround (disable all AMS humidity notifications) also killed the useful alarms — not a real choice. New `_ams_has_filament(ams_data)` helper inspects the firmware-reported `tray_exist_bits` hex bitmap (one bit per tray slot, `"0"` / `"00"` = empty unit) with a fallback to the `tray` array's `tray_type` strings for early-pushall shapes where the bitmap is missing. The recorder gates the alarm dispatch on this check per-AMS-unit, so a multi-AMS printer with one loaded + one empty still alarms on the loaded one. **Sensor history still records regardless of the gate** so the System page humidity/temperature charts stay continuous — the only thing the gate suppresses is the outbound notification. 9 unit tests in `test_ams_alarm_gating.py` cover the bitmap-zero-is-empty case, single/multi/all-loaded variants, the `tray_exist_bits` missing → tray-array fallback, garbage bitmap → fallback, blank bitmap → fallback, non-string bitmap → fallback (Bambu sometimes sends `int`), whitespace-only `tray_type` not counting as loaded, and defensive non-dict tray entries. diff --git a/README.md b/README.md index 43dadb356..7dabd9837 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R - MQTT publishing for Home Assistant, Node-RED, etc. - **Prometheus metrics** - Export printer telemetry for Grafana dashboards - Bambu Cloud profile management +- **Orca Cloud profile sync** — read your OrcaSlicer 2.4.0+ cloud-synced profiles directly in Bambuddy, usable for slicing alongside Bambu Cloud / local / standard presets. Four sign-in providers (Google / Apple / GitHub / email+password) - **Local Profiles** - Import OrcaSlicer presets (`.orca_filament`, `.bbscfg`, `.bbsflmt`, `.zip`, `.json`) without Bambu Cloud - K-profiles (pressure advance) - **GitHub backup** - Schedule automatic backups of cloud profiles, k profiles and settings to GitHub @@ -267,7 +268,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R - External sidebar links - Webhooks & API keys - Per-user ownership — each key acts on behalf of its creator - - Optional **cloud-access scope** — opt in to let an API key read its owner's Bambu Cloud presets / filament catalogue / device list (off by default) + - Optional **cloud-access scope** — opt in to let an API key read its owner's Bambu Cloud + Orca Cloud presets / filament catalogue / device list (off by default) - Interactive API browser with live testing ### 🖨️ Virtual Printer & Remote Printing diff --git a/backend/app/api/routes/orca_cloud.py b/backend/app/api/routes/orca_cloud.py new file mode 100644 index 000000000..5b09c9874 --- /dev/null +++ b/backend/app/api/routes/orca_cloud.py @@ -0,0 +1,666 @@ +""" +Orca Cloud API Routes + +PKCE-based connect/disconnect + profile sync endpoints for the +Orca Cloud (Supabase) profile-sync 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. + GET /orca-cloud/status + Connected/disconnected + email + 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). + 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. + 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. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.api.routes.cloud import _cloud_api_key_gate, cloud_caller +from backend.app.core.database import get_db +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, + OrcaProfileDetail, + OrcaProfileListResponse, + OrcaProfileMeta, +) +from backend.app.services.orca_cloud import ( + PENDING_PKCE_TTL, + OrcaCloudAuthError, + OrcaCloudError, + OrcaCloudService, + build_authorize_url, + generate_pkce, + parse_callback_url, +) + +logger = logging.getLogger(__name__) + +# Router-level dependency: enforce the same API-key cloud-access fence as the +# Bambu Cloud router (rejects ownerless legacy keys, requires the +# ``can_access_cloud`` scope, stashes the owner on ``request.state`` so +# per-route deps can resolve it as the effective ``current_user``). +# Without this gate the kiosk's API-keyed requests sail past with +# ``current_user=None`` → ``_build_authenticated_service`` falls back to +# the global Settings table → no Orca token → 401, no presets surfaced. +# Bambu Cloud works in the same kiosk because its router has this gate. +router = APIRouter(prefix="/orca-cloud", tags=["orca-cloud"], dependencies=[Depends(_cloud_api_key_gate)]) + +# Orca ``content.type`` values map onto Bambu Cloud's preset type vocabulary. +# Empirically (confirmed against a live account on 2026-06-04): Orca uses +# ``"printer"`` / ``"print"`` / ``"filament"`` — NOT the BambuStudio +# ``"machine"`` / ``"process"`` / ``"filament"`` triplet that lives elsewhere +# in the OrcaSlicer source. The aliases keep us forward-compatible if Orca +# ever flips back to the older naming. +_ORCA_TYPE_TO_BAMBU = { + "filament": "filament", + "printer": "printer", + "machine": "printer", # alias for the BambuStudio-style naming + "print": "process", + "process": "process", # alias for the BambuStudio-style naming +} + + +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.""" + content = orca_profile.get("content") or {} + if not isinstance(content, dict): + return None + bambu_type = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", ""))) + if bambu_type is None: + return None + pid = orca_profile.get("id") + if pid is None: + return None + updated = orca_profile.get("updated_time") + return OrcaProfileMeta( + setting_id=str(pid), + name=str(orca_profile.get("name") or pid), + type=bambu_type, + version=_str_or_none(content.get("version")), + # ``from`` distinguishes ``system`` (bundled) from ``User`` (custom), + # same field the Bambu source-of-truth uses for that distinction. + user_id=_str_or_none(content.get("user_id") or content.get("from")), + updated_time=str(updated) if updated is not None else None, + # Every profile that lives in the user's Orca Cloud account is by + # definition user-authored; bundled defaults aren't synced. + is_custom=True, + ) + + +def _str_or_none(value: object) -> str | None: + """Cast non-empty scalars to ``str``; pass ``None`` and empty values + through unchanged. Used to keep the response shape consistent when + Orca's source data has heterogenous typing for the same field.""" + if value is None: + return None + s = str(value) + return s if s else 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_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_at": "orca_cloud_pending_at", # ISO 8601 UTC string +} + + +# --------------------------------------------------------------------------- +# Storage helpers — bridge User-row vs Settings-table fallback transparently +# --------------------------------------------------------------------------- + + +def _iso(dt: datetime | None) -> str | None: + """Serialize a datetime to ISO 8601 UTC. ``None`` passes through.""" + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + + +def _as_utc(dt: datetime | None) -> datetime | None: + """Attach ``tzinfo=UTC`` to a naive datetime that we know was stored as + UTC. ``None`` passes through. Already-aware datetimes are converted to + UTC to normalize.""" + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _parse_iso(value: str | None) -> datetime | None: + """Parse an ISO 8601 string back to a UTC datetime. ``None`` passes through.""" + if not value: + return None + try: + dt = datetime.fromisoformat(value) + except (TypeError, ValueError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +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.""" + + __slots__ = ( + "token", + "refresh_token", + "expires_at", + "email", + "user_id", + "pending_verifier", + "pending_state", + "pending_at", + ) + + def __init__(self) -> None: + self.token: str | None = None + self.refresh_token: str | None = None + 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_at: datetime | None = None + + +async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredentials: + """Load stored Orca Cloud credentials for the caller (user-row when auth + is enabled, Settings fallback when auth is disabled). + + Datetimes coming back from the User row are NAIVE on the Postgres side + (asyncpg strips tzinfo for ``TIMESTAMP WITHOUT TIME ZONE`` columns) but + represent UTC moments because that's what we stored. We attach + ``tzinfo=UTC`` here so downstream comparisons against + ``datetime.now(timezone.utc)`` don't get shifted by the host's local + offset — ``naive_dt.astimezone(UTC)`` would assume local time, which on + a UTC+2 host turns a 1-minute-old pending state into a 2h1m one and + fires the 10-minute TTL guard immediately.""" + creds = _OrcaCredentials() + if user is not None: + creds.token = user.orca_cloud_token + creds.refresh_token = user.orca_cloud_refresh_token + 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_at = _as_utc(user.orca_cloud_pending_at) + return creds + + result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values())))) + raw = {s.key: s.value for s in result.scalars().all()} + creds.token = raw.get(_SETTINGS_KEYS["token"]) + creds.refresh_token = raw.get(_SETTINGS_KEYS["refresh_token"]) + 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_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"])) + return creds + + +async def _persist_pending_pkce( + db: AsyncSession, + user: User | None, + verifier: str, + state: str, + when: datetime, +) -> None: + """Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``.""" + 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_at=when, + ) + ) + await db.commit() + return + await _upsert_settings( + db, + { + _SETTINGS_KEYS["pending_verifier"]: verifier, + _SETTINGS_KEYS["pending_state"]: state, + _SETTINGS_KEYS["pending_at"]: _iso(when), + }, + ) + + +async def _persist_tokens( + db: AsyncSession, + user: User | None, + access_token: str, + refresh_token: str | None, + expires_at: datetime | None, + email: str | None, + 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.""" + if user is not None: + await db.execute( + update(User) + .where(User.id == user.id) + .values( + orca_cloud_token=access_token, + orca_cloud_refresh_token=refresh_token, + orca_cloud_expires_at=expires_at, + orca_cloud_email=email, + orca_cloud_user_id=user_id, + 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["token"]: access_token, + _SETTINGS_KEYS["refresh_token"]: refresh_token, + _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_at"]: None, + }, + ) + + +async def _persist_rotated_tokens( + db: AsyncSession, + user: User | None, + access_token: str, + refresh_token: str | None, + 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).""" + if user is not None: + await db.execute( + update(User) + .where(User.id == user.id) + .values( + orca_cloud_token=access_token, + orca_cloud_refresh_token=refresh_token, + orca_cloud_expires_at=expires_at, + ) + ) + await db.commit() + return + await _upsert_settings( + db, + { + _SETTINGS_KEYS["token"]: access_token, + _SETTINGS_KEYS["refresh_token"]: refresh_token, + _SETTINGS_KEYS["expires_at"]: _iso(expires_at), + }, + ) + + +async def _clear_credentials(db: AsyncSession, user: User | None) -> None: + """Wipe everything Orca-related (tokens, identity, pending state).""" + if user is not None: + await db.execute( + update(User) + .where(User.id == user.id) + .values( + orca_cloud_token=None, + orca_cloud_refresh_token=None, + orca_cloud_expires_at=None, + orca_cloud_email=None, + orca_cloud_user_id=None, + orca_cloud_pending_verifier=None, + orca_cloud_pending_state=None, + orca_cloud_pending_at=None, + ) + ) + await db.commit() + return + result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values())))) + for setting in result.scalars().all(): + await db.delete(setting) + await db.commit() + + +async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> None: + """Idempotent upsert into the Settings table. ``None`` values delete the row.""" + keys = [k for k, _ in values.items()] + result = await db.execute(select(Settings).where(Settings.key.in_(keys))) + existing = {s.key: s for s in result.scalars().all()} + for key, value in values.items(): + row = existing.get(key) + if value is None: + if row is not None: + await db.delete(row) + continue + if row is not None: + row.value = value + else: + db.add(Settings(key=key, value=value)) + await db.commit() + + +# --------------------------------------------------------------------------- +# Authenticated service builder with JIT refresh +# --------------------------------------------------------------------------- + + +async def _build_authenticated_service( + db: AsyncSession, + user: User | None, +) -> OrcaCloudService: + """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.""" + creds = await _load_credentials(db, user) + if not creds.token: + raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.") + + svc = OrcaCloudService() + svc.set_tokens(creds.token, creds.refresh_token, creds.expires_at) + if not svc.is_authenticated: + if not svc.refresh_token: + raise HTTPException( + status_code=401, + detail="Orca Cloud session expired and no refresh token is stored — sign in again.", + ) + try: + await svc.refresh() + except OrcaCloudAuthError as e: + # Refresh token was revoked or rotated out from under us. Clear + # the stale credentials so the UI flips to disconnected. + await _clear_credentials(db, user) + raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e + except OrcaCloudError as e: + raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e + # Persist new pair BEFORE returning. A crash between here and the + # downstream API call would still leave the user with valid stored + # tokens for the next request. + await _persist_rotated_tokens(db, user, svc.access_token, svc.refresh_token, svc.token_expiry) + return svc + + +# --------------------------------------------------------------------------- +# Route handlers +# --------------------------------------------------------------------------- + + +@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, + 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``.""" + svc = OrcaCloudService() + try: + await svc.password_login(payload.email, payload.password) + except OrcaCloudAuthError as e: + raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {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) + + +@router.post("/auth/finish", response_model=OrcaAuthStatusResponse) +async def auth_finish( + payload: OrcaAuthFinishRequest, + 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.""" + creds = await _load_credentials(db, current_user) + if not creds.pending_verifier or not creds.pending_state or not creds.pending_at: + raise HTTPException( + status_code=400, + detail="No pending Orca Cloud sign-in. 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. + 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. + + 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 + 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 + 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: + # 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) + + 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) + + +@router.get("/status", response_model=OrcaAuthStatusResponse) +async def get_status( + db: AsyncSession = Depends(get_db), + current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH), +): + """Return whether the caller has an Orca Cloud session stored, plus + identifier details for display. Does NOT make a live API call.""" + creds = await _load_credentials(db, current_user) + return OrcaAuthStatusResponse( + connected=bool(creds.token), + email=creds.email, + user_id=creds.user_id, + ) + + +@router.post("/logout") +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).""" + await _clear_credentials(db, current_user) + return {"success": True} + + +@router.get("/profiles", response_model=OrcaProfileListResponse) +async def list_profiles( + db: AsyncSession = Depends(get_db), + current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH), +): + """Return profile metadata grouped by type (``filament`` / ``printer`` + / ``process``), matching the ``SlicerSettingsResponse`` shape the + Bambu Cloud tab consumes. This lets the frontend render Orca profiles + with the same visual components — same cards, same filter bar, same + grouping — without separate UI code paths.""" + svc = await _build_authenticated_service(db, current_user) + try: + raw_profiles = await svc.list_profiles() + except OrcaCloudAuthError as e: + raise HTTPException(status_code=401, detail=str(e)) from e + except OrcaCloudError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + grouped: dict[str, list[OrcaProfileMeta]] = {"filament": [], "printer": [], "process": []} + # Log any unknown content.type values we silently drop, so a future + # change in Orca's type vocabulary surfaces in the logs rather than + # quietly losing profiles. + unknown_types: dict[str, int] = {} + for entry in raw_profiles: + setting = _orca_to_setting(entry) + if setting is None: + content = entry.get("content") if isinstance(entry, dict) else None + raw_type = (content.get("type") if isinstance(content, dict) else None) or "" + unknown_types[str(raw_type)] = unknown_types.get(str(raw_type), 0) + 1 + continue + grouped[setting.type].append(setting) + if unknown_types: + logger.warning( + "Orca Cloud profile list dropped %d profiles with unmapped content.type values: %s", + sum(unknown_types.values()), + unknown_types, + ) + return OrcaProfileListResponse(**grouped) + + +@router.get("/profiles/{profile_id}", response_model=OrcaProfileDetail) +async def get_profile( + profile_id: str, + db: AsyncSession = Depends(get_db), + current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH), +): + """Fetch a single profile's full content, shaped like + ``SlicerSettingDetail`` so the Bambu Cloud detail modal can render it + unchanged. The inner ``setting`` field is the raw slicer-format JSON + Orca stores — same shape Bambu Cloud uses since OrcaSlicer is a + BambuStudio fork.""" + svc = await _build_authenticated_service(db, current_user) + try: + profile = await svc.get_profile(profile_id) + except OrcaCloudAuthError as e: + raise HTTPException(status_code=401, detail=str(e)) from e + except OrcaCloudError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=404, detail=str(e)) from e + raise HTTPException(status_code=502, detail=str(e)) from e + content = profile.get("content") if isinstance(profile, dict) else None + if not isinstance(content, dict): + content = {} + orca_type = str(content.get("type", "")) + bambu_type = _ORCA_TYPE_TO_BAMBU.get(orca_type, orca_type) + update_time = profile.get("updated_time") if isinstance(profile, dict) else None + return OrcaProfileDetail( + setting_id=str(profile_id), + name=str(profile.get("name") if isinstance(profile, dict) else "") or str(profile_id), + type=bambu_type, + version=_str_or_none(content.get("version")), + base_id=_str_or_none(content.get("inherits") or content.get("base_id")), + update_time=str(update_time) if update_time is not None else None, + setting=content, + ) diff --git a/backend/app/api/routes/slicer_presets.py b/backend/app/api/routes/slicer_presets.py index 1e34b5392..29138c043 100644 --- a/backend/app/api/routes/slicer_presets.py +++ b/backend/app/api/routes/slicer_presets.py @@ -21,6 +21,11 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner +from backend.app.api.routes.orca_cloud import ( + _ORCA_TYPE_TO_BAMBU, + _build_authenticated_service as _build_orca_service, + _load_credentials as _load_orca_credentials, +) from backend.app.core.auth import RequirePermissionIfAuthEnabled from backend.app.core.config import settings as app_settings from backend.app.core.database import get_db @@ -37,6 +42,10 @@ from backend.app.services.bambu_cloud import ( BambuCloudError, BambuCloudService, ) +from backend.app.services.orca_cloud import ( + OrcaCloudAuthError, + OrcaCloudError, +) from backend.app.services.slicer_api import ( BundleNotFoundError, BundleSummary, @@ -69,6 +78,9 @@ _bundled_cache: tuple[float, dict[str, list[UnifiedPreset]]] | None = None _CLOUD_TTL_S = 300.0 _cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {} +# Same shape for Orca Cloud — keyed on (user_id, access_token-fingerprint). +_orca_cloud_cache: dict[tuple[int, str], tuple[float, dict[str, list[UnifiedPreset]]]] = {} + def _token_fingerprint(token: str) -> str: """Short stable hash of the cloud token for use as a cache-key component. @@ -172,6 +184,101 @@ async def _fetch_cloud_presets( await cloud.close() +async def _fetch_orca_cloud_presets( + db: AsyncSession, user: User | None, *, refresh: bool = False +) -> tuple[dict[str, list[UnifiedPreset]], str]: + """Mirror of :func:`_fetch_cloud_presets` but for Orca Cloud. Same status + vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``), + same caching shape, same defence-in-depth permission gate + (``orca_cloud:auth`` rather than ``cloud:auth``). + + Filament metadata (``filament_type`` / ``filament_colour``) is extracted + from the profile's inline ``content`` dict — unlike Bambu Cloud where + we'd have to fetch each setting separately and hit a rate limit, Orca's + ``/sync/pull`` already returns full content per profile, so the metadata + enrichment is free here. + """ + if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value): + return _empty_slots(), "not_authenticated" + + creds = await _load_orca_credentials(db, user) + if not creds.token: + return _empty_slots(), "not_authenticated" + + user_key = user.id if user is not None else 0 + cache_key = (user_key, _token_fingerprint(creds.token)) + now = time.monotonic() + if not refresh: + cached = _orca_cloud_cache.get(cache_key) + if cached and now - cached[0] < _CLOUD_TTL_S: + return cached[1], "ok" + + try: + svc = await _build_orca_service(db, user) + except HTTPException as e: + # ``_build_orca_service`` raises 401 when the token is missing, + # the refresh-token rotation failed, or the JIT refresh hit Orca's + # backend and got rejected; 502 when Orca is unreachable. Translate + # to the status vocabulary the SliceModal expects. + if e.status_code == 401: + return _empty_slots(), "expired" + return _empty_slots(), "unreachable" + + try: + try: + raw_profiles = await svc.list_profiles() + except OrcaCloudAuthError: + return _empty_slots(), "expired" + except OrcaCloudError as e: + logger.warning("Orca Cloud preset fetch failed for user %s: %s", user_key, e) + return _empty_slots(), "unreachable" + except Exception as e: # noqa: BLE001 — defensive: never crash the modal + logger.warning("Orca Cloud preset fetch unexpected error for user %s: %s", user_key, e) + return _empty_slots(), "unreachable" + + slots = _empty_slots() + for entry in raw_profiles: + content = entry.get("content") if isinstance(entry, dict) else None + if not isinstance(content, dict): + continue + slot = _ORCA_TYPE_TO_BAMBU.get(str(content.get("type", ""))) + if slot is None: + continue + preset_id = entry.get("id") + name = entry.get("name") or preset_id + if not preset_id or not name: + continue + filament_type: str | None = None + filament_colour: str | None = None + if slot == "filament": + # Bambu/Orca filament profiles store these as single-element + # arrays (the historical multi-extruder shape). Extract the + # first non-empty element for both. + ft = content.get("filament_type") + if isinstance(ft, list) and ft and isinstance(ft[0], str): + filament_type = ft[0] + elif isinstance(ft, str): + filament_type = ft + fc = content.get("default_filament_colour") + if isinstance(fc, list) and fc and isinstance(fc[0], str): + filament_colour = fc[0] + elif isinstance(fc, str): + filament_colour = fc + slots[slot].append( + UnifiedPreset( + id=str(preset_id), + name=str(name), + source="orca_cloud", + filament_type=filament_type, + filament_colour=filament_colour, + ) + ) + _orca_cloud_cache[cache_key] = (now, slots) + return slots, "ok" + finally: + await svc.close() + + async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset]]: """Local imports — no caching needed, single indexed DB read.""" result = await db.execute(select(LocalPreset).order_by(LocalPreset.name)) @@ -314,6 +421,7 @@ async def _resolve_slicer_api_url(db: AsyncSession) -> str | None: def _dedupe_by_name( + orca_cloud: dict[str, list[UnifiedPreset]], cloud: dict[str, list[UnifiedPreset]], local: dict[str, list[UnifiedPreset]], standard: dict[str, list[UnifiedPreset]], @@ -321,34 +429,35 @@ def _dedupe_by_name( dict[str, list[UnifiedPreset]], dict[str, list[UnifiedPreset]], dict[str, list[UnifiedPreset]], + dict[str, list[UnifiedPreset]], ]: - """Filter so each preset name appears in exactly one tier (cloud > local > standard). + """Filter so each preset name appears in exactly one tier. - Order within each tier is preserved as-is — only "lower-priority duplicates" - are dropped. A preset shared across tiers (e.g. "Bambu PLA Basic" in cloud - public AND standard bundled) only renders once, in the cloud tier. + Precedence: ``orca_cloud > cloud > local > standard``. Orca Cloud is + highest because a user who set up Orca sync is explicitly curating + those profiles for use here; Bambu Cloud follows for the same reason + one tier down. Order within each tier is preserved. - Filament metadata is **merged across tiers** during dedup: when a cloud - entry wins over a same-named local entry, the cloud entry inherits the - local entry's ``filament_type`` and ``filament_colour`` (cloud entries - carry no metadata themselves because we deliberately don't fetch each - setting's content — see _fetch_cloud_presets). Without this merge, the - SliceModal's metadata-aware pre-pick would silently lose match data for - every preset the user has both cloud-synced and locally imported, and - fall back to plain priority selection. + Filament metadata merges across tiers: a Bambu Cloud entry without its + own ``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface + these in the list response for rate-limiting reasons — see + :func:`_fetch_cloud_presets`) inherits values from the same-named local + or standard entry. Orca Cloud already carries metadata inline, so no + backfill is needed for it. """ - # Build a lookup: filament name → metadata from the highest-quality tier - # that has it. Local + standard both expose parsed metadata; cloud - # doesn't. Take whichever non-empty entry shows up first. + # Build a name → metadata lookup from the tiers that carry it (orca_cloud, + # local, standard). Bambu cloud is intentionally skipped — it doesn't + # populate filament_type/colour in the list response. Take whichever + # non-empty entry shows up first. metadata_by_name: dict[str, tuple[str | None, str | None]] = {} - for tier in (local, standard): + for tier in (orca_cloud, local, standard): for p in tier["filament"]: if p.name in metadata_by_name: continue if p.filament_type or p.filament_colour: metadata_by_name[p.name] = (p.filament_type, p.filament_colour) - # Backfill cloud entries that don't have their own metadata. + # Backfill Bambu Cloud entries that don't have their own metadata. for p in cloud["filament"]: if (p.filament_type is None or p.filament_colour is None) and p.name in metadata_by_name: t, c = metadata_by_name[p.name] @@ -357,10 +466,16 @@ def _dedupe_by_name( if p.filament_colour is None and c is not None: p.filament_colour = c + deduped_cloud = _empty_slots() deduped_local = _empty_slots() deduped_standard = _empty_slots() for slot in ("printer", "process", "filament"): - seen = {p.name for p in cloud[slot]} + seen = {p.name for p in orca_cloud[slot]} + for p in cloud[slot]: + if p.name in seen: + continue + deduped_cloud[slot].append(p) + seen.add(p.name) for p in local[slot]: if p.name in seen: continue @@ -371,7 +486,7 @@ def _dedupe_by_name( continue deduped_standard[slot].append(p) seen.add(p.name) - return cloud, deduped_local, deduped_standard + return orca_cloud, deduped_cloud, deduped_local, deduped_standard @router.get("/printer-models") @@ -421,17 +536,20 @@ async def list_unified_presets( too — matching the slice route (#1182 follow-up). """ cloud_token_user = current_user or api_key_cloud_owner + orca_cloud, orca_cloud_status = await _fetch_orca_cloud_presets(db, cloud_token_user, refresh=refresh) cloud, cloud_status = await _fetch_cloud_presets(db, cloud_token_user, refresh=refresh) local = await _fetch_local_presets(db) standard = await _fetch_bundled_presets(db, refresh=refresh) - cloud, local, standard = _dedupe_by_name(cloud, local, standard) + orca_cloud, cloud, local, standard = _dedupe_by_name(orca_cloud, cloud, local, standard) return UnifiedPresetsResponse( + orca_cloud=UnifiedPresetsBySlot(**orca_cloud), cloud=UnifiedPresetsBySlot(**cloud), local=UnifiedPresetsBySlot(**local), standard=UnifiedPresetsBySlot(**standard), cloud_status=cloud_status, + orca_cloud_status=orca_cloud_status, ) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 75badbfc5..dcb342082 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -124,6 +124,12 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = { # level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed # when the flag is off (defence-in-depth). Permission.CLOUD_AUTH: "can_access_cloud", + # ORCA_CLOUD_AUTH folds into the same ``can_access_cloud`` scope: same + # trust dimension (third-party cloud access for profile sync), so an + # operator who already accepted "this key can talk to clouds for the + # owner" doesn't need a second toggle for Orca. Splitting later requires + # a new column + migration — easy to add if the trust dimensions diverge. + Permission.ORCA_CLOUD_AUTH: "can_access_cloud", } # Retained for documentation, drift-detection, and the prior "administrative diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 369ea9356..29294311d 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -2755,6 +2755,27 @@ async def run_migrations(conn): "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10", ) + # Migration: Add per-user Orca Cloud credential columns. Mirrors the Bambu + # Cloud columns but adds refresh_token + expires_at (Supabase PKCE issues + # short-lived access tokens with rotating refresh tokens), plus three + # transient PKCE state columns held during the auth handshake. DATETIME + # is SQLite-only — Postgres uses TIMESTAMP, so the datetime columns are + # dialect-branched per project convention. + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_token VARCHAR(2000)") + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_refresh_token VARCHAR(128)") + if is_sqlite(): + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_expires_at DATETIME") + else: + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_expires_at TIMESTAMP") + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_email VARCHAR(255)") + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_user_id VARCHAR(64)") + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_verifier VARCHAR(64)") + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_state VARCHAR(32)") + if is_sqlite(): + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_at DATETIME") + else: + await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP") + # Data migration: drop the embedded 3MF Title (`print_name`) from library # file metadata so the FileManager displays the filename, not the title (#1489). await _migrate_drop_library_print_name(conn) diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index a6e8c668f..da02047e7 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -144,6 +144,7 @@ class Permission(StrEnum): # Cloud Auth (admin-level) CLOUD_AUTH = "cloud:auth" + ORCA_CLOUD_AUTH = "orca_cloud:auth" # MakerWorld Integration MAKERWORLD_VIEW = "makerworld:view" # Resolve MakerWorld URLs and view model metadata @@ -297,6 +298,7 @@ PERMISSION_CATEGORIES = { ], "Cloud": [ Permission.CLOUD_AUTH, + Permission.ORCA_CLOUD_AUTH, ], "MakerWorld": [ Permission.MAKERWORLD_VIEW, diff --git a/backend/app/main.py b/backend/app/main.py index 99c19161d..a3132d0c6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -45,6 +45,7 @@ from backend.app.api.routes import ( notification_templates, notifications, obico, + orca_cloud, pending_uploads, print_log, print_queue, @@ -5777,6 +5778,7 @@ app.include_router(inventory.router, prefix=app_settings.api_prefix) app.include_router(labels.router, prefix=app_settings.api_prefix) app.include_router(settings_routes.router, prefix=app_settings.api_prefix) app.include_router(cloud.router, prefix=app_settings.api_prefix) +app.include_router(orca_cloud.router, prefix=app_settings.api_prefix) app.include_router(local_presets.router, prefix=app_settings.api_prefix) app.include_router(smart_plugs.router, prefix=app_settings.api_prefix) app.include_router(print_log.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 82d4ef79d..af4ccf60c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -45,6 +45,20 @@ class User(Base): # "global" or "china"; NULL treated as "global" for legacy rows. cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None) + # Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE + # with short-lived access tokens (1h) and rotating single-use refresh tokens, + # so we store the refresh token + expiry alongside the access token. + orca_cloud_token: Mapped[str | None] = mapped_column(String(2000), nullable=True, default=None) + orca_cloud_refresh_token: Mapped[str | None] = mapped_column(String(128), nullable=True, default=None) + orca_cloud_expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + orca_cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + orca_cloud_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None) + # Transient PKCE state held between /orca-cloud/auth/start and /orca-cloud/auth/finish. + # Cleared on successful finish; expires after 10 minutes if the user abandons the flow. + orca_cloud_pending_verifier: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None) + orca_cloud_pending_state: Mapped[str | None] = mapped_column(String(32), nullable=True, default=None) + orca_cloud_pending_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None) + # Relationship to groups through association table groups: Mapped[list[Group]] = relationship( "Group", diff --git a/backend/app/schemas/orca_cloud.py b/backend/app/schemas/orca_cloud.py new file mode 100644 index 000000000..20a8761ac --- /dev/null +++ b/backend/app/schemas/orca_cloud.py @@ -0,0 +1,94 @@ +"""Schemas for Orca Cloud 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 OrcaAuthStartRequest(BaseModel): + """Body for ``POST /orca-cloud/auth/start``. Provider defaults to + ``google`` so existing clients that send an empty body keep working.""" + + provider: OrcaOAuthProvider = Field(default="google", description="OAuth provider to use for sign-in") + + +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 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) + + +class OrcaAuthStatusResponse(BaseModel): + """Connection status for the Orca Cloud tab.""" + + connected: bool + email: str | None = None + user_id: str | None = None + + +class OrcaProfileMeta(BaseModel): + """A single profile, shaped to match the Bambu Cloud ``SlicerSetting`` + schema so the frontend can render Orca profiles with the existing + Bambu Cloud visual components (cards, filter bar, grouping). Per-source + differences (Orca's IDs are UUIDs not Bambu's ``PFU...`` prefix; Orca + types are ``machine`` / ``process`` / ``filament`` whereas Bambu uses + ``printer`` / ``process`` / ``filament``) are normalized at the route + layer before the response leaves the backend.""" + + setting_id: str + name: str + type: str + version: str | None = None + user_id: str | None = None + updated_time: str | None = None + is_custom: bool = True + + +class OrcaProfileListResponse(BaseModel): + """Groups Orca profiles by type, matching ``SlicerSettingsResponse``.""" + + filament: list[OrcaProfileMeta] = [] + printer: list[OrcaProfileMeta] = [] + process: list[OrcaProfileMeta] = [] + + +class OrcaProfileDetail(BaseModel): + """Single profile's full content, shaped to match ``SlicerSettingDetail`` + so the frontend's detail modal can render it without translation.""" + + setting_id: str + name: str + type: str + version: str | None = None + base_id: str | None = None + update_time: str | None = None + setting: dict diff --git a/backend/app/schemas/slicer.py b/backend/app/schemas/slicer.py index 163782381..d63d63497 100644 --- a/backend/app/schemas/slicer.py +++ b/backend/app/schemas/slicer.py @@ -8,13 +8,20 @@ from pydantic import BaseModel, Field, model_validator class PresetRef(BaseModel): """A source-aware reference to a printer / process / filament preset. - The SliceModal pulls dropdown options from three tiers (cloud / local / - standard). At submit time the client sends one of these per slot so the - backend knows where to fetch the preset content from at slice time. + The SliceModal pulls dropdown options from four tiers (orca_cloud / + cloud / local / standard). At submit time the client sends one of these + per slot so the backend knows where to fetch the preset content from at + slice time. ``cloud`` is Bambu Cloud (kept as the bare name for backward + compatibility with existing requests); ``orca_cloud`` is Orca Cloud. """ - source: Literal["cloud", "local", "standard"] - id: str = Field(..., description=("Cloud setting_id, local DB row id (stringified), or standard preset name.")) + source: Literal["orca_cloud", "cloud", "local", "standard"] + id: str = Field( + ..., + description=( + "Orca Cloud profile id, Bambu Cloud setting_id, local DB row id (stringified), or standard preset name." + ), + ) class SliceBundleSpec(BaseModel): diff --git a/backend/app/schemas/slicer_presets.py b/backend/app/schemas/slicer_presets.py index 758a74270..d110ef205 100644 --- a/backend/app/schemas/slicer_presets.py +++ b/backend/app/schemas/slicer_presets.py @@ -44,7 +44,7 @@ class UnifiedPreset(BaseModel): id: str name: str - source: Literal["cloud", "local", "standard"] + source: Literal["orca_cloud", "cloud", "local", "standard"] filament_type: str | None = None filament_colour: str | None = None compatible_printers: list[str] | None = None @@ -61,17 +61,21 @@ class UnifiedPresetsBySlot(BaseModel): class UnifiedPresetsResponse(BaseModel): """Each tier carries only the names that didn't appear in a higher tier. - Cloud is the highest priority (user's personal customisations win), then - the local imports the user explicitly curated, then the slicer's stock - fallback. A name that appears in cloud is filtered out of local and - standard; a name that appears in local is filtered out of standard. + Priority order: ``orca_cloud > cloud > local > standard``. Orca Cloud is + highest because it's the most-recently-explicitly-curated source for + users who set up Orca sync (they did it on purpose; their Orca picks + should outrank everything else). Bambu Cloud follows as the next-most- + curated tier. Local imports beat the slicer's stock fallback. - ``cloud_status`` lets the frontend show a banner explaining why the cloud - tier is empty when the user expected to see it (signed out / token - expired / network down). + ``cloud_status`` / ``orca_cloud_status`` let the frontend show a banner + explaining why a cloud tier is empty when the user expected to see it + (signed out / token expired / network down). Each tier has its own + status because they can fail independently. """ + orca_cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot() cloud: UnifiedPresetsBySlot = UnifiedPresetsBySlot() local: UnifiedPresetsBySlot = UnifiedPresetsBySlot() standard: UnifiedPresetsBySlot = UnifiedPresetsBySlot() cloud_status: CloudStatus = "ok" + orca_cloud_status: CloudStatus = "ok" diff --git a/backend/app/services/orca_cloud.py b/backend/app/services/orca_cloud.py new file mode 100644 index 000000000..e61b7c313 --- /dev/null +++ b/backend/app/services/orca_cloud.py @@ -0,0 +1,504 @@ +""" +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/`` UA clears it. No TLS-fingerprint matching needed. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any + +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). +_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_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) + + +class OrcaCloudError(Exception): + """Base exception for Orca Cloud errors.""" + + 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.""" + + pass + + +_shared_http_client: httpx.AsyncClient | None = None + + +def set_shared_http_client(client: httpx.AsyncClient | None) -> None: + """Register an app-scoped ``httpx.AsyncClient`` so per-request + ``OrcaCloudService`` instances can reuse its connection pool. Mirrors the + pattern used by :mod:`backend.app.services.bambu_cloud`.""" + global _shared_http_client + _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. + + 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). + """ + + def __init__(self, client: httpx.AsyncClient | None = None): + self.access_token: str | None = None + self.refresh_token: str | None = None + self.token_expiry: datetime | None = None + # Mirror the bambu_cloud pattern for client ownership: prefer injected + # client (tests), fall back to app-scoped shared client (production), + # else create our own so ad-hoc scripts still work. + if client is not None: + self._client = client + self._owns_client = False + elif _shared_http_client is not None: + self._client = _shared_http_client + self._owns_client = False + else: + self._client = httpx.AsyncClient(timeout=30.0) + self._owns_client = True + + @property + def is_authenticated(self) -> bool: + """True iff we have an access token that won't expire within + :data:`_REFRESH_LEEWAY`. The leeway prevents a slow API call from + timing out mid-flight on a token that was nominally still valid.""" + if not self.access_token: + return False + if self.token_expiry is None: + # No expiry recorded — pessimistically treat as expired so the + # caller refreshes before use. + return False + return datetime.now(timezone.utc) + _REFRESH_LEEWAY < self.token_expiry + + def set_tokens( + self, + access_token: str | None, + refresh_token: str | None, + expires_at: datetime | None, + ) -> None: + """Hydrate the service from stored credentials.""" + self.access_token = access_token + self.refresh_token = refresh_token + # Normalize to timezone-aware UTC so subsequent comparisons against + # ``datetime.now(timezone.utc)`` are well-defined. asyncpg returns + # naive datetimes from a ``TIMESTAMP WITHOUT TIME ZONE`` column — + # we treat naive values as UTC since that's how we stored them. + if expires_at is not None and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + self.token_expiry = expires_at + + def clear_tokens(self) -> None: + """Forget all credentials. Used on logout and after auth failures.""" + self.access_token = None + 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.""" + 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 + # ------------------------------------------------------------------ + + 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} + try: + resp = await self._client.post( + url, + json=payload, + headers={ + "User-Agent": _USER_AGENT, + "apikey": ORCA_ANON_KEY, + "Content-Type": "application/json", + }, + ) + except httpx.HTTPError as e: + raise OrcaCloudError(f"Network error during Orca Cloud password login: {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 + + 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} + try: + resp = await self._client.post( + url, + json=payload, + headers={ + "User-Agent": _USER_AGENT, + "apikey": ORCA_ANON_KEY, + "Content-Type": "application/json", + }, + ) + 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 + + 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. + """ + 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} + try: + resp = await self._client.post( + url, + json=payload, + headers={ + "User-Agent": _USER_AGENT, + "apikey": ORCA_ANON_KEY, + "Content-Type": "application/json", + }, + ) + 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. + if resp.status_code in (400, 401, 403): + self.clear_tokens() + raise OrcaCloudAuthError(f"Orca Cloud refresh rejected: {detail}") + raise OrcaCloudError(f"Orca Cloud refresh failed ({resp.status_code}): {detail}") + + data = resp.json() + self._apply_token_response(data) + return data + + 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.""" + 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. + if refresh: + self.refresh_token = refresh + if isinstance(expires_in, (int, float)) and expires_in > 0: + self.token_expiry = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in)) + else: + self.token_expiry = None + + # ------------------------------------------------------------------ + # Sync 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" + try: + resp = await self._client.get(url, headers=self._auth_headers()) + except httpx.HTTPError as e: + raise OrcaCloudError(f"Network error fetching Orca Cloud user info: {e}") from e + if resp.status_code == 401: + raise OrcaCloudAuthError("Orca Cloud user fetch unauthorized — token expired or revoked") + if resp.status_code >= 400: + raise OrcaCloudError(f"Orca Cloud user fetch 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" + 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 >= 400: + raise OrcaCloudError(f"Orca Cloud profile list failed ({resp.status_code}): {resp.text[:200]}") + data = resp.json() + if isinstance(data, dict): + 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. + 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__) + 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. + """ + profiles = await self.list_profiles() + for profile in profiles: + if str(profile.get("id")) == str(profile_id): + return profile + raise OrcaCloudError(f"Orca Cloud profile {profile_id!r} not found (scanned {len(profiles)} profiles)") + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def close(self) -> None: + """Release the underlying httpx client iff we own it. No-op if we're + using an injected or app-shared client (those are managed elsewhere).""" + if self._owns_client: + await self._client.aclose() + + +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.""" + try: + data = resp.json() + except (json.JSONDecodeError, ValueError): + return (resp.text or "")[:200] + if not isinstance(data, dict): + return str(data)[:200] + for key in ("error_description", "msg", "error", "message"): + val = data.get(key) + if isinstance(val, str) and val: + return val + return str(data)[:200] diff --git a/backend/app/services/preset_resolver.py b/backend/app/services/preset_resolver.py index df09ccdc8..c57407be2 100644 --- a/backend/app/services/preset_resolver.py +++ b/backend/app/services/preset_resolver.py @@ -30,6 +30,7 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from backend.app.api.routes.cloud import get_stored_token +from backend.app.api.routes.orca_cloud import _build_authenticated_service as _build_orca_service from backend.app.core.permissions import Permission from backend.app.models.local_preset import LocalPreset from backend.app.models.user import User @@ -39,6 +40,7 @@ from backend.app.services.bambu_cloud import ( BambuCloudError, BambuCloudService, ) +from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError logger = logging.getLogger(__name__) @@ -85,6 +87,8 @@ async def resolve_preset_ref( return await _resolve_local(db, ref, slot) if ref.source == "cloud": return await _resolve_cloud(db, user, ref, slot) + if ref.source == "orca_cloud": + return await _resolve_orca_cloud(db, user, ref, slot) if ref.source == "standard": return _resolve_standard(ref, slot) raise HTTPException( @@ -163,6 +167,64 @@ async def _resolve_cloud(db: AsyncSession, user: User | None, ref: PresetRef, sl return json.dumps(payload) +async def _resolve_orca_cloud(db: AsyncSession, user: User | None, ref: PresetRef, slot: str) -> str: + """Fetch a single profile from Orca Cloud and return its content JSON. + + The route-layer service builder handles JIT token refresh and stale-credential + cleanup, so any exception here means a genuine fetch / network / not-found + problem — never a "stale token" situation the caller could retry through. + Permission gate matches the rest of the Orca Cloud surface so a user with + ``LIBRARY_UPLOAD`` but no ``ORCA_CLOUD_AUTH`` cannot slice using cloud + profiles even if their stored token survived a permission revocation. + """ + if user is not None and not user.has_permission(Permission.ORCA_CLOUD_AUTH.value): + raise HTTPException( + status_code=403, + detail=f"Orca Cloud presets require the orca_cloud:auth permission ({slot})", + ) + + try: + svc = await _build_orca_service(db, user) + except HTTPException: + # Builder already produces the right user-facing error (401 not + # connected, 401 session refresh failed, 502 unreachable). + raise + + try: + profile = await svc.get_profile(ref.id) + except OrcaCloudAuthError as e: + raise HTTPException( + status_code=401, + detail=f"Orca Cloud session expired while fetching {slot} preset. Sign in again and retry.", + ) from e + except OrcaCloudError as e: + if "not found" in str(e).lower(): + raise HTTPException( + status_code=400, + detail=f"Orca Cloud {slot} preset {ref.id!r} not found.", + ) from e + raise HTTPException( + status_code=502, + detail=f"Orca Cloud unreachable while fetching {slot} preset: {e}", + ) from e + finally: + await svc.close() + + # ``profile`` is the ProfileUpsert shape — the inner ``content`` is the + # actual slicer-format JSON. Fall back to forwarding the wrapper if the + # shape doesn't match what we expect (defensive, in case Orca evolves + # the wire format). + content = profile.get("content") if isinstance(profile, dict) else None + if not isinstance(content, dict): + logger.info( + "Orca Cloud preset %r for %s returned unexpected shape, forwarding raw payload", + ref.id, + slot, + ) + content = profile + return json.dumps(content) + + def _resolve_standard(ref: PresetRef, slot: str) -> str: """Build a minimal `{name, inherits, from, type}` stub. The sidecar's resolver walks `BUNDLED_PROFILES_PATH//.json` and merges, diff --git a/backend/tests/unit/services/test_orca_cloud.py b/backend/tests/unit/services/test_orca_cloud.py new file mode 100644 index 000000000..82c303250 --- /dev/null +++ b/backend/tests/unit/services/test_orca_cloud.py @@ -0,0 +1,433 @@ +"""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.""" + +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 + +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, + 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, + text_body: str = "", +) -> MagicMock: + """Build an httpx-like response mock with the only attributes the + service touches: ``status_code``, ``.json()``, ``.text``.""" + resp = MagicMock(spec=["status_code", "json", "text"]) + resp.status_code = status_code + if json_data is not None: + resp.json.return_value = json_data + resp.text = json.dumps(json_data) + else: + resp.json.side_effect = ValueError("not json") + resp.text = text_body + return resp + + +@pytest.fixture +def svc() -> OrcaCloudService: + return OrcaCloudService(client=MagicMock(spec=httpx.AsyncClient)) + + +class TestExchangeCode: + @pytest.mark.asyncio + async def test_success_populates_tokens_and_expiry(self, svc): + token_resp = _mock_response( + json_data={ + "access_token": "ACCESS-1", + "refresh_token": "REFRESH-1", + "expires_in": 3600, + "token_type": "bearer", + } + ) + svc._client.post = AsyncMock(return_value=token_resp) + + await svc.exchange_code("CODE", "VERIFIER") + + 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) + + @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/`` 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) + + await svc.exchange_code("CODE", "VERIFIER") + + _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" + + @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) + + 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_network_error_wraps_as_orca_error(self, svc): + svc._client.post = AsyncMock(side_effect=httpx.ConnectError("boom")) + with pytest.raises(OrcaCloudError): + await svc.exchange_code("CODE", "VERIFIER") + + +class TestPasswordLogin: + @pytest.mark.asyncio + async def test_success_populates_tokens(self, svc): + resp = _mock_response( + json_data={ + "access_token": "PWD-A", + "refresh_token": "PWD-R", + "expires_in": 3600, + } + ) + svc._client.post = AsyncMock(return_value=resp) + + await svc.password_login("user@example.com", "secret") + + assert svc.access_token == "PWD-A" + assert svc.refresh_token == "PWD-R" + + @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") + + @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") + + +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" + resp = _mock_response( + json_data={ + "access_token": "ACCESS-2", + "refresh_token": "REFRESH-2", + "expires_in": 3600, + } + ) + svc._client.post = AsyncMock(return_value=resp) + + await svc.refresh() + + assert svc.access_token == "ACCESS-2" + assert svc.refresh_token == "REFRESH-2" + + @pytest.mark.asyncio + async def test_no_refresh_token_raises_auth_error(self, svc): + svc.refresh_token = None + with pytest.raises(OrcaCloudAuthError): + await svc.refresh() + + @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" + 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) + + with pytest.raises(OrcaCloudAuthError): + await svc.refresh() + + assert svc.access_token is None + assert svc.refresh_token is None + assert svc.token_expiry is None + + +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.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.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) + assert svc.is_authenticated is True + + +class TestApiHeaders: + def test_api_headers_include_apikey_and_bearer(self, svc): + svc.access_token = "ACCESS-123" + headers = svc._api_headers() + assert headers["apikey"] == ORCA_ANON_KEY + assert headers["Authorization"] == "Bearer ACCESS-123" + assert headers["User-Agent"].startswith("Bambuddy/") + + def test_api_headers_without_token_raises(self, svc): + svc.access_token = None + with pytest.raises(OrcaCloudAuthError): + svc._api_headers() + + +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._client.get = AsyncMock( + return_value=_mock_response( + json_data={ + "next_cursor": 12345, + "upserts": [ + {"id": "a", "name": "A", "content": {"x": 1}}, + {"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": []}), + ) + await svc.list_profiles() + called_url = svc._client.get.call_args.args[0] + assert called_url.endswith("/api/v1/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_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"}]), + ) + 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._client.get = AsyncMock( + return_value=_mock_response( + json_data={ + "upserts": [ + {"id": "a", "name": "A", "content": {"foo": 1}}, + {"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._client.get = AsyncMock( + return_value=_mock_response( + json_data={"upserts": [{"id": "a", "name": "A"}], "deletes": []}, + ), + ) + with pytest.raises(OrcaCloudError, match="not found"): + await svc.get_profile("missing") diff --git a/backend/tests/unit/services/test_preset_resolver.py b/backend/tests/unit/services/test_preset_resolver.py index c0774689f..8e465494c 100644 --- a/backend/tests/unit/services/test_preset_resolver.py +++ b/backend/tests/unit/services/test_preset_resolver.py @@ -208,6 +208,83 @@ async def test_cloud_auth_error_returns_401(): assert exc.value.status_code == 401 +# --- orca_cloud tier ------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_orca_cloud_blocks_user_without_orca_cloud_auth(): + """Defence-in-depth, same shape as the Bambu Cloud permission check: + a user holding LIBRARY_UPLOAD but not ORCA_CLOUD_AUTH can't slice with + Orca Cloud presets even if their User row still carries a token.""" + db = MagicMock() + user = MagicMock() + user.has_permission = MagicMock(return_value=False) + with pytest.raises(HTTPException) as exc: + await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer") + assert exc.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_orca_cloud_unwraps_content(): + """Orca's profile shape is ``{id, name, content, updated_time, created_time}`` + — the inner ``content`` is the actual slicer-format JSON. We forward + that, not the wrapper.""" + db = MagicMock() + user = MagicMock() + user.has_permission = MagicMock(return_value=True) + svc_mock = MagicMock() + svc_mock.get_profile = AsyncMock( + return_value={ + "id": "abc", + "name": "X1C Custom", + "content": {"name": "X1C Custom", "nozzle_diameter": [0.4]}, + } + ) + svc_mock.close = AsyncMock() + with patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)): + out = await preset_resolver._resolve_orca_cloud( + db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer" + ) + payload = json.loads(out) + assert payload == {"name": "X1C Custom", "nozzle_diameter": [0.4]} + svc_mock.close.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_orca_cloud_auth_error_returns_401(): + db = MagicMock() + user = MagicMock() + user.has_permission = MagicMock(return_value=True) + svc_mock = MagicMock() + svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudAuthError("expired")) + svc_mock.close = AsyncMock() + with ( + patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)), + pytest.raises(HTTPException) as exc, + ): + await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer") + assert exc.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_orca_cloud_not_found_returns_400(): + """``get_profile`` raises generic ``OrcaCloudError`` for "not found" — + the resolver maps that to a 400 (not 502) so the UI can show "profile + no longer exists" rather than "service down".""" + db = MagicMock() + user = MagicMock() + user.has_permission = MagicMock(return_value=True) + svc_mock = MagicMock() + svc_mock.get_profile = AsyncMock(side_effect=preset_resolver.OrcaCloudError("profile 'abc' not found (scanned 0)")) + svc_mock.close = AsyncMock() + with ( + patch.object(preset_resolver, "_build_orca_service", AsyncMock(return_value=svc_mock)), + pytest.raises(HTTPException) as exc, + ): + await preset_resolver._resolve_orca_cloud(db, user, PresetRef(source="orca_cloud", id="abc"), slot="printer") + assert exc.value.status_code == 400 + + # --- top-level dispatcher ------------------------------------------------- diff --git a/backend/tests/unit/test_slicer_presets.py b/backend/tests/unit/test_slicer_presets.py index 21d7aa15b..49ab5c0c7 100644 --- a/backend/tests/unit/test_slicer_presets.py +++ b/backend/tests/unit/test_slicer_presets.py @@ -36,7 +36,7 @@ class TestDedupeByName: local = _slot([("lid1", "Bambu PLA Basic", "local")]) standard = _slot([("Bambu PLA Basic", "Bambu PLA Basic", "standard")]) - c, l_, s = sp._dedupe_by_name(cloud, local, standard) + _oc, c, l_, s = sp._dedupe_by_name(_slot([]), cloud, local, standard) assert [p.source for p in c["printer"]] == ["cloud"] assert l_["printer"] == [] @@ -52,7 +52,7 @@ class TestDedupeByName: ) standard = _slot([]) - _c, l_, _s = sp._dedupe_by_name(cloud, local, standard) + _oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), cloud, local, standard) assert [p.name for p in l_["printer"]] == ["My Workhorse PLA"] def test_standard_filtered_against_both_higher_tiers(self): @@ -66,7 +66,7 @@ class TestDedupeByName: ] ) - _c, _l, s = sp._dedupe_by_name(cloud, local, standard) + _oc, _c, _l, s = sp._dedupe_by_name(_slot([]), cloud, local, standard) assert [p.name for p in s["printer"]] == ["C"] def test_preserves_order_within_tier(self): @@ -79,7 +79,7 @@ class TestDedupeByName: ("c3", "M-Third", "cloud"), ] ) - c, _l, _s = sp._dedupe_by_name(cloud, _slot([]), _slot([])) + _oc, c, _l, _s = sp._dedupe_by_name(_slot([]), cloud, _slot([]), _slot([])) assert [p.name for p in c["printer"]] == ["Z-First", "A-Second", "M-Third"] def test_dedupe_is_per_slot(self): @@ -95,7 +95,7 @@ class TestDedupeByName: "process": [], "filament": [], } - _c, l_, _s = sp._dedupe_by_name(cloud, local, _slot([])) + _oc, _c, l_, _s = sp._dedupe_by_name(_slot([]), cloud, local, _slot([])) # The filament-tier collision must NOT remove the printer-tier "Custom". assert [p.name for p in l_["printer"]] == ["Custom"] @@ -111,6 +111,133 @@ def _user_with_cloud_auth(user_id: int = 1) -> MagicMock: return user +class TestFetchOrcaCloudPresets: + """``_fetch_orca_cloud_presets`` mirrors the Bambu Cloud fetcher's status + vocabulary (``ok`` / ``not_authenticated`` / ``expired`` / ``unreachable``) + and the same permission-shortcut + caching behaviour. Tests pin the + contract so a future bug in either fetcher doesn't silently desync them.""" + + def _orca_creds(self, token: str | None = "tok") -> MagicMock: + creds = MagicMock() + creds.token = token + return creds + + @pytest.mark.asyncio + async def test_no_token_returns_not_authenticated(self): + sp._orca_cloud_cache.clear() + with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds(None))): + user = MagicMock(id=1) + user.has_permission = MagicMock(return_value=True) + slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user) + assert status == "not_authenticated" + assert slots == {"printer": [], "process": [], "filament": []} + + @pytest.mark.asyncio + async def test_user_without_orca_cloud_auth_returns_not_authenticated(self): + """Defence-in-depth — a user lacking ORCA_CLOUD_AUTH must not see Orca + presets even if their User row carries a stale token. Credentials + lookup must short-circuit ahead of the token read.""" + sp._orca_cloud_cache.clear() + user = MagicMock(id=1) + user.has_permission = MagicMock(return_value=False) + with patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))) as load: + slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user) + assert status == "not_authenticated" + assert slots["printer"] == [] + load.assert_not_called() + + @pytest.mark.asyncio + async def test_auth_error_returns_expired(self): + sp._orca_cloud_cache.clear() + svc_mock = MagicMock() + svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudAuthError("expired")) + svc_mock.close = AsyncMock() + user = MagicMock(id=1) + user.has_permission = MagicMock(return_value=True) + with ( + patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))), + patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)), + ): + _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user) + assert status == "expired" + svc_mock.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_orca_error_returns_unreachable(self): + sp._orca_cloud_cache.clear() + svc_mock = MagicMock() + svc_mock.list_profiles = AsyncMock(side_effect=sp.OrcaCloudError("net down")) + svc_mock.close = AsyncMock() + user = MagicMock(id=1) + user.has_permission = MagicMock(return_value=True) + with ( + patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))), + patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)), + ): + _slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user) + assert status == "unreachable" + + @pytest.mark.asyncio + async def test_happy_path_shapes_grouped_by_type(self): + """Orca content.type values map onto Bambu Cloud's preset type vocab + (``printer`` / ``print`` → ``process`` / ``filament``). Verify the + full mapping by feeding one of each shape.""" + sp._orca_cloud_cache.clear() + svc_mock = MagicMock() + svc_mock.list_profiles = AsyncMock( + return_value=[ + {"id": "m1", "name": "Orca X1C", "content": {"type": "printer"}}, + {"id": "p1", "name": "Orca 0.20mm", "content": {"type": "print"}}, + { + "id": "f1", + "name": "Orca PLA", + "content": { + "type": "filament", + "filament_type": ["PLA"], + "default_filament_colour": ["#000000"], + }, + }, + ] + ) + svc_mock.close = AsyncMock() + user = MagicMock(id=1) + user.has_permission = MagicMock(return_value=True) + with ( + patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))), + patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)), + ): + slots, status = await sp._fetch_orca_cloud_presets(MagicMock(), user) + assert status == "ok" + assert [p.name for p in slots["printer"]] == ["Orca X1C"] + assert [p.name for p in slots["process"]] == ["Orca 0.20mm"] + filament = slots["filament"] + assert [p.name for p in filament] == ["Orca PLA"] + # Inline metadata extracted from the content blob (Orca's sync_pull + # returns full content, so unlike Bambu Cloud we don't need a second + # per-preset fetch to enrich filament_type / filament_colour). + assert filament[0].filament_type == "PLA" + assert filament[0].filament_colour == "#000000" + + @pytest.mark.asyncio + async def test_cache_hit_skips_orca_call(self): + """A second call within TTL must reuse the cached slots and NOT + hit the Orca service again — same TTL as Bambu Cloud (5 min).""" + sp._orca_cloud_cache.clear() + svc_mock = MagicMock() + svc_mock.list_profiles = AsyncMock(return_value=[]) + svc_mock.close = AsyncMock() + user = MagicMock(id=1) + user.has_permission = MagicMock(return_value=True) + with ( + patch.object(sp, "_load_orca_credentials", AsyncMock(return_value=self._orca_creds("tok"))), + patch.object(sp, "_build_orca_service", AsyncMock(return_value=svc_mock)) as build, + ): + await sp._fetch_orca_cloud_presets(MagicMock(), user) + await sp._fetch_orca_cloud_presets(MagicMock(), user) + # Build is the cache miss signal — second call reused the cache. + build.assert_awaited_once() + + class TestFetchCloudPresets: """`_fetch_cloud_presets` translates token state and cloud errors into the four ``cloud_status`` values the SliceModal banner consumes.""" diff --git a/frontend/public/sw.js b/frontend/public/sw.js index e26cca794..f7bb88ea4 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,6 +1,6 @@ // Bambuddy Service Worker -const CACHE_NAME = 'bambuddy-v28'; -const STATIC_CACHE = 'bambuddy-static-v27'; +const CACHE_NAME = 'bambuddy-v29'; +const STATIC_CACHE = 'bambuddy-static-v28'; // Static assets to cache on install const STATIC_ASSETS = [ @@ -31,23 +31,46 @@ self.addEventListener('install', (event) => { self.skipWaiting(); }); -// Activate event - clean up old caches +// Activate event - clean up old caches, then force-reload any controlled +// windows so they pick up the new bundle. Important for the SpoolBuddy kiosk +// (Pi + Chromium-in-kiosk-mode, no devtools, no manual reload control): +// without this hop, restarting Chromium installs the new SW but the existing +// document keeps running the previously-cached bundle until a navigation +// happens — which on a locked kiosk never occurs. self.addEventListener('activate', (event) => { console.log('[SW] Activating service worker...'); event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( + (async () => { + const cacheNames = await caches.keys(); + await Promise.all( cacheNames .filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); - }) + }), ); - }) + // Take control immediately. + await self.clients.claim(); + // Force a fresh navigation in any window that this SW now controls. + // ``client.navigate(client.url)`` re-requests the page through the + // network-first fetch handler, picking up the new index.html + the + // new content-hashed JS bundle. Guarded so the very first install on + // a never-controlled client doesn't trigger an unwanted reload. + const clients = await self.clients.matchAll({ type: 'window' }); + for (const client of clients) { + try { + if (client.url && typeof client.navigate === 'function') { + await client.navigate(client.url); + } + } catch (e) { + // Some browsers reject navigate on cross-origin or detached + // clients — swallow so one bad client doesn't break the rest. + console.warn('[SW] Forced reload skipped for client:', client.url, e); + } + } + })(), ); - // Take control immediately - self.clients.claim(); }); // Fetch event - network-first for API, cache-first for static diff --git a/frontend/scripts/check-i18n-parity.mjs b/frontend/scripts/check-i18n-parity.mjs index b11ca85b9..a3d8b6f7f 100644 --- a/frontend/scripts/check-i18n-parity.mjs +++ b/frontend/scripts/check-i18n-parity.mjs @@ -142,6 +142,7 @@ function isAlwaysAllowedIdentical(value) { const DE_COGNATES = [ 'Name', 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Modus', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Bug', 'Job', + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'Pause', 'Power', 'System', 'Problem', 'Designer', 'Extruder', 'Firmware', 'Material', 'Original', 'Position', 'Webhook', 'Workflow', 'Slicer', 'Region', 'Normal', 'Orange', 'Branch', 'Budget', 'Commit', 'Global', @@ -169,6 +170,7 @@ const DE_COGNATES = [ // French cognates — many UI labels overlap with English exactly. const FR_COGNATES = [ + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament', 'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Plate', 'Layer', 'Active', 'Total', 'Avatar', @@ -205,6 +207,8 @@ const FR_COGNATES = [ // Italian cognates. const IT_COGNATES = [ + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale + 'Email', // common loanword in Italian, used verbatim in UI labels 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament', 'Filaments', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', @@ -232,6 +236,7 @@ const IT_COGNATES = [ // everything needs translation. Only true loanwords / proper nouns stay. const JA_COGNATES = [ 'OK', 'Bambu', 'Code', + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'EU (DD/MM/YYYY)', 'US (MM/DD/YYYY)', 'ON, true, 1', '({{count}}/8)', 'Custom Headers (JSON)', 'Box label (62 × 29 mm)', @@ -242,6 +247,7 @@ const JA_COGNATES = [ // Portuguese (BR) cognates. const PT_BR_COGNATES = [ + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'Status', 'Tag', 'Tags', 'Online', 'Offline', 'Standard', 'Filament', 'Software', 'Hardware', 'Stop', 'Reset', 'Test', 'Code', 'Token', 'Server', 'Port', 'Plate', 'Layer', 'Modal', 'Pin', 'Pro', 'Mini', 'Studio', 'Cache', @@ -269,6 +275,7 @@ const PT_BR_COGNATES = [ // Chinese (Simplified): very few cognates beyond brand names. const ZH_CN_COGNATES = [ 'OK', 'Bambu', + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale '({{count}}/8)', 'Custom Headers (JSON)', 'Box label (62 × 29 mm)', 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)', @@ -278,6 +285,7 @@ const ZH_CN_COGNATES = [ const ZH_TW_COGNATES = [ 'OK', 'Bambu', + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale '({{count}}/8)', 'Custom Headers (JSON)', 'Box label (62 × 29 mm)', 'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)', @@ -289,6 +297,7 @@ const ZH_TW_COGNATES = [ // Allow loanwords/acronyms, format strings, and proper nouns that stay verbatim. const KO_COGNATES = [ 'OK', 'Bambu', 'N/A', + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale '({{count}}/8)', '(25%, 50%, 75%)', 'Custom Headers (JSON)', 'Box label (62 × 29 mm)', @@ -305,6 +314,7 @@ const KO_COGNATES = [ // Spanish cognates — words/phrases that are genuinely identical in Spanish. const ES_COGNATES = [ + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'Error', 'Firmware', 'General', 'Control', 'Total', 'total', 'Material', 'Material:', 'Color', 'Hex', 'Local', 'Global', 'China', 'Editable', 'Normal', 'Metal', 'Multicolor', 'Proxy', 'Host', 'Factor', 'Original', @@ -323,6 +333,7 @@ const ES_COGNATES = [ // from English (loanwords + acronyms + format strings). Curated, not a shortcut. const TR_COGNATES = [ 'Filament', 'Firmware', 'Disk', 'Hex', 'Test', 'Port', 'Model', 'Metal', + 'Bambu Cloud', 'Orca Cloud', // brand names — same in every locale 'Min', 'Normal', 'Platform', 'Net', 'Trend', 'Commit', 'Global', 'Proxy', 'N/A', 'email', 'STARTTLS (Port 587)', 'SSL/TLS (Port 465)', diff --git a/frontend/src/__tests__/components/OrcaCloudView.test.tsx b/frontend/src/__tests__/components/OrcaCloudView.test.tsx new file mode 100644 index 000000000..cebec42d7 --- /dev/null +++ b/frontend/src/__tests__/components/OrcaCloudView.test.tsx @@ -0,0 +1,211 @@ +/** + * Tests for OrcaCloudView component — covers the four UI phases of the + * paste-based PKCE handshake: disconnected, awaiting-paste, connected, + * and disconnect. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +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 noProfilesResponse = { profiles: [] }; + +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 }), + ), + ); + render(); + + await waitFor(() => { + expect(screen.getByText(/Connect to 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; + 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', 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(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Sign in with Apple/i })).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /Sign in with Apple/i })); + + await waitFor(() => { + expect(receivedProvider).toBe('apple'); + }); + expect(window.open).toHaveBeenCalledWith( + 'https://auth.orcaslicer.com/auth/v1/authorize?test=1', + '_blank', + 'noopener,noreferrer', + ); + }); + + it('connects via email and password without the paste flow', 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: 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 }; + 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(); + + 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' }), + ), + ); + render(); + + 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(); + }); + + 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 })); + + await waitFor(() => { + expect(screen.getByText(/does not look like an Orca Cloud callback/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; + 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' }), + ), + 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(); + + 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' }, + }); + fireEvent.click(screen.getByRole('button', { name: /Finish connecting/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.getByRole('button', { name: /Disconnect/i })).toBeInTheDocument(); + }); + + it('clears the connection on Disconnect', async () => { + let connected = true; + server.use( + http.get('/api/v1/orca-cloud/status', () => + HttpResponse.json( + connected + ? { connected: true, email: 'martin@example.com', user_id: 'u1' } + : { connected: false, email: null, user_id: null }, + ), + ), + http.get('/api/v1/orca-cloud/profiles', () => HttpResponse.json(noProfilesResponse)), + http.post('/api/v1/orca-cloud/logout', () => { + connected = false; + return HttpResponse.json({ success: true }); + }), + ); + render(); + + await waitFor(() => { + expect(screen.getByText('martin@example.com')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByRole('button', { name: /Disconnect/i })); + + await waitFor(() => { + expect(screen.getByText(/Connect to Orca Cloud/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/components/SliceModal.test.tsx b/frontend/src/__tests__/components/SliceModal.test.tsx index 63dd266d4..fdb9afc81 100644 --- a/frontend/src/__tests__/components/SliceModal.test.tsx +++ b/frontend/src/__tests__/components/SliceModal.test.tsx @@ -46,10 +46,12 @@ const mockApi = api as unknown as { function makeUnified(overrides: Partial = {}): UnifiedPresetsResponse { return { + orca_cloud: { printer: [], process: [], filament: [] }, cloud: { printer: [], process: [], filament: [] }, local: { printer: [], process: [], filament: [] }, standard: { printer: [], process: [], filament: [] }, cloud_status: 'ok', + orca_cloud_status: 'ok', ...overrides, }; } @@ -166,7 +168,7 @@ describe('SliceModal', () => { const groups = printerSelect.querySelectorAll('optgroup'); expect(Array.from(groups).map((g) => g.label)).toEqual([ 'Imported', - 'Cloud', + 'Bambu Cloud', 'Standard', ]); @@ -703,6 +705,7 @@ describe('SliceModal', () => { // should match each plate slot to the same-colour preset so the user // doesn't have to manually align them. return { + orca_cloud: { printer: [], process: [], filament: [] }, cloud: { printer: [{ id: 'P1', name: 'X1C', source: 'cloud' }], process: [{ id: 'PR1', name: '0.20mm', source: 'cloud' }], @@ -714,6 +717,7 @@ describe('SliceModal', () => { local: { printer: [], process: [], filament: [] }, standard: { printer: [], process: [], filament: [] }, cloud_status: 'ok', + orca_cloud_status: 'ok', }; } @@ -921,6 +925,8 @@ describe('SliceModal', () => { local: { printer: [], process: [], filament: [] }, standard: { printer: [], process: [], filament: [] }, cloud_status: 'ok', + orca_cloud: { printer: [], process: [], filament: [] }, + orca_cloud_status: 'ok', }); renderWithTracker({ @@ -985,6 +991,8 @@ describe('SliceModal', () => { local: { printer: [], process: [], filament: [] }, standard: { printer: [], process: [], filament: [] }, cloud_status: 'ok', + orca_cloud: { printer: [], process: [], filament: [] }, + orca_cloud_status: 'ok', }); mockApi.sliceLibraryFile.mockResolvedValue({ job_id: 50, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 80b3ed741..d21edccc6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1185,6 +1185,51 @@ export interface CloudLoginResponse { tfa_key?: string | null; } +// 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 OrcaAuthStatusResponse { + connected: boolean; + email: string | null; + user_id: string | null; +} + +// Orca profiles are shaped to match Bambu Cloud's SlicerSetting on the wire +// so the frontend can use the same visual components for both surfaces (cards, +// grouped sections, filter bar). Backend handles the source-specific +// transformation in routes/orca_cloud.py::_orca_to_setting. +export interface OrcaProfileMeta { + setting_id: string; + name: string; + type: string; + version: string | null; + user_id: string | null; + updated_time: string | null; + is_custom: boolean; +} + +export interface OrcaProfileListResponse { + filament: OrcaProfileMeta[]; + printer: OrcaProfileMeta[]; + process: OrcaProfileMeta[]; +} + +export interface OrcaProfileDetail { + setting_id: string; + name: string; + type: string; + version: string | null; + base_id: string | null; + update_time: string | null; + setting: Record; +} + // MakerWorld integration. Full metadata/instance shapes come back as // Record — MakerWorld's API adds fields over time, so we // pass them through verbatim rather than maintaining a brittle mirror. @@ -1305,7 +1350,7 @@ export interface BuiltinFilament { // - Source-aware refs (`*_preset: PresetRef`) — new SliceModal that picks // across cloud / local / standard tiers. Source-aware refs win when both // are present in the same payload. -export type PresetSource = 'cloud' | 'local' | 'standard'; +export type PresetSource = 'orca_cloud' | 'cloud' | 'local' | 'standard'; export interface PresetRef { source: PresetSource; id: string; @@ -1384,10 +1429,14 @@ export interface UnifiedPresetsBySlot { filament: UnifiedPreset[]; } export interface UnifiedPresetsResponse { + // Priority order: orca_cloud > cloud > local > standard. Dedup is applied + // backend-side so each name appears in only one tier. + orca_cloud: UnifiedPresetsBySlot; cloud: UnifiedPresetsBySlot; local: UnifiedPresetsBySlot; standard: UnifiedPresetsBySlot; cloud_status: SlicerCloudStatus; + orca_cloud_status: SlicerCloudStatus; } export interface SliceResponse { @@ -2814,7 +2863,7 @@ export type Permission = | 'system:read' | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore' | 'github:backup' | 'github:restore' - | 'cloud:auth' + | 'cloud:auth' | 'orca_cloud:auth' | 'makerworld:view' | 'makerworld:import' | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete' | 'users:read' | 'users:create' | 'users:update' | 'users:delete' @@ -4341,6 +4390,34 @@ 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('/orca-cloud/auth/start', { + method: 'POST', + body: JSON.stringify({ provider }), + }), + orcaCloudFinishAuth: (callback_url: string) => + request('/orca-cloud/auth/finish', { + method: 'POST', + body: JSON.stringify({ callback_url }), + }), + orcaCloudPasswordLogin: (email: string, password: string) => + request('/orca-cloud/auth/password', { + method: 'POST', + body: JSON.stringify({ email, password }), + }), + orcaCloudStatus: () => + request('/orca-cloud/status'), + orcaCloudLogout: () => + request<{ success: boolean }>('/orca-cloud/logout', { method: 'POST' }), + orcaCloudListProfiles: () => + request('/orca-cloud/profiles'), + orcaCloudGetProfile: (id: string) => + request(`/orca-cloud/profiles/${id}`), getCloudSettings: (version = '02.04.00.70') => request(`/cloud/settings?version=${version}`), getBuiltinFilaments: () => diff --git a/frontend/src/components/ConfigureAmsSlotModal.tsx b/frontend/src/components/ConfigureAmsSlotModal.tsx index ed70fcd00..c5918fe9a 100644 --- a/frontend/src/components/ConfigureAmsSlotModal.tsx +++ b/frontend/src/components/ConfigureAmsSlotModal.tsx @@ -266,6 +266,17 @@ export function ConfigureAmsSlotModal({ retry: false, }); + // Orca Cloud filament profiles, same shape as Bambu Cloud's. Each query + // is independent — the picker degrades gracefully if Orca Cloud isn't + // connected (no entries surface, no error banner because we don't want + // to nag users who deliberately only use Bambu Cloud). + const { data: orcaCloudList } = useQuery({ + queryKey: ['orcaCloudProfilesForAmsSlot'], + queryFn: () => api.orcaCloudListProfiles(), + enabled: isOpen, + retry: false, + }); + // Fetch local presets const { data: localPresets, isLoading: localLoading } = useQuery({ queryKey: ['localPresets'], @@ -301,9 +312,23 @@ export function ConfigureAmsSlotModal({ mutationFn: async () => { if (!selectedPresetId) throw new Error('No filament preset selected'); - // Determine preset source + // Determine preset source. Orca detection is done via setting_id + // lookup as well as the ``orca_`` prefix because the saved-preset + // pre-population on modal open (the useEffect at the top of this + // component) writes ``slotInfo.savedPresetId`` verbatim — which for a + // historical Orca save would be the raw UUID, no prefix. Falling + // through to the cloud lookup in that case would have thrown + // "Selected preset not found". const isLocal = selectedPresetId.startsWith('local_'); const isBuiltin = selectedPresetId.startsWith('builtin_'); + const orcaSettingId = selectedPresetId.startsWith('orca_') + ? selectedPresetId.replace('orca_', '') + : selectedPresetId; + const orcaPresetCandidate = (!isLocal && !isBuiltin) + ? orcaCloudList?.filament.find(p => p.setting_id === orcaSettingId) + : undefined; + const isOrca = !!orcaPresetCandidate; + const orcaPreset = orcaPresetCandidate ?? null; const localId = isLocal ? parseInt(selectedPresetId.replace('local_', ''), 10) : null; const builtinFilamentId = isBuiltin ? selectedPresetId.replace('builtin_', '') : null; const localPreset = isLocal @@ -313,17 +338,24 @@ export function ConfigureAmsSlotModal({ ? builtinFilaments?.find(b => b.filament_id === builtinFilamentId) : null; - // Get the selected cloud preset details (null for local/builtin presets) - const selectedPreset = (!isLocal && !isBuiltin) + // Get the selected cloud preset details (null for local/builtin/orca presets) + const selectedPreset = (!isLocal && !isBuiltin && !isOrca) ? cloudSettings?.filament.find(p => p.setting_id === selectedPresetId) : null; - if (!isLocal && !isBuiltin && !selectedPreset) throw new Error('Selected preset not found'); + if (!isLocal && !isBuiltin && !isOrca && !selectedPreset) throw new Error('Selected preset not found'); if (isLocal && !localPreset) throw new Error('Selected local preset not found'); if (isBuiltin && !builtinPreset) throw new Error('Selected builtin preset not found'); + if (isOrca && !orcaPreset) throw new Error('Selected Orca Cloud preset not found'); // Parse the preset name for filament info - const presetName = isLocal ? localPreset!.name : isBuiltin ? builtinPreset!.name : selectedPreset!.name; + const presetName = isLocal + ? localPreset!.name + : isBuiltin + ? builtinPreset!.name + : isOrca + ? orcaPreset!.name + : selectedPreset!.name; const parsed = parsePresetName(presetName); // Get cali_idx from selected K profile's slot_id (-1 = use default 0.020) @@ -342,21 +374,27 @@ export function ConfigureAmsSlotModal({ // Prefer this over stored filament_type which may have been parsed with old logic. const parsedMat = parsed.material.toUpperCase(); + // Generic Bambu filament-ID map used to derive a ``tray_info_idx`` for + // presets that don't carry a Bambu setting_id of their own (local + // imports and Orca Cloud sync both fall in this bucket). The printer's + // firmware needs SOMETHING in tray_info_idx to recognize the filament + // type for HMS / drying / colour-matching; the closest generic Bambu + // filament for the parsed material is the right choice. + const GENERIC_IDS: Record = { + 'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95', + 'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97', + 'ABS': 'GFB99', 'ASA': 'GFB98', + 'PC': 'GFC99', + 'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99', + 'TPU': 'GFU99', + 'PVA': 'GFS99', 'HIPS': 'GFS98', + 'PE': 'GFP99', 'PP': 'GFP97', + }; + if (isLocal) { // Local presets have no Bambu Cloud setting_id, but need a valid // tray_info_idx for the printer to recognize the filament type. - // Map the material type to the closest generic Bambu filament ID. const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '').toUpperCase(); - const GENERIC_IDS: Record = { - 'PLA': 'GFL99', 'PLA-CF': 'GFL98', 'PLA SILK': 'GFL96', 'PLA HIGH SPEED': 'GFL95', - 'PETG': 'GFG99', 'PETG HF': 'GFG96', 'PETG-CF': 'GFG98', 'PCTG': 'GFG97', - 'ABS': 'GFB99', 'ASA': 'GFB98', - 'PC': 'GFC99', - 'PA': 'GFN99', 'PA-CF': 'GFN98', 'NYLON': 'GFN99', - 'TPU': 'GFU99', - 'PVA': 'GFS99', 'HIPS': 'GFS98', - 'PE': 'GFP99', 'PP': 'GFP97', - }; // Try exact match first, then base material (strip suffixes like "-CF", "+", " HF") trayInfoIdx = GENERIC_IDS[material] || GENERIC_IDS[material.replace(/[-\s]?CF$/, '')] @@ -364,6 +402,18 @@ export function ConfigureAmsSlotModal({ || GENERIC_IDS[material.split(/[-\s]/)[0]] || ''; settingId = ''; + } else if (isOrca) { + // Orca Cloud presets have a UUID setting_id that Bambu printers can't + // resolve; treat them like local imports — derive a generic tray_info + // _idx from the parsed material, leave settingId empty so the slicer + // doesn't get a foreign cloud ID it can't look up. + const material = (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || '').toUpperCase(); + trayInfoIdx = GENERIC_IDS[material] + || GENERIC_IDS[material.replace(/[-\s]?CF$/, '')] + || GENERIC_IDS[material.replace(/\+$/, '')] + || GENERIC_IDS[material.split(/[-\s]/)[0]] + || ''; + settingId = ''; } else if (isBuiltin) { // Built-in presets use the filament_id directly as tray_info_idx trayInfoIdx = builtinFilamentId!; @@ -392,7 +442,7 @@ export function ConfigureAmsSlotModal({ let tempMin = isLocal && localPreset?.nozzle_temp_min ? localPreset.nozzle_temp_min : 190; let tempMax = isLocal && localPreset?.nozzle_temp_max ? localPreset.nozzle_temp_max : 230; - if (!isLocal || isBuiltin || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) { + if (!isLocal || isBuiltin || isOrca || (!localPreset?.nozzle_temp_min && !localPreset?.nozzle_temp_max)) { // Fall back to material-based defaults (prefer parsed material for "Support for" handling) const material = (isLocal ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || '') @@ -431,7 +481,9 @@ export function ConfigureAmsSlotModal({ // patterns correctly) over stored filament_type which may have been parsed with old logic. const trayType = isLocal ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : localPreset?.filament_type || parsed.material || 'PLA') - : (parsed.material || 'PLA'); + : isOrca + ? (MATERIAL_TYPES.includes(parsedMat) ? parsedMat : parsed.material || 'PLA') + : (parsed.material || 'PLA'); // Configure the slot via MQTT const result = await api.configureAmsSlot(printerId, slotInfo.amsId, slotInfo.trayId, { @@ -454,8 +506,20 @@ export function ConfigureAmsSlotModal({ // Save the preset mapping so we can display the correct name in the UI // This is needed because user presets use filament_id (e.g., P285e239) as tray_info_idx, // which can't be resolved to a name via the filamentInfo API - const mappingPresetId = isLocal ? `local_${localId}` : isBuiltin ? `builtin_${builtinFilamentId}` : selectedPresetId; - const mappingSource = isLocal ? 'local' : isBuiltin ? 'builtin' : 'cloud'; + const mappingPresetId = isLocal + ? `local_${localId}` + : isBuiltin + ? `builtin_${builtinFilamentId}` + : isOrca + ? selectedPresetId + : selectedPresetId; + const mappingSource = isLocal + ? 'local' + : isBuiltin + ? 'builtin' + : isOrca + ? 'orca_cloud' + : 'cloud'; try { await api.saveSlotPreset(printerId, slotInfo.amsId, slotInfo.trayId, mappingPresetId, traySubBrands, mappingSource); } catch (e) { @@ -491,24 +555,45 @@ export function ConfigureAmsSlotModal({ }, }); - // Unified preset item for the list (cloud + local + builtin fallback) - type PresetItem = { id: string; name: string; source: 'cloud' | 'local' | 'builtin'; isUser: boolean }; + // Unified preset item for the list (orca_cloud + cloud + local + builtin fallback) + type PresetItem = { id: string; name: string; source: 'orca_cloud' | 'cloud' | 'local' | 'builtin'; isUser: boolean }; - // Filter filament presets based on search (merged cloud + local + builtin) + // Filter filament presets based on search (merged orca_cloud + cloud + local + builtin) const filteredPresets = useMemo(() => { const query = searchQuery.toLowerCase(); const items: PresetItem[] = []; - // Collect IDs already covered by cloud and local to avoid duplicates in fallback + // Collect IDs already covered by higher-priority tiers to avoid duplicates const coveredIds = new Set(); // Currently-configured preset should always be shown (bypass model filter) const savedId = slotInfo.savedPresetId; const trayIdx = slotInfo.trayInfoIdx; + // 0. Orca Cloud filament presets — surfaced first because the user + // explicitly opted into Orca sync; their picks should outrank Bambu Cloud + // presets of the same name. IDs are prefixed ``orca_`` so the configure + // flow can detect "this is an Orca preset" via a cheap string check + // (mirrors the ``local_`` / ``builtin_`` prefix convention already in use). + if (orcaCloudList?.filament) { + for (const op of orcaCloudList.filament) { + const orcaId = `orca_${op.setting_id}`; + coveredIds.add(op.setting_id); + coveredIds.add(orcaId); + if (query && !op.name.toLowerCase().includes(query)) continue; + if (printerModel) { + const presetModel = extractPresetModel(op.name); + if (presetModel && presetModel.toUpperCase() !== printerModel.toUpperCase()) continue; + } + // All Orca Cloud profiles are user-authored, so isUser is always true. + items.push({ id: orcaId, name: op.name, source: 'orca_cloud', isUser: true }); + } + } + // 1. Cloud presets if (cloudSettings?.filament) { for (const cp of cloudSettings.filament) { + if (coveredIds.has(cp.setting_id)) continue; coveredIds.add(cp.setting_id); // Keep preset if it matches the slot's saved mapping or current tray_info_idx const isSavedPreset = savedId === cp.setting_id; @@ -550,21 +635,24 @@ export function ConfigureAmsSlotModal({ } } - // Sort: cloud user presets first, then cloud built-in, then local, then builtin fallback + // Sort: orca_cloud first (user-curated), then cloud user presets, then + // cloud built-in, then local, then builtin fallback return items.sort((a, b) => { - const sourceOrder = { cloud: 0, local: 1, builtin: 2 }; + const sourceOrder = { orca_cloud: 0, cloud: 1, local: 2, builtin: 3 }; if (a.source !== b.source) return sourceOrder[a.source] - sourceOrder[b.source]; if (a.isUser && !b.isUser) return -1; if (!a.isUser && b.isUser) return 1; return a.name.localeCompare(b.name); }); - }, [cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]); + }, [orcaCloudList?.filament, cloudSettings?.filament, localPresets?.filament, builtinFilaments, searchQuery, printerModel, slotInfo.savedPresetId, slotInfo.trayInfoIdx]); // Get full preset name for K profile filtering (brand + material, without printer suffix) const selectedPresetInfo = useMemo(() => { if (!selectedPresetId) return null; - // Resolve the name from cloud, local, or builtin presets + // Resolve the name from orca, cloud, local, or builtin presets. The + // Orca branch tolerates both ``orca_`` and a bare UUID — see the + // configure-mutation comment for why the raw UUID also reaches us. let presetName: string | null = null; if (selectedPresetId.startsWith('local_')) { const localId = parseInt(selectedPresetId.replace('local_', ''), 10); @@ -574,11 +662,17 @@ export function ConfigureAmsSlotModal({ const filamentId = selectedPresetId.replace('builtin_', ''); const bf = builtinFilaments?.find(b => b.filament_id === filamentId); presetName = bf?.name || null; - } else if (cloudSettings?.filament) { - const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId); - presetName = cp?.name || null; } else { - // No cloud settings available + const orcaCandidateId = selectedPresetId.startsWith('orca_') + ? selectedPresetId.replace('orca_', '') + : selectedPresetId; + const op = orcaCloudList?.filament.find(p => p.setting_id === orcaCandidateId); + if (op) { + presetName = op.name; + } else if (cloudSettings?.filament) { + const cp = cloudSettings.filament.find(p => p.setting_id === selectedPresetId); + presetName = cp?.name || null; + } } if (!presetName) { return null; @@ -597,7 +691,7 @@ export function ConfigureAmsSlotModal({ material: parsed.material, brand: parsed.brand, }; - }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments]); + }, [selectedPresetId, cloudSettings?.filament, localPresets?.filament, builtinFilaments, orcaCloudList?.filament]); // For backwards compatibility with the label const selectedMaterial = selectedPresetInfo?.fullName || ''; diff --git a/frontend/src/components/OrcaCloudProfilesView.tsx b/frontend/src/components/OrcaCloudProfilesView.tsx new file mode 100644 index 000000000..7e3b44d4f --- /dev/null +++ b/frontend/src/components/OrcaCloudProfilesView.tsx @@ -0,0 +1,399 @@ +import { useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { TFunction } from 'i18next'; +import { Search, Filter, RefreshCw, Droplet, Settings2, Printer as PrinterIcon, Layers, X, Loader2, Clock } from 'lucide-react'; + +import { api } from '../api/client'; +import type { OrcaProfileListResponse, OrcaProfileMeta, Printer } from '../api/client'; +import { Button } from './Button'; +import { FilterDropdown } from '../pages/ProfilesPage'; +import { formatRelativeTime } from '../utils/date'; + +/** + * Read-only profile browser for the Orca Cloud tab. + * + * Visual parity with Bambu Cloud's CloudProfilesView: same filter bar layout, + * same 3-column grouped list (Filament / Process / Printer), same card + * styling. Differences vs the Bambu version are removals — no + * Create / Edit / Duplicate / Delete / Compare / Templates buttons because + * Orca's `/sync/push` and `/sync/delete` endpoints aren't wired in this + * shipping cut (planned for a follow-up). The "Owner" filter is also + * dropped: every profile that lives in a user's Orca Cloud account is + * user-authored, the system/builtin distinction doesn't apply. + */ + +type ProfileType = 'all' | 'filament' | 'printer' | 'process'; + +interface PresetMeta { + printer: string | null; + nozzle: string | null; + layerHeight: string | null; + filamentType: string | null; +} + +// Mirror of ProfilesPage.tsx::extractMetadata. Inlined rather than exported +// because the patterns are conservative (Bambu printer-name regex etc.) and +// Orca profile naming follows the same conventions (Orca is a BambuStudio +// fork; profile names like "Bambu PLA Basic @BBL X1C" carry over verbatim). +function extractMetadata(name: string): PresetMeta { + const printerMatch = name.match(/@?\s*(?:BBL\s+)?(?:Bambu\s+Lab\s+)?([XPAH][1-9][A-Z]?(?:\s*(?:Carbon|mini))?|H2D)/i); + const nozzleMatch = name.match(/(\d+\.?\d*)\s*(?:mm\s*)?nozzle|nozzle\s*(\d+\.?\d*)/i); + const layerMatch = name.match(/(\d+\.?\d*)mm\s*(?:Standard|Fine|Extra Fine|Draft|Quality)?/i); + const filamentMatch = name.match(/\b(PLA|PETG|ABS|ASA|TPU|PC|PA|PVA|HIPS|PP|PET(?:-?CF)?|PA(?:-?CF)?|PLA(?:-?CF)?)\b/i); + return { + printer: printerMatch ? printerMatch[1].trim() : null, + nozzle: nozzleMatch ? (nozzleMatch[1] || nozzleMatch[2]) + 'mm' : null, + layerHeight: layerMatch ? layerMatch[1] + 'mm' : null, + filamentType: filamentMatch ? filamentMatch[1].toUpperCase() : null, + }; +} + +interface OrcaCloudProfilesViewProps { + settings: OrcaProfileListResponse; + lastSyncTime?: Date; + onRefresh: () => void; + isRefreshing: boolean; + printers: Printer[]; + t: TFunction; +} + +export function OrcaCloudProfilesView({ + settings, + lastSyncTime, + onRefresh, + isRefreshing, + printers, + t, +}: OrcaCloudProfilesViewProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [filterPrinter, setFilterPrinter] = useState('all'); + const [filterNozzle, setFilterNozzle] = useState('all'); + const [filterFilament, setFilterFilament] = useState('all'); + const [filterLayerHeight, setFilterLayerHeight] = useState('all'); + const [selectedSetting, setSelectedSetting] = useState(null); + + const allPresetsWithMeta = useMemo(() => { + const combined = [ + ...settings.filament.map(s => ({ ...s, type: 'filament' as const })), + ...settings.printer.map(s => ({ ...s, type: 'printer' as const })), + ...settings.process.map(s => ({ ...s, type: 'process' as const })), + ]; + return combined.map(s => ({ ...s, meta: extractMetadata(s.name) })); + }, [settings]); + + const filterOptions = useMemo(() => { + const nozzles = new Set(); + const filaments = new Set(); + const layerHeights = new Set(); + allPresetsWithMeta.forEach(p => { + if (p.meta.nozzle) nozzles.add(p.meta.nozzle); + if (p.meta.filamentType) filaments.add(p.meta.filamentType); + if (p.meta.layerHeight) layerHeights.add(p.meta.layerHeight); + }); + return { + printers: printers.map(p => ({ id: p.id.toString(), name: p.name })), + nozzles: Array.from(nozzles).sort((a, b) => parseFloat(a) - parseFloat(b)), + filaments: Array.from(filaments).sort(), + layerHeights: Array.from(layerHeights).sort((a, b) => parseFloat(a) - parseFloat(b)), + }; + }, [allPresetsWithMeta, printers]); + + const selectedPrinterModel = useMemo(() => { + if (filterPrinter === 'all') return null; + const printer = printers.find(p => p.id.toString() === filterPrinter); + return printer?.model || null; + }, [filterPrinter, printers]); + + const filteredPresets = useMemo(() => { + return allPresetsWithMeta + .filter(s => filterType === 'all' || s.type === filterType) + .filter(s => { + if (filterPrinter === 'all' || !selectedPrinterModel) return true; + const presetPrinter = s.meta.printer?.toLowerCase() || ''; + const configuredModel = selectedPrinterModel.toLowerCase(); + return presetPrinter.includes(configuredModel) || configuredModel.includes(presetPrinter); + }) + .filter(s => filterNozzle === 'all' || s.meta.nozzle === filterNozzle) + .filter(s => filterFilament === 'all' || s.meta.filamentType === filterFilament) + .filter(s => filterLayerHeight === 'all' || s.meta.layerHeight === filterLayerHeight) + .filter(s => searchQuery === '' || s.name.toLowerCase().includes(searchQuery.toLowerCase())) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [allPresetsWithMeta, filterType, filterPrinter, selectedPrinterModel, filterNozzle, filterFilament, filterLayerHeight, searchQuery]); + + const clearFilters = () => { + setFilterType('all'); + setFilterPrinter('all'); + setFilterNozzle('all'); + setFilterFilament('all'); + setFilterLayerHeight('all'); + setSearchQuery(''); + }; + + const hasActiveFilters = + filterType !== 'all' || + filterPrinter !== 'all' || + filterNozzle !== 'all' || + filterFilament !== 'all' || + filterLayerHeight !== 'all' || + searchQuery !== ''; + + const totalCount = settings.filament.length + settings.printer.length + settings.process.length; + + const presetsByType = (type: ProfileType) => filteredPresets.filter(p => p.type === type); + + return ( + <> + {/* Search and Filters — mirrors the layout of profiles.cloudView in the + Bambu Cloud tab so the two tabs feel like the same surface. */} +
+
+
+ + setSearchQuery(e.target.value)} + placeholder={t('profiles.cloudView.searchPlaceholder')} + className="w-full pl-10 pr-4 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none" + /> +
+
+ +
+
+ +
+ + setFilterType(v as ProfileType)} + /> + {filterOptions.printers.length > 0 && ( + ({ value: p.id, label: p.name })), + ]} + onChange={setFilterPrinter} + /> + )} + {filterOptions.nozzles.length > 0 && ( + ({ value: n, label: n })), + ]} + onChange={setFilterNozzle} + /> + )} + {filterOptions.filaments.length > 0 && (filterType === 'all' || filterType === 'filament') && ( + ({ value: f, label: f })), + ]} + onChange={setFilterFilament} + /> + )} + {filterOptions.layerHeights.length > 0 && (filterType === 'all' || filterType === 'process') && ( + ({ value: l, label: l })), + ]} + onChange={setFilterLayerHeight} + /> + )} + {hasActiveFilters && ( + + )} +
+
+ +
+ {lastSyncTime && ( +
+ + {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)} +
+ )} + {t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })} +
+ + {filteredPresets.length === 0 ? ( +
+ +

{t('profiles.cloudView.noPresetsFound')}

+ {hasActiveFilters && ( + + )} +
+ ) : ( +
+ } + title={t('profiles.cloudView.columns.filament')} + presets={presetsByType('filament')} + emptyText={t('profiles.cloudView.noFilamentPresets')} + onSelect={setSelectedSetting} + /> + } + title={t('profiles.cloudView.columns.process')} + presets={presetsByType('process')} + emptyText={t('profiles.cloudView.noProcessPresets')} + onSelect={setSelectedSetting} + /> + } + title={t('profiles.cloudView.columns.printer')} + presets={presetsByType('printer')} + emptyText={t('profiles.cloudView.noPrinterPresets')} + onSelect={setSelectedSetting} + /> +
+ )} + + {selectedSetting && ( + setSelectedSetting(null)} + t={t} + /> + )} + + ); +} + +interface PresetColumnProps { + icon: React.ReactNode; + title: string; + presets: (OrcaProfileMeta & { meta: PresetMeta })[]; + emptyText: string; + onSelect: (preset: OrcaProfileMeta) => void; +} + +function PresetColumn({ icon, title, presets, emptyText, onSelect }: PresetColumnProps) { + return ( +
+
+ {icon} +

{title}

+ ({presets.length}) +
+
+ {presets.length === 0 ? ( +

{emptyText}

+ ) : ( + presets.map((preset) => ( + onSelect(preset)} /> + )) + )} +
+
+ ); +} + +function PresetCard({ + preset, + onClick, +}: { + preset: OrcaProfileMeta & { meta: PresetMeta }; + onClick: () => void; +}) { + return ( + + ); +} + +interface OrcaPresetDetailModalProps { + setting: OrcaProfileMeta; + onClose: () => void; + t: TFunction; +} + +function OrcaPresetDetailModal({ setting, onClose, t }: OrcaPresetDetailModalProps) { + const { data: detail, isLoading, error } = useQuery({ + queryKey: ['orcaCloudProfileDetail', setting.setting_id], + queryFn: () => api.orcaCloudGetProfile(setting.setting_id), + retry: false, + }); + + return ( +
+
e.stopPropagation()} + > +
+
+

{setting.name}

+

{setting.type}

+
+ +
+
+ {isLoading ? ( +
+ +
+ ) : error ? ( +

{(error as Error).message}

+ ) : detail ? ( +
+              {JSON.stringify(detail.setting, null, 2)}
+            
+ ) : ( +

{t('profiles.cloudView.noPresetsFound')}

+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/OrcaCloudView.tsx b/frontend/src/components/OrcaCloudView.tsx new file mode 100644 index 000000000..247d1479b --- /dev/null +++ b/frontend/src/components/OrcaCloudView.tsx @@ -0,0 +1,515 @@ +import { useEffect, 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 { api } from '../api/client'; +import type { OrcaOAuthProvider } from '../api/client'; +import { Card, CardContent } from './Card'; +import { Button } from './Button'; +import { useToast } from '../contexts/ToastContext'; +import { useAuth } from '../contexts/AuthContext'; +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. + */ +export function OrcaCloudView() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const { showToast } = useToast(); + 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(null); + const [pastedUrl, setPastedUrl] = useState(''); + const [pasteError, setPasteError] = useState(null); + const [passwordEmail, setPasswordEmail] = useState(''); + const [passwordValue, setPasswordValue] = useState(''); + const [passwordError, setPasswordError] = useState(null); + + const { data: status, isLoading: statusLoading } = useQuery({ + queryKey: ['orcaCloudStatus'], + queryFn: api.orcaCloudStatus, + }); + + const connected = !!status?.connected; + + const { + data: profilesData, + isLoading: profilesLoading, + refetch: refetchProfiles, + isRefetching: profilesRefetching, + error: profilesError, + dataUpdatedAt: profilesUpdatedAt, + } = useQuery({ + queryKey: ['orcaCloudProfiles'], + queryFn: api.orcaCloudListProfiles, + enabled: connected, + retry: false, + staleTime: 1000 * 60 * 5, + }); + + // Configured Bambuddy printers — fed into the profile-view's printer + // filter dropdown so the user can narrow profiles to a specific printer + // model. Same usage as the Bambu Cloud tab. + const { data: printers = [] } = useQuery({ + queryKey: ['printers'], + queryFn: api.getPrinters, + enabled: connected, + }); + + const [lastSyncTime, setLastSyncTime] = useState(); + useEffect(() => { + 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 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 passwordLoginMutation = useMutation({ + mutationFn: ({ email, password }: { email: string; password: string }) => + api.orcaCloudPasswordLogin(email, password), + 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 || '' })); + }, + onError: (err: Error) => { + setPasswordError(err.message || t('profiles.orcaCloud.errors.passwordFailed')); + }, + }); + + const logoutMutation = useMutation({ + mutationFn: api.orcaCloudLogout, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] }); + queryClient.removeQueries({ queryKey: ['orcaCloudProfiles'] }); + showToast(t('profiles.orcaCloud.toast.disconnected')); + }, + }); + + const handleSubmitPaste = (e: React.FormEvent) => { + e.preventDefault(); + setPasteError(null); + const trimmed = pastedUrl.trim(); + if (!trimmed) { + setPasteError(t('profiles.orcaCloud.errors.emptyPaste')); + return; + } + if (!trimmed.includes('code=')) { + setPasteError(t('profiles.orcaCloud.errors.noCode')); + return; + } + finishAuthMutation.mutate(trimmed); + }; + + const handleSubmitPassword = (e: React.FormEvent) => { + e.preventDefault(); + setPasswordError(null); + const email = passwordEmail.trim(); + if (!email || !passwordValue) { + setPasswordError(t('profiles.orcaCloud.errors.passwordEmpty')); + return; + } + passwordLoginMutation.mutate({ email, password: passwordValue }); + }; + + const resetToPicker = () => { + setMode('picker'); + setAuthUrl(null); + setPastedUrl(''); + setPasteError(null); + setPasswordEmail(''); + setPasswordValue(''); + setPasswordError(null); + }; + + if (statusLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {connected && ( +
+
+
+ + {t('profiles.orcaCloud.connectedAs')}{' '} + {status?.email} + +
+ +
+ )} + + {!connected ? ( + startAuthMutation.mutate(provider)} + onPickPassword={() => { + setMode('password'); + setPasswordError(null); + }} + onSubmitPaste={handleSubmitPaste} + onSubmitPassword={handleSubmitPassword} + onBack={resetToPicker} + isStarting={startAuthMutation.isPending} + isFinishing={finishAuthMutation.isPending} + isPasswordLoading={passwordLoginMutation.isPending} + canManage={canManage} + t={t} + /> + ) : profilesLoading ? ( +
+ +
+ ) : profilesError ? ( +
+

{(profilesError as Error).message}

+ +
+ ) : profilesData ? ( + refetchProfiles()} + isRefreshing={profilesRefetching} + printers={printers} + t={t} + /> + ) : null} +
+ ); +} + +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; + isStarting: boolean; + isFinishing: boolean; + isPasswordLoading: boolean; + canManage: boolean; + t: (key: string, opts?: Record) => string; +} + +function ConnectFlow(props: ConnectFlowProps) { + if (props.mode === 'paste' && props.authUrl) { + return ; + } + if (props.mode === 'password') { + return ; + } + return ; +} + +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 ( + + + +

+ {t('profiles.orcaCloud.connect.title')} +

+

+ {t('profiles.orcaCloud.connect.description')} +

+
+ + + + +
+
+
+ ); +} + +function PasteCard({ + authUrl, + pastedUrl, + setPastedUrl, + pasteError, + onSubmitPaste, + onBack, + isFinishing, + t, +}: ConnectFlowProps & { authUrl: string }) { + return ( + + + +

+ {t('profiles.orcaCloud.paste.title')} +

+ + {/* 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. */} +
    +
  1. + 1 +

    {t('profiles.orcaCloud.paste.step1')}

    +
  2. +
  3. + 2 +
    +
    + +

    {t('profiles.orcaCloud.paste.step2')}

    +
    +
    +
  4. +
  5. + 3 +

    {t('profiles.orcaCloud.paste.step3')}

    +
  6. +
+ +
+

{t('profiles.orcaCloud.paste.signInUrl')}

+ + {authUrl} + +
+
+ +