Przeglądaj źródła

fix(backup): collect cloud profiles from every connected account (#2717)

    Enabling Cloud Profiles for a Git backup produced nothing, and said it had
    worked. Two independent faults, either one sufficient.

    The collector looked for a "setting" list. The Bambu Cloud listing endpoint
    is keyed by preset type instead, each key holding private and public arrays,
    so the loop body never executed once — and the entries carry no type of
    their own either, which routes/cloud.py already knew: it takes the type from
    the outer key and maps Bambu's "print" to process. Two bugs on one line.

    It also asked build_authenticated_cloud for the credential store used when
    authentication is disabled. With auth on, tokens live on User rows, so the
    collector returned at "Cloud not authenticated" before ever reaching the bad
    key. Every multi-user install was collecting from zero accounts.

    Neither failure surfaced. backup_metadata.json recorded the configured flag
    rather than the outcome, so it claimed cloud_profiles: true on runs that
    wrote nothing, and the log read "Collected cloud profiles: 0 filament, 0
    printer, 0 process" at INFO — which is exactly what a successful backup of
    an empty account looks like.

    Cloud profiles now come from every connected account across both clouds. The
    toggle predates Orca Cloud entirely, and Orca has the same three preset
    types, so both are collected and grouped the same way:

        cloud_profiles/bambu/user-3/{filament,printer,process}.json
        cloud_profiles/orca/user-3/{filament,printer,process}.json

    Accounts are keyed by Bambuddy user id, "global" when auth is off. Never by
    email: a backup repository can be public, and the Bambu listing's user_id is
    dropped for the same reason. Both credential stores are read on every run,
    because a Settings row survives someone enabling auth later and dropping it
    would silently stop backing that account up.

    Bambu costs one get_setting_detail per private preset. The listing is
    metadata only, and without base_id and setting the backup is a list of names
    that create_setting cannot rebuild from. Public presets are skipped — Bambu's
    bundled catalogue is the same hundreds of entries for everyone, always
    re-downloadable, not recreatable under your account, and would rewrite the
    repository on every run. Orca needs no second call; its sync-pull carries
    each profile's content inline. Where the Orca route drops a profile whose
    content.type it cannot map, the backup writes it to other.json instead:
    silently omitting a profile because Orca added a type is the same class of
    bug as this one.

    Failures are contained per account and per preset, and counted rather than
    swallowed. A partial backup that looks complete is how this stayed invisible.

    The metadata now reports what was collected, per cloud and per account, and a
    run that collects nothing while the category is enabled warns with the reason
    instead of an INFO line that reads like success.

    The checkbox gated on the viewer's own Bambu sign-in, which is not the same
    question as whether there is anything to back up — with auth enabled the
    accounts belong to individual users, and an administrator who never signed
    in personally saw the category disabled with plenty in scope. It now gates
    on the total across both clouds and shows the counts. That comes from its
    own endpoint rather than a field on /config, since /config answers null
    until the first save and would disable the toggle during the very setup it
    belongs to. Counts only, never identities.

    One deliberate restraint. _build_authenticated_service clears stored
    credentials when a refresh is rejected, which is right for a route — the
    user is on the page and can pair again — and wrong for a scheduled job.
    Orca reports every rejection with one composite reason ("unknown, expired,
    revoked, or already used"), so a genuine revocation cannot be told apart
    from a lost token-rotation race, and acting destructively on a signal that
    cannot be disambiguated is the #2562 mistake in a different cloud. It also
    gains nothing: the Profiles route hits the same failure and clears it then,
    with the user present. Background callers now pass clear_on_auth_failure=
    False and skip the account. A successful refresh is still persisted either
    way — by that point the old token is consumed, so dropping the new pair
    would break a working pairing for real.

    Restore is not part of this. Nothing reads cloud_profiles/* yet; the format
    carries base_id/setting for Bambu and content for Orca so that it can.
maziggy 1 miesiąc temu
rodzic
commit
8e493318c0

Plik diff jest za duży
+ 1 - 0
CHANGELOG.md


+ 34 - 0
backend/app/api/routes/github_backup.py

@@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
+    CloudAccountCounts,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
     GitHubBackupConfigUpdate,
@@ -75,6 +76,39 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
         raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
 
 
+async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
+    """How many Bambu / Orca accounts a backup would collect from.
+
+    Asks the collector itself rather than re-deriving the rule, so the number
+    the UI gates on can't drift from the number the backup actually uses
+    (#2717). Counts only — never who.
+    """
+    try:
+        bambu, orca = await github_backup_service.cloud_accounts(db)
+        return len(bambu), len(orca)
+    except Exception:
+        # A settings page must still render when a credential store is
+        # unreadable; the toggle simply shows as unavailable.
+        logger.warning("Failed to count connected cloud accounts", exc_info=True)
+        return 0, 0
+
+
+@router.get("/cloud-accounts", response_model=CloudAccountCounts)
+async def get_cloud_accounts(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
+    """How many cloud accounts the Cloud Profiles category would collect from.
+
+    Its own endpoint rather than a field on ``/config``, because the settings
+    form needs this before any config exists — ``/config`` answers ``null``
+    until the first save, which would leave the toggle disabled during the
+    very setup it's part of.
+    """
+    bambu, orca = await _count_cloud_accounts(db)
+    return CloudAccountCounts(bambu=bambu, orca=orca)
+
+
 def _config_to_response(config: GitHubBackupConfig) -> dict:
     """Convert config model to response dict."""
     return {

+ 24 - 3
backend/app/api/routes/orca_cloud.py

@@ -431,6 +431,7 @@ async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> N
 async def _build_authenticated_service(
     db: AsyncSession,
     user: User | None,
+    clear_on_auth_failure: bool = True,
 ) -> OrcaCloudService:
     """Construct an :class:`OrcaCloudService` pre-populated with stored
     credentials. If the access token is within the refresh-leeway of expiry,
@@ -440,7 +441,24 @@ async def _build_authenticated_service(
     We don't lock around the refresh: Orca tolerates concurrent refreshes for
     ~60s (each racer gets its own valid pair on the same connection rather than
     a revoke), so a lost race here is harmless — last-write-wins on the stored
-    pair, and whichever pair we keep is valid."""
+    pair, and whichever pair we keep is valid.
+
+    ``clear_on_auth_failure`` controls what happens when the refresh is
+    rejected. Routes leave it on: the caller is a person looking at the UI, and
+    wiping the dead credentials flips the page to disconnected in front of them
+    so they can pair again. Background jobs pass ``False`` — see the caveat
+    below.
+
+    Why background callers must not clear: Orca reports every rejection with
+    one composite reason (``unknown, expired, revoked, or already used``), so
+    a genuine revocation is indistinguishable from a lost refresh-rotation
+    race. Acting destructively on a signal that can't be disambiguated is the
+    #2562 mistake in a different cloud. It also gains nothing — a route call
+    hits the same failure and clears then, at a moment the user can respond to.
+    A successful refresh is still persisted either way: by that point the old
+    refresh token is consumed, so dropping the new pair would break a working
+    pairing for real.
+    """
     creds = await _load_credentials(db, user)
     if not creds.token:
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
@@ -457,8 +475,11 @@ async def _build_authenticated_service(
             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)
+            # the stale credentials so the UI flips to disconnected — unless
+            # the caller is a background job, which must not change sign-in
+            # state on its own.
+            if clear_on_auth_failure:
+                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

+ 13 - 0
backend/app/schemas/github_backup.py

@@ -157,6 +157,19 @@ class GitHubBackupLogResponse(BaseModel):
         from_attributes = True
 
 
+class CloudAccountCounts(BaseModel):
+    """How many connected cloud accounts a backup would collect presets from.
+
+    Counts only, never identities: with auth enabled these are other users'
+    accounts, and whoever administers the backup has no business learning who
+    signed in to what. The number is enough to answer the only question the UI
+    asks — is the Cloud Profiles category worth offering at all (#2717).
+    """
+
+    bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
+    orca: int = Field(default=0, description="Connected Orca Cloud accounts")
+
+
 class GitHubBackupStatus(BaseModel):
     """Schema for current backup status."""
 

+ 335 - 58
backend/app/services/github_backup.py

@@ -8,7 +8,7 @@ import logging
 from datetime import datetime, timedelta, timezone
 
 import httpx
-from sqlalchemy import desc, select
+from sqlalchemy import desc, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.database import async_session
@@ -18,11 +18,61 @@ from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
 from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.user import User
 from backend.app.services.git_providers.factory import get_provider_backend
 from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
 
+# Bambu's listing endpoint is keyed by preset type and calls process presets
+# "print". Same mapping as `routes/cloud.py` — kept in step with it, since a
+# divergence here silently drops a whole preset type from every backup.
+_BAMBU_PRESET_TYPES = {
+    "filament": "filament",
+    "printer": "printer",
+    "print": "process",
+}
+
+
+def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -> dict:
+    """One Bambu preset as stored in the backup: metadata plus the payload.
+
+    ``base_id`` and ``setting`` are the two fields ``BambuCloudService.
+    create_setting`` needs, so a restore can rebuild the preset rather than
+    just list it.
+
+    ``user_id`` from the listing is deliberately dropped. It identifies the
+    account and adds nothing to a rebuild, and backup repositories can be
+    public.
+    """
+    return {
+        "setting_id": str(setting_id),
+        "name": detail.get("name") or entry.get("name") or "Unknown",
+        "type": our_type,
+        "version": detail.get("version") or entry.get("version"),
+        "updated_time": entry.get("updated_time"),
+        "base_id": detail.get("base_id"),
+        "filament_id": detail.get("filament_id"),
+        "setting": detail.get("setting") or {},
+    }
+
+
+def _orca_profile_record(entry: dict) -> dict:
+    """One Orca profile as stored in the backup.
+
+    ``content`` is kept whole rather than picked apart: it is the profile, the
+    sync API hands it over inline, and Orca owns its shape. Narrowing it here
+    would mean guessing which keys a future restore needs.
+    """
+    return {
+        "id": str(entry.get("id")) if entry.get("id") is not None else None,
+        "name": entry.get("name"),
+        "updated_time": entry.get("updated_time"),
+        "created_time": entry.get("created_time"),
+        "content": entry.get("content"),
+    }
+
+
 # Schedule intervals in seconds
 SCHEDULE_INTERVALS = {
     "hourly": 3600,
@@ -279,11 +329,13 @@ class GitHubBackupService:
         {
             "backup_metadata.json": {...},
             "kprofiles/{serial}/{nozzle}.json": {...},
-            "cloud_profiles/filament.json": [...],
-            "cloud_profiles/printer.json": [...],
-            "cloud_profiles/process.json": [...],
+            "cloud_profiles/bambu/{account}/{filament,printer,process}.json": {...},
+            "cloud_profiles/orca/{account}/{filament,printer,process}.json": {...},
             "settings/app_settings.json": {...},
         }
+
+        ``{account}`` is ``global`` when auth is disabled, otherwise
+        ``user-{id}`` — one directory per connected cloud account (#2717).
         """
         files: dict[str, dict | list] = {}
 
@@ -306,10 +358,20 @@ class GitHubBackupService:
             self._backup_progress = "Collecting K-profiles from printers..."
             await self._collect_kprofiles(db, files)
 
-        # Collect cloud profiles
+        # Collect cloud profiles. `contents.cloud_profiles` is corrected below
+        # from what was configured to what was actually written — it claimed
+        # `true` on every backup, including the ones that collected nothing
+        # (#2717), which is exactly the signal a restore needs to be able to
+        # trust.
         if config.backup_cloud_profiles:
-            self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
-            await self._collect_cloud_profiles(db, files)
+            self._backup_progress = "Collecting cloud profiles from Bambu Cloud and Orca Cloud..."
+            cloud_summary = await self._collect_cloud_profiles(db, files)
+            collected = bool(cloud_summary.get("bambu") or cloud_summary.get("orca"))
+            metadata["contents"]["cloud_profiles"] = collected
+            if collected:
+                # Per-cloud, per-account counts, so a restore can tell an empty
+                # account from one that failed to collect.
+                metadata["cloud_profiles"] = cloud_summary
 
         # Collect app settings
         if config.backup_settings:
@@ -374,68 +436,283 @@ class GitHubBackupService:
             if printer_profiles:
                 logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
 
-    async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
-        """Collect Bambu Cloud profiles if authenticated."""
-        # Backup runs without a user context, so fall back to the auth-disabled
-        # Settings storage. ``build_authenticated_cloud`` honours the stored
-        # region so China-region tokens are validated against api.bambulab.cn.
+    async def _collect_cloud_profiles(self, db: AsyncSession, files: dict) -> dict:
+        """Collect slicer presets from every connected cloud account.
+
+        Two clouds, and on an auth-enabled install any number of accounts in
+        each: Bambu Cloud tokens live on ``User.cloud_token`` and Orca Cloud
+        tokens on ``User.orca_cloud_token``, falling back to the global
+        ``Settings`` table only when auth is disabled. The previous version
+        asked for the auth-disabled store unconditionally, so it collected
+        nothing at all on any install with auth on (#2717).
+
+        Layout is one directory per cloud per account, both clouds grouped the
+        same way so a restore reads them identically::
+
+            cloud_profiles/bambu/user-3/{filament,printer,process}.json
+            cloud_profiles/orca/user-3/{filament,printer,process}.json
+
+        Accounts are keyed by Bambuddy user id (``global`` when auth is off),
+        never by email — a backup repository can be public.
+
+        Returns a per-cloud summary for ``backup_metadata.json`` so the
+        metadata records what was actually collected rather than what was
+        merely enabled.
+        """
+        summary: dict = {"bambu": {}, "orca": {}}
+
+        bambu_accounts, orca_accounts = await self.cloud_accounts(db)
+        if not bambu_accounts and not orca_accounts:
+            # Enabled but nothing to collect. Deliberately a warning: the INFO
+            # line this replaces read as a successful collection of nothing,
+            # which is how #2717 went unnoticed through every backup.
+            logger.warning(
+                "Cloud profiles are enabled for backup, but no Bambu Cloud or Orca Cloud "
+                "account is connected — nothing to collect."
+            )
+            return summary
+
+        for account_key, user in bambu_accounts:
+            try:
+                counts = await self._collect_bambu_profiles(db, files, account_key, user)
+            except Exception:
+                logger.warning("Failed to collect Bambu Cloud profiles for %s", account_key, exc_info=True)
+                continue
+            if counts:
+                summary["bambu"][account_key] = counts
+
+        for account_key, user in orca_accounts:
+            try:
+                counts = await self._collect_orca_profiles(db, files, account_key, user)
+            except Exception:
+                logger.warning("Failed to collect Orca Cloud profiles for %s", account_key, exc_info=True)
+                continue
+            if counts:
+                summary["orca"][account_key] = counts
+
+        if not summary["bambu"] and not summary["orca"]:
+            logger.warning(
+                "Cloud profiles are enabled and %d Bambu / %d Orca account(s) are connected, "
+                "but no presets were collected — see the per-account warnings above.",
+                len(bambu_accounts),
+                len(orca_accounts),
+            )
+        else:
+            logger.info("Collected cloud profiles: %s", summary)
+        return summary
+
+    async def cloud_accounts(self, db: AsyncSession) -> tuple[list, list]:
+        """Enumerate connected accounts as ``(account_key, user_or_None)`` per cloud.
+
+        With auth enabled every user holds their own credentials, so a backup
+        that only looked at the global store saw none of them. With auth
+        disabled there is a single global row and no ``User`` at all, which is
+        what ``user=None`` means to both clouds' credential loaders.
+
+        Both stores are read regardless: a ``Settings`` row survives enabling
+        auth later, and dropping it silently would lose that account's presets.
+        """
+        from backend.app.api.routes.cloud import get_stored_token
+        from backend.app.api.routes.orca_cloud import _load_credentials
+
+        bambu: list = []
+        orca: list = []
+
+        global_token, _email, _region = await get_stored_token(db, None)
+        if global_token:
+            bambu.append(("global", None))
+        global_orca = await _load_credentials(db, None)
+        if global_orca.token:
+            orca.append(("global", None))
+
+        result = await db.execute(
+            select(User).where(or_(User.cloud_token.isnot(None), User.orca_cloud_token.isnot(None)))
+        )
+        for user in result.scalars().all():
+            if user.cloud_token:
+                bambu.append((f"user-{user.id}", user))
+            if user.orca_cloud_token:
+                orca.append((f"user-{user.id}", user))
+
+        return bambu, orca
+
+    async def _collect_bambu_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
+        """Collect one Bambu Cloud account's custom presets, with their payloads.
+
+        The listing endpoint is keyed by preset type, each holding ``private``
+        and ``public`` lists — there is no flat ``setting`` array, and the
+        entries carry no ``type`` of their own, which is why the type comes
+        from the outer key here exactly as it does in ``routes/cloud.py``.
+        Bambu calls process presets ``print``.
+
+        ``public`` is skipped: those are Bambu's own bundled catalogue, the
+        same hundreds of entries for every user, re-downloadable at any time
+        and not recreatable under your account anyway. Backing them up would
+        churn the repository on every run for nothing.
+
+        Each private preset then costs one ``get_setting_detail`` call, because
+        the listing carries only metadata. Without ``base_id`` and ``setting``
+        the backup is a list of names, not something a restore can rebuild
+        from. Bounded by the number of *custom* presets, and the backup already
+        makes a round-trip per printer for K-profiles.
+        """
         from backend.app.api.routes.cloud import build_authenticated_cloud
 
-        cloud = await build_authenticated_cloud(db, user=None)
+        cloud = await build_authenticated_cloud(db, user=user)
         if cloud is None or not cloud.is_authenticated:
-            if cloud is not None:
-                await cloud.close()
-            logger.info("Cloud not authenticated, skipping cloud profiles")
-            return
+            logger.info("Bambu Cloud not authenticated for %s, skipping", account_key)
+            return {}
 
+        counts: dict = {}
         try:
             settings = await cloud.get_slicer_settings()
-            if not settings:
-                return
-
-            # Separate by type
-            filament_settings = []
-            printer_settings = []
-            process_settings = []
-
-            for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
-                setting_type = setting.get("type", "")
-                if setting_type == "filament":
-                    filament_settings.append(setting)
-                elif setting_type == "printer":
-                    printer_settings.append(setting)
-                elif setting_type == "process":
-                    process_settings.append(setting)
-
-            if filament_settings:
-                files["cloud_profiles/filament.json"] = {
-                    "version": "1.0",
-                    "profiles": filament_settings,
-                }
+            if not isinstance(settings, dict) or not settings:
+                logger.warning("Bambu Cloud returned no slicer settings for %s", account_key)
+                return {}
+
+            failed = 0
+            for api_key, our_type in _BAMBU_PRESET_TYPES.items():
+                type_data = settings.get(api_key)
+                if not isinstance(type_data, dict):
+                    continue
+                private = type_data.get("private")
+                if not isinstance(private, list) or not private:
+                    continue
+
+                profiles = []
+                for entry in private:
+                    setting_id = entry.get("setting_id") or entry.get("id")
+                    if not setting_id:
+                        continue
+                    try:
+                        detail = await cloud.get_setting_detail(str(setting_id))
+                    except Exception as e:
+                        # One unreadable preset must not cost the rest of the
+                        # account, but it must not vanish quietly either.
+                        failed += 1
+                        logger.warning(
+                            "Failed to fetch Bambu Cloud preset %s (%s) for %s: %s",
+                            setting_id,
+                            entry.get("name", "unnamed"),
+                            account_key,
+                            e,
+                        )
+                        continue
+                    profiles.append(_bambu_preset_record(setting_id, our_type, entry, detail))
+
+                if profiles:
+                    files[f"cloud_profiles/bambu/{account_key}/{our_type}.json"] = {
+                        "version": "2.0",
+                        "cloud": "bambu",
+                        "type": our_type,
+                        "profiles": profiles,
+                    }
+                    counts[our_type] = len(profiles)
 
-            if printer_settings:
-                files["cloud_profiles/printer.json"] = {
-                    "version": "1.0",
-                    "profiles": printer_settings,
-                }
+            if failed:
+                counts["failed"] = failed
+            return counts
+        finally:
+            await cloud.close()
 
-            if process_settings:
-                files["cloud_profiles/process.json"] = {
-                    "version": "1.0",
-                    "profiles": process_settings,
-                }
+    async def _collect_orca_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
+        """Collect one Orca Cloud account's profiles, grouped the same three ways.
+
+        Cheaper than Bambu: the sync-pull listing already carries each
+        profile's full ``content``, so there is no per-profile fetch.
+
+        The type lives at ``content.type`` and is mapped through the same
+        ``_ORCA_TYPE_TO_BAMBU`` table the Orca tab uses, so the backup groups
+        exactly as the UI does. Where that route *drops* a profile whose type
+        it can't map, this writes it to ``other.json`` instead — a backup that
+        silently omits a profile because Orca added a type is the same class of
+        bug as #2717 itself.
+
+        Uses the route layer's ``_build_authenticated_service`` rather than
+        re-implementing the refresh: the Orca refresh token is single-use and
+        rotating, and that helper already persists the new pair atomically
+        before returning.
+
+        Passes ``clear_on_auth_failure=False``, so a rejected refresh skips the
+        account instead of disconnecting it. A backup is an observer; it should
+        not change anyone's sign-in state on a schedule, least of all on a
+        rejection reason Orca does not disambiguate. The next time the user
+        opens the Orca Profiles page that route clears the dead pairing anyway,
+        with the user present to pair again.
+        """
+        from fastapi import HTTPException
 
-            logger.info(
-                "Collected cloud profiles: %d filament, %d printer, %d process",
-                len(filament_settings),
-                len(printer_settings),
-                len(process_settings),
-            )
+        from backend.app.api.routes.orca_cloud import (
+            _ORCA_TYPE_TO_BAMBU,
+            _build_authenticated_service,
+        )
+
+        try:
+            svc = await _build_authenticated_service(db, user, clear_on_auth_failure=False)
+        except HTTPException as e:
+            # Either way the stored credentials are untouched and this account
+            # is skipped, not disconnected — but the two need different advice.
+            # A rejected refresh will not fix itself and needs the user to pair
+            # again; an unreachable Orca is very likely gone by the next run.
+            if e.status_code == 401:
+                logger.warning(
+                    "Orca Cloud rejected the stored session for %s, so its profiles are not in this "
+                    "backup. Later runs will skip it too until the account is paired again under "
+                    "Profiles > Orca Cloud Profiles — which is also where the dead credentials get "
+                    "cleared. Cause: %s",
+                    account_key,
+                    e.detail,
+                )
+            else:
+                logger.warning(
+                    "Orca Cloud unreachable for %s, skipping its profiles this run: %s",
+                    account_key,
+                    e.detail,
+                )
+            return {}
+        except Exception as e:
+            logger.warning("Orca Cloud not usable for %s: %s", account_key, e, exc_info=True)
+            return {}
 
-        except Exception:
-            logger.warning("Failed to collect cloud profiles", exc_info=True)
+        counts: dict = {}
+        try:
+            raw_profiles = await svc.list_profiles()
+            grouped: dict[str, list] = {}
+            unknown_types: dict[str, int] = {}
+
+            for entry in raw_profiles:
+                if not isinstance(entry, dict):
+                    continue
+                content = entry.get("content")
+                raw_type = content.get("type") if isinstance(content, dict) else None
+                our_type = _ORCA_TYPE_TO_BAMBU.get(str(raw_type)) if raw_type is not None else None
+                if our_type is None:
+                    unknown_types[str(raw_type) if raw_type is not None else "<missing>"] = (
+                        unknown_types.get(str(raw_type) if raw_type is not None else "<missing>", 0) + 1
+                    )
+                    our_type = "other"
+                grouped.setdefault(our_type, []).append(_orca_profile_record(entry))
+
+            for our_type, profiles in grouped.items():
+                files[f"cloud_profiles/orca/{account_key}/{our_type}.json"] = {
+                    "version": "2.0",
+                    "cloud": "orca",
+                    "type": our_type,
+                    "profiles": profiles,
+                }
+                counts[our_type] = len(profiles)
+
+            if unknown_types:
+                logger.warning(
+                    "Orca Cloud sent %d profile(s) for %s with unmapped content.type values %s — "
+                    "backed up to other.json rather than dropped.",
+                    sum(unknown_types.values()),
+                    account_key,
+                    unknown_types,
+                )
+            return counts
         finally:
-            await cloud.close()
+            await svc.close()
 
     async def _collect_settings(self, db: AsyncSession, files: dict):
         """Collect app settings."""

+ 509 - 0
backend/tests/unit/test_github_backup_cloud_profiles.py

@@ -0,0 +1,509 @@
+"""Cloud-profile collection for Git backup (#2717).
+
+The collector used to read a ``setting`` key the Bambu Cloud API never returns,
+so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed
+it was. It also asked for the auth-disabled credential store unconditionally,
+which meant it saw no accounts at all once auth was on. These tests pin the
+response shape it actually has to parse, the account enumeration, and the
+metadata now telling the truth.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.github_backup import GitHubBackupService
+
+# The real listing body: keyed by preset type, each holding private/public
+# lists. There is no top-level "setting" array, and the entries carry no "type"
+# of their own — the type is the outer key, and Bambu calls process "print".
+BAMBU_LISTING = {
+    "filament": {
+        "private": [
+            {"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"},
+        ],
+        "public": [
+            {"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"},
+        ],
+    },
+    "printer": {
+        "private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}],
+        "public": [],
+    },
+    "print": {
+        "private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}],
+        "public": [],
+    },
+}
+
+
+def _detail(setting_id: str, name: str, base: str) -> dict:
+    return {
+        "setting_id": setting_id,
+        "name": name,
+        "type": "filament",
+        "version": "1.0",
+        "base_id": base,
+        "filament_id": "P1234",
+        "setting": {"filament_flow_ratio": ["0.98"]},
+    }
+
+
+def _bambu_cloud(listing=None, detail_side_effect=None):
+    cloud = MagicMock()
+    cloud.is_authenticated = True
+    cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING)
+    cloud.get_setting_detail = AsyncMock(
+        side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")),
+    )
+    cloud.close = AsyncMock()
+    return cloud
+
+
+def _orca_service(profiles):
+    svc = MagicMock()
+    svc.list_profiles = AsyncMock(return_value=profiles)
+    svc.close = AsyncMock()
+    return svc
+
+
+@pytest.fixture
+def service():
+    return GitHubBackupService()
+
+
+class TestCloudAccountEnumeration:
+    """Which accounts a backup collects from."""
+
+    @pytest.mark.asyncio
+    async def test_auth_disabled_uses_the_global_store(self, service, db_session):
+        """With auth off there is no User row at all — credentials live in the
+        Settings table and the account is keyed ``global``."""
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=("bambu-token", "a@b.c", "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, orca = await service.cloud_accounts(db_session)
+
+        assert bambu == [("global", None)]
+        assert orca == []
+
+    @pytest.mark.asyncio
+    async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session):
+        """The bug that made this invisible: with auth on, tokens live on User
+        rows, and the collector only ever looked at the global store. Each cloud
+        is enumerated separately so a user connected to one shows up only there.
+        """
+        both = User(username="both", cloud_token="t1", orca_cloud_token="o1")
+        bambu_only = User(username="bambu-only", cloud_token="t2")
+        orca_only = User(username="orca-only", orca_cloud_token="o2")
+        neither = User(username="neither")
+        db_session.add_all([both, bambu_only, orca_only, neither])
+        await db_session.commit()
+
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, orca = await service.cloud_accounts(db_session)
+
+        assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"]
+        assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"]
+
+    @pytest.mark.asyncio
+    async def test_global_and_per_user_accounts_coexist(self, service, db_session):
+        """A Settings row survives someone enabling auth later. Dropping it
+        would silently stop backing up that account's presets."""
+        db_session.add(User(username="u", cloud_token="t1"))
+        await db_session.commit()
+
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=("legacy-global", None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, _orca = await service.cloud_accounts(db_session)
+
+        assert "global" in [key for key, _ in bambu]
+        assert len(bambu) == 2
+
+
+class TestBambuCollection:
+    @pytest.mark.asyncio
+    async def test_reads_the_shape_the_api_actually_returns(self, service, db_session):
+        """The whole bug in one assertion: presets come out of
+        ``data[type]["private"]``, not a flat ``setting`` list, and ``print``
+        maps to ``process``."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        assert counts == {"filament": 1, "printer": 1, "process": 1}
+        assert set(files) == {
+            "cloud_profiles/bambu/global/filament.json",
+            "cloud_profiles/bambu/global/printer.json",
+            "cloud_profiles/bambu/global/process.json",
+        }
+
+    @pytest.mark.asyncio
+    async def test_public_presets_are_not_backed_up(self, service, db_session):
+        """Bambu's bundled catalogue is identical for everyone, re-downloadable,
+        and not recreatable under your account — backing it up would churn the
+        repository on every run for no recovery value."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"]
+        assert [p["setting_id"] for p in filament] == ["PFUS1"]
+
+    @pytest.mark.asyncio
+    async def test_stores_the_payload_a_restore_needs(self, service, db_session):
+        """The listing is metadata only. Without ``base_id`` and ``setting``
+        the backup is a list of names — ``create_setting`` cannot rebuild from
+        it."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0]
+        assert preset["base_id"] == "GFSA00"
+        assert preset["setting"] == {"filament_flow_ratio": ["0.98"]}
+        assert preset["type"] == "filament"
+
+    @pytest.mark.asyncio
+    async def test_account_identity_is_not_written_to_the_repo(self, service, db_session):
+        """Backup repositories can be public, and ``user_id`` adds nothing to a
+        rebuild."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        for payload in files.values():
+            for preset in payload["profiles"]:
+                assert "user_id" not in preset
+
+    @pytest.mark.asyncio
+    async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session):
+        """And it is counted, not swallowed — a partial backup that looks
+        complete is how #2717 stayed invisible."""
+
+        def detail(setting_id):
+            if setting_id == "PFUS1":
+                raise RuntimeError("boom")
+            return _detail(setting_id, "ok", "GFSA00")
+
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(detail_side_effect=detail),
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        assert "cloud_profiles/bambu/global/filament.json" not in files
+        assert counts["printer"] == 1
+        assert counts["process"] == 1
+        assert counts["failed"] == 1
+
+    @pytest.mark.asyncio
+    async def test_unauthenticated_account_writes_nothing(self, service, db_session):
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock())
+
+        assert counts == {}
+        assert files == {}
+
+
+class TestOrcaCollection:
+    @pytest.mark.asyncio
+    async def test_groups_by_content_type_including_aliases(self, service, db_session):
+        """Orca carries the type at ``content.type`` and uses BambuStudio-style
+        aliases — ``machine`` is a printer, ``process`` and ``print`` are both
+        process. Same map the Orca tab groups by."""
+        profiles = [
+            {"id": 1, "name": "f", "content": {"type": "filament"}},
+            {"id": 2, "name": "m", "content": {"type": "machine"}},
+            {"id": 3, "name": "p", "content": {"type": "print"}},
+            {"id": 4, "name": "p2", "content": {"type": "process"}},
+        ]
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=_orca_service(profiles),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock())
+
+        assert counts == {"filament": 1, "printer": 1, "process": 2}
+        assert "cloud_profiles/orca/user-3/printer.json" in files
+
+    @pytest.mark.asyncio
+    async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session):
+        """The sync-pull listing already carries each profile's content, so
+        unlike Bambu there is no per-profile round trip."""
+        svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}])
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=svc,
+        ):
+            await service._collect_orca_profiles(db_session, files, "global", None)
+
+        stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0]
+        assert stored["content"] == {"type": "filament", "flow": 0.98}
+        assert svc.list_profiles.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_unmapped_types_are_kept_not_dropped(self, service, db_session):
+        """The Orca *route* drops profiles whose type it can't render, which is
+        right for a list and wrong for a backup: silently omitting a profile
+        because Orca added a type is the same class of bug as #2717."""
+        profiles = [
+            {"id": 1, "name": "f", "content": {"type": "filament"}},
+            {"id": 2, "name": "x", "content": {"type": "something_new"}},
+            {"id": 3, "name": "y", "content": {}},
+        ]
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=_orca_service(profiles),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts["other"] == 2
+        assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2
+
+    @pytest.mark.asyncio
+    async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session):
+        """An unexpected failure building the Orca client must not abort the
+        rest of the backup — the other accounts and the other cloud still have
+        profiles worth collecting."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            side_effect=RuntimeError("session expired"),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock())
+
+        assert counts == {}
+        assert files == {}
+
+    @pytest.mark.asyncio
+    async def test_the_backup_never_disconnects_an_account(self, service, db_session):
+        """A backup is an observer. It must not change anyone's sign-in state
+        on a schedule — least of all on Orca's composite rejection reason,
+        which cannot tell a real revocation from a lost refresh-rotation race.
+        The Profiles route clears the dead pairing instead, with the user
+        present to act on it.
+        """
+        from fastapi import HTTPException
+
+        build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used"))
+        files: dict = {}
+        with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts == {}
+        assert build.await_args.kwargs["clear_on_auth_failure"] is False
+
+    @pytest.mark.asyncio
+    async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog):
+        """Not clearing means the warning recurs every run, so the one line the
+        operator sees has to say how to stop it."""
+        from fastapi import HTTPException
+
+        files: dict = {}
+        with (
+            caplog.at_level("WARNING"),
+            patch(
+                "backend.app.api.routes.orca_cloud._build_authenticated_service",
+                new_callable=AsyncMock,
+                side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"),
+            ),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts == {}
+        assert "paired again" in caplog.text
+        assert "Later runs will skip it too" in caplog.text
+
+    @pytest.mark.asyncio
+    async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog):
+        """502 is very likely gone by the next run, so it must not carry the
+        "go and re-pair" advice a rejected session does."""
+        from fastapi import HTTPException
+
+        files: dict = {}
+        with (
+            caplog.at_level("WARNING"),
+            patch(
+                "backend.app.api.routes.orca_cloud._build_authenticated_service",
+                new_callable=AsyncMock,
+                side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"),
+            ),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-9", None)
+
+        assert counts == {}
+        assert "unreachable" in caplog.text
+        assert "paired again" not in caplog.text
+
+
+class TestCollectorAndMetadata:
+    @pytest.mark.asyncio
+    async def test_no_connected_account_collects_nothing(self, service, db_session):
+        files: dict = {}
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            summary = await service._collect_cloud_profiles(db_session, files)
+
+        assert summary == {"bambu": {}, "orca": {}}
+        assert files == {}
+
+    @pytest.mark.asyncio
+    async def test_one_failing_account_does_not_stop_the_others(self, service, db_session):
+        a = User(username="a", cloud_token="t1")
+        b = User(username="b", cloud_token="t2")
+        db_session.add_all([a, b])
+        await db_session.commit()
+
+        def build(db, user=None):
+            if user is not None and user.username == "a":
+                raise RuntimeError("cloud down for this account")
+            return _bambu_cloud()
+
+        files: dict = {}
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+            patch(
+                "backend.app.api.routes.cloud.build_authenticated_cloud",
+                new_callable=AsyncMock,
+                side_effect=build,
+            ),
+        ):
+            summary = await service._collect_cloud_profiles(db_session, files)
+
+        assert f"user-{a.id}" not in summary["bambu"]
+        assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1}
+
+    @pytest.mark.asyncio
+    async def test_metadata_reports_collection_not_configuration(self, service, db_session):
+        """``contents.cloud_profiles`` said ``true`` on every backup, including
+        the ones that wrote nothing. A restore has to be able to trust it."""
+        config = MagicMock(
+            backup_kprofiles=False,
+            backup_cloud_profiles=True,
+            backup_settings=False,
+            backup_spools=False,
+            backup_archives=False,
+        )
+        with patch.object(
+            service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}}
+        ):
+            files = await service._collect_backup_data(db_session, config)
+
+        assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False
+        assert "cloud_profiles" not in files["backup_metadata.json"]
+
+    @pytest.mark.asyncio
+    async def test_metadata_records_per_account_counts_when_collected(self, service, db_session):
+        config = MagicMock(
+            backup_kprofiles=False,
+            backup_cloud_profiles=True,
+            backup_settings=False,
+            backup_spools=False,
+            backup_archives=False,
+        )
+        summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}}
+        with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary):
+            files = await service._collect_backup_data(db_session, config)
+
+        assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True
+        assert files["backup_metadata.json"]["cloud_profiles"] == summary
+
+
+class TestSettingsFallbackIsStillHonoured:
+    @pytest.mark.asyncio
+    async def test_global_orca_row_is_discovered(self, service, db_session):
+        """Orca's auth-disabled fallback lives in the same Settings table as
+        Bambu's; both stores are read on every run."""
+        db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x"))
+        await db_session.commit()
+
+        with patch(
+            "backend.app.api.routes.cloud.get_stored_token",
+            new_callable=AsyncMock,
+            return_value=(None, None, "global"),
+        ):
+            _bambu, orca = await service.cloud_accounts(db_session)
+
+        assert orca == [("global", None)]

+ 125 - 0
backend/tests/unit/test_orca_cloud_refresh.py

@@ -0,0 +1,125 @@
+"""What a rejected Orca Cloud refresh is allowed to do to stored credentials.
+
+The refresh token is single-use and rotating, and Orca reports every rejection
+with one composite reason (``unknown, expired, revoked, or already used``), so
+Bambuddy cannot tell a genuine revocation from a lost rotation race. Routes may
+still clear on that signal — a person is looking at the page and can pair again
+— but a background job must not, or an unattended run can destroy a working
+pairing (#2717).
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import select
+
+from backend.app.api.routes.orca_cloud import _SETTINGS_KEYS, _build_authenticated_service
+from backend.app.models.settings import Settings
+from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
+
+
+async def _store_global_credentials(db):
+    """An auth-disabled install's Orca credentials, expired so the helper
+    refreshes rather than returning straight away."""
+    db.add_all(
+        [
+            Settings(key=_SETTINGS_KEYS["token"], value="oc_ext_old"),
+            Settings(key=_SETTINGS_KEYS["refresh_token"], value="oc_ext_rt_old"),
+            Settings(key=_SETTINGS_KEYS["expires_at"], value="2000-01-01T00:00:00+00:00"),
+            Settings(key=_SETTINGS_KEYS["email"], value="a@b.c"),
+        ]
+    )
+    await db.commit()
+
+
+async def _stored_keys(db) -> set[str]:
+    result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
+    return {s.key for s in result.scalars().all()}
+
+
+def _expired_service(refresh_side_effect=None):
+    """A service that reports its access token as expired, so the helper takes
+    the refresh branch."""
+    svc = MagicMock()
+    svc.is_authenticated = False
+    svc.refresh_token = "oc_ext_rt_old"
+    svc.set_tokens = MagicMock()
+    svc.refresh = AsyncMock(side_effect=refresh_side_effect)
+    svc.access_token = "oc_ext_new"
+    svc.token_expiry = None
+    return svc
+
+
+class TestRejectedRefresh:
+    @pytest.mark.asyncio
+    async def test_routes_clear_the_dead_pairing_by_default(self, db_session):
+        """Unchanged behaviour for interactive callers: the page flips to
+        disconnected while the user is there to pair again."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudAuthError("grant already used"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None)
+
+        assert exc.value.status_code == 401
+        assert await _stored_keys(db_session) == set()
+
+    @pytest.mark.asyncio
+    async def test_background_callers_leave_the_credentials_alone(self, db_session):
+        """The whole point of the flag. A scheduled backup that guesses wrong
+        here destroys a pairing nobody asked it to touch, and the user finds
+        out when their profiles stop being backed up."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudAuthError("grant already used"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
+
+        # Still reported as a hard auth failure — the caller has to skip the
+        # account — but nothing was destroyed on the way out.
+        assert exc.value.status_code == 401
+        assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
+        assert _SETTINGS_KEYS["refresh_token"] in await _stored_keys(db_session)
+
+    @pytest.mark.asyncio
+    async def test_an_unreachable_orca_never_clears_either_way(self, db_session):
+        """A transport failure says nothing about the credentials' validity."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudError("connection reset"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None)
+
+        assert exc.value.status_code == 502
+        assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
+
+
+class TestSuccessfulRefresh:
+    @pytest.mark.asyncio
+    async def test_the_rotated_pair_is_persisted_even_for_background_callers(self, db_session):
+        """Not optional: by the time the refresh succeeds the old token is
+        consumed, so failing to store the new pair would break a live pairing
+        for real. The flag suppresses destruction, never persistence.
+        """
+        await _store_global_credentials(db_session)
+        svc = _expired_service()
+        svc.refresh_token = "oc_ext_rt_new"
+
+        with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc):
+            returned = await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
+
+        assert returned is svc
+        result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["token"]))
+        assert result.scalar_one().value == "oc_ext_new"
+        result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"]))
+        assert result.scalar_one().value == "oc_ext_rt_new"

+ 11 - 0
frontend/src/api/client.ts

@@ -2652,6 +2652,14 @@ export interface NotificationProviderUpdate {
 export type ScheduleType = 'hourly' | 'daily' | 'weekly';
 export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab';
 
+/** How many cloud accounts a backup would collect presets from. Counts only —
+ *  with auth enabled the accounts belong to individual users, so the backup
+ *  administrator sees how many, never whose. */
+export interface CloudAccountCounts {
+  bambu: number;
+  orca: number;
+}
+
 export interface GitHubBackupConfig {
   id: number;
   repository_url: string;
@@ -6464,6 +6472,9 @@ export const api = {
   getGitHubBackupConfig: () =>
     request<GitHubBackupConfig | null>('/github-backup/config'),
 
+  getGitHubBackupCloudAccounts: () =>
+    request<CloudAccountCounts>('/github-backup/cloud-accounts'),
+
   saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>
     request<GitHubBackupConfig>('/github-backup/config', {
       method: 'POST',

+ 35 - 4
frontend/src/components/GitHubBackupSettings.tsx

@@ -32,6 +32,7 @@ import type {
   LocalBackupPathCheck,
   LocalBackupStatus,
   ScheduleType,
+  CloudAccountCounts,
   CloudAuthStatus,
   Printer,
 } from '../api/client';
@@ -324,6 +325,24 @@ export function GitHubBackupSettings() {
     queryFn: api.getCloudStatus,
   });
 
+  // How many cloud accounts the backup would actually collect from, across
+  // both Bambu Cloud and Orca Cloud. Not the same question as `cloudStatus`,
+  // which is only *this viewer's* Bambu sign-in: with auth enabled every user
+  // holds their own credentials and the backup collects from all of them, so
+  // an admin who never signed in to Bambu Cloud personally would otherwise see
+  // the category disabled while there is plenty to back up (#2717).
+  const { data: cloudAccounts } = useQuery<CloudAccountCounts>({
+    queryKey: ['github-backup-cloud-accounts'],
+    queryFn: api.getGitHubBackupCloudAccounts,
+    staleTime: 60_000,
+  });
+  const connectedCloudAccounts = (cloudAccounts?.bambu ?? 0) + (cloudAccounts?.orca ?? 0);
+  // Until the count arrives, fall back to the viewer's own Bambu status rather
+  // than rendering the toggle as unavailable and making it flicker enabled.
+  const anyCloudConnected = cloudAccounts
+    ? connectedCloudAccounts > 0
+    : !!cloudStatus?.is_authenticated;
+
   // Fetch printers and their statuses for K-profile availability
   const { data: printers } = useQuery<Printer[]>({
     queryKey: ['printers'],
@@ -751,18 +770,18 @@ export function GitHubBackupSettings() {
                     <p className="text-xs text-bambu-gray">{t('backup.kProfilesDescription')}</p>
                   </div>
                 </label>
-                <label className={`flex items-start gap-2 ${!cloudStatus?.is_authenticated ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
+                <label className={`flex items-start gap-2 ${!anyCloudConnected ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
                   <input
                     type="checkbox"
                     checked={backupCloudProfiles}
                     onChange={(e) => setBackupCloudProfiles(e.target.checked)}
                     className="w-4 h-4 mt-0.5 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
-                    disabled={!cloudStatus?.is_authenticated}
+                    disabled={!anyCloudConnected}
                   />
                   <div>
                     <div className="flex items-center gap-2">
-                      <span className={`text-sm ${cloudStatus?.is_authenticated ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
-                      {!cloudStatus?.is_authenticated && (
+                      <span className={`text-sm ${anyCloudConnected ? 'text-white' : 'text-bambu-gray'}`}>{t('backup.cloudProfiles')}</span>
+                      {!anyCloudConnected && (
                         <span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">
                           <AlertTriangle className="w-3 h-3" />
                           {t('backup.cloudLoginRequiredShort')}
@@ -770,6 +789,18 @@ export function GitHubBackupSettings() {
                       )}
                     </div>
                     <p className="text-xs text-bambu-gray">{t('backup.cloudProfilesDescription')}</p>
+                    {/* Say how many accounts are in scope. On a multi-user
+                        install the presets being backed up are other people's,
+                        and the count is the only honest way to show that
+                        without naming them. */}
+                    {connectedCloudAccounts > 0 && (
+                      <p className="text-xs text-bambu-gray mt-0.5">
+                        {t('backup.cloudProfilesAccounts', {
+                          bambu: cloudAccounts?.bambu ?? 0,
+                          orca: cloudAccounts?.orca ?? 0,
+                        })}
+                      </p>
+                    )}
                   </div>
                 </label>
                 <label className="flex items-start gap-2 cursor-pointer">

+ 2 - 1
frontend/src/i18n/locales/de.ts

@@ -4746,7 +4746,8 @@ export default {
     noPrintersConnected: 'Keine Drucker verbunden',
     printersConnected: '{{connected}}/{{total}} verbunden',
     cloudProfiles: 'Cloud-Profile',
-    cloudProfilesDescription: 'Filament-, Drucker- und Prozessprofile aus der Bambu Cloud',
+    cloudProfilesDescription: 'Filament-, Drucker- und Prozessprofile aus Bambu Cloud und Orca Cloud',
+    cloudProfilesAccounts: 'Verbundene Konten — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'App-Einstellungen',
     appSettingsDescription: 'Bambuddy-Konfiguration (komplette Datenbank)',
     spoolInventory: 'Spulenbestand',

+ 2 - 1
frontend/src/i18n/locales/en.ts

@@ -4789,7 +4789,8 @@ export default {
     noPrintersConnected: 'No printers connected',
     printersConnected: '{{connected}}/{{total}} connected',
     cloudProfiles: 'Cloud Profiles',
-    cloudProfilesDescription: 'Filament, printer, and process presets from Bambu Cloud',
+    cloudProfilesDescription: 'Filament, printer, and process presets from Bambu Cloud and Orca Cloud',
+    cloudProfilesAccounts: 'Connected accounts — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'App Settings',
     appSettingsDescription: 'Bambuddy configuration (complete database)',
     spoolInventory: 'Spool Inventory',

+ 2 - 1
frontend/src/i18n/locales/es.ts

@@ -4754,7 +4754,8 @@ export default {
     noPrintersConnected: 'No hay impresoras conectadas',
     printersConnected: '{{connected}}/{{total}} conectadas',
     cloudProfiles: 'Perfiles en la nube',
-    cloudProfilesDescription: 'Preajustes de filamento, impresora y proceso de Bambu Cloud',
+    cloudProfilesDescription: 'Preajustes de filamento, impresora y proceso de Bambu Cloud y Orca Cloud',
+    cloudProfilesAccounts: 'Cuentas conectadas — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'Ajustes de la aplicación',
     appSettingsDescription: 'Configuración de Bambuddy (base de datos completa)',
     spoolInventory: 'Inventario de bobinas',

+ 2 - 1
frontend/src/i18n/locales/fr.ts

@@ -4735,7 +4735,8 @@ export default {
     noPrintersConnected: 'Aucune imprimante connectée',
     printersConnected: '{{connected}}/{{total}} connectées',
     cloudProfiles: 'Profils Cloud',
-    cloudProfilesDescription: 'Préréglages de filament, imprimante et processus depuis Bambu Cloud',
+    cloudProfilesDescription: 'Préréglages de filament, imprimante et processus depuis Bambu Cloud et Orca Cloud',
+    cloudProfilesAccounts: 'Comptes connectés — Bambu Cloud : {{bambu}}, Orca Cloud : {{orca}}',
     appSettings: 'Paramètres de l\'application',
     appSettingsDescription: 'Configuration Bambuddy (base de données complète)',
     spoolInventory: 'Inventaire des bobines',

+ 2 - 1
frontend/src/i18n/locales/it.ts

@@ -4734,7 +4734,8 @@ export default {
     noPrintersConnected: 'Nessuna stampante connessa',
     printersConnected: '{{connected}}/{{total}} connesse',
     cloudProfiles: 'Profili Cloud',
-    cloudProfilesDescription: 'Preset di filamento, stampante e processo da Bambu Cloud',
+    cloudProfilesDescription: 'Preset di filamento, stampante e processo da Bambu Cloud e Orca Cloud',
+    cloudProfilesAccounts: 'Account collegati — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'Impostazioni App',
     appSettingsDescription: 'Configurazione Bambuddy (database completo)',
     spoolInventory: 'Inventario bobine',

+ 2 - 1
frontend/src/i18n/locales/ja.ts

@@ -4746,7 +4746,8 @@ export default {
     noPrintersConnected: 'プリンターが接続されていません',
     printersConnected: '{{connected}}/{{total}} 接続済み',
     cloudProfiles: 'クラウドプロファイル',
-    cloudProfilesDescription: 'Bambu Cloudからのフィラメント、プリンター、プロセスプリセット',
+    cloudProfilesDescription: 'Bambu CloudとOrca Cloudからのフィラメント、プリンター、プロセスプリセット',
+    cloudProfilesAccounts: '接続済みアカウント — Bambu Cloud: {{bambu}}、Orca Cloud: {{orca}}',
     appSettings: 'アプリ設定',
     appSettingsDescription: 'Bambuddy設定(データベース全体)',
     spoolInventory: 'スプール在庫',

+ 2 - 1
frontend/src/i18n/locales/ko.ts

@@ -4511,7 +4511,8 @@ export default {
     noPrintersConnected: '연결된 프린터 없음',
     printersConnected: '{{total}}개 중 {{connected}}개 연결됨',
     cloudProfiles: '클라우드 프로필',
-    cloudProfilesDescription: 'Bambu 클라우드의 필라멘트, 프린터 및 프로세스 프리셋',
+    cloudProfilesDescription: 'Bambu 클라우드와 Orca 클라우드의 필라멘트, 프린터 및 프로세스 프리셋',
+    cloudProfilesAccounts: '연결된 계정 — Bambu 클라우드: {{bambu}}, Orca 클라우드: {{orca}}',
     appSettings: '앱 설정',
     appSettingsDescription: 'Bambuddy 구성 (전체 데이터베이스)',
     spoolInventory: '스풀 재고',

+ 2 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -4734,7 +4734,8 @@ export default {
     noPrintersConnected: 'Nenhuma impressora conectada',
     printersConnected: '{{connected}}/{{total}} conectadas',
     cloudProfiles: 'Perfis Cloud',
-    cloudProfilesDescription: 'Predefinições de filamento, impressora e processo do Bambu Cloud',
+    cloudProfilesDescription: 'Predefinições de filamento, impressora e processo do Bambu Cloud e do Orca Cloud',
+    cloudProfilesAccounts: 'Contas conectadas — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'Configurações do App',
     appSettingsDescription: 'Configuração do Bambuddy (banco de dados completo)',
     spoolInventory: 'Inventário de bobinas',

+ 2 - 1
frontend/src/i18n/locales/ru.ts

@@ -4503,7 +4503,8 @@ export default {
     noPrintersConnected: "Нет подключённых принтеров",
     printersConnected: "Подключено {{connected}} из {{total}}",
     cloudProfiles: "Облачные профили",
-    cloudProfilesDescription: "Предустановки филамента, принтера и процесса из Bambu Cloud",
+    cloudProfilesDescription: "Предустановки филамента, принтера и процесса из Bambu Cloud и Orca Cloud",
+    cloudProfilesAccounts: "Подключённые аккаунты — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}",
     appSettings: "Настройки приложения",
     appSettingsDescription: "Конфигурация Bambuddy (полная база данных)",
     spoolInventory: "Учёт катушек",

+ 2 - 1
frontend/src/i18n/locales/tr.ts

@@ -4724,7 +4724,8 @@ export default {
     noPrintersConnected: 'Bağlı yazıcı yok',
     printersConnected: '{{connected}}/{{total}} bağlı',
     cloudProfiles: 'Bulut Profilleri',
-    cloudProfilesDescription: 'Bambu Cloud\'dan filament, yazıcı ve işlem ön ayarları',
+    cloudProfilesDescription: 'Bambu Cloud ve Orca Cloud\'dan filament, yazıcı ve işlem ön ayarları',
+    cloudProfilesAccounts: 'Bağlı hesaplar — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}',
     appSettings: 'Uygulama Ayarları',
     appSettingsDescription: 'Bambuddy yapılandırması (tam veritabanı)',
     spoolInventory: 'Makara Envanteri',

+ 2 - 1
frontend/src/i18n/locales/uk.ts

@@ -4789,7 +4789,8 @@ export default {
     noPrintersConnected: "Немає підключених принтерів",
     printersConnected: "{{connected}}/{{total}} підключено",
     cloudProfiles: "Хмарні профілі",
-    cloudProfilesDescription: "Попередні налаштування філаменту, принтера та процесу від Bambu Cloud",
+    cloudProfilesDescription: "Попередні налаштування філаменту, принтера та процесу від Bambu Cloud і Orca Cloud",
+    cloudProfilesAccounts: "Підключені акаунти — Bambu Cloud: {{bambu}}, Orca Cloud: {{orca}}",
     appSettings: "Налаштування програми",
     appSettingsDescription: "Конфігурація Bambuddy (повна база даних)",
     spoolInventory: "Облік котушок",

+ 2 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -4734,7 +4734,8 @@ export default {
     noPrintersConnected: '没有打印机连接',
     printersConnected: '{{connected}}/{{total}} 已连接',
     cloudProfiles: '云配置文件',
-    cloudProfilesDescription: '来自 Bambu Cloud 的耗材、打印机和工艺预设',
+    cloudProfilesDescription: '来自 Bambu Cloud 和 Orca Cloud 的耗材、打印机和工艺预设',
+    cloudProfilesAccounts: '已连接账户 — Bambu Cloud:{{bambu}},Orca Cloud:{{orca}}',
     appSettings: '应用设置',
     appSettingsDescription: 'Bambuddy 配置(完整数据库)',
     spoolInventory: '耗材库存',

+ 2 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -4734,7 +4734,8 @@ export default {
     noPrintersConnected: '沒有印表機連線',
     printersConnected: '{{connected}}/{{total}} 已連線',
     cloudProfiles: '雲設定檔案',
-    cloudProfilesDescription: '來自 Bambu Cloud 的耗材、印表機和工藝預設',
+    cloudProfilesDescription: '來自 Bambu Cloud 和 Orca Cloud 的耗材、印表機和工藝預設',
+    cloudProfilesAccounts: '已連線帳戶 — Bambu Cloud:{{bambu}},Orca Cloud:{{orca}}',
     appSettings: '應用程式設定',
     appSettingsDescription: 'Bambuddy 設定(完整資料庫)',
     spoolInventory: '耗材庫存',

Plik diff jest za duży
+ 0 - 0
static/assets/index-CxAiFpme.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-Css9_XII.js"></script>
+    <script type="module" crossorigin src="/assets/index-CxAiFpme.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-oReXTzKG.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików