Jelajahi Sumber

feat(cloud): support China region for token-based login (#1013)

feat(cloud): support China region for token-based login

The /cloud/token endpoint always used the global Bambu API endpoint,
so users with China-region access tokens could not validate their
token. The password login flow already exposes a region selector; this
brings the token flow to parity.
Minidoracat 1 bulan lalu
induk
melakukan
baf0716a9a

+ 128 - 102
backend/app/api/routes/cloud.py

@@ -36,7 +36,7 @@ from backend.app.schemas.cloud import (
 from backend.app.services.bambu_cloud import (
     BambuCloudAuthError,
     BambuCloudError,
-    get_cloud_service,
+    BambuCloudService,
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 
@@ -48,40 +48,57 @@ router = APIRouter(prefix="/cloud", tags=["cloud"])
 # Keys for storing cloud credentials in settings
 CLOUD_TOKEN_KEY = "bambu_cloud_token"
 CLOUD_EMAIL_KEY = "bambu_cloud_email"
+CLOUD_REGION_KEY = "bambu_cloud_region"
 
 
-async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None]:
-    """Get stored cloud token and email.
+def _normalise_region(region: str | None) -> str:
+    """Treat NULL/empty as 'global' for legacy rows that predate the region column."""
+    return region if region in ("global", "china") else "global"
+
+
+async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
+    """Get stored cloud token, email, and region.
 
     When a user is provided (auth enabled), returns that user's per-user credentials.
     When user is None (auth disabled), falls back to global Settings table.
+    Region defaults to ``"global"`` when unset (including for rows that predate
+    the ``cloud_region`` column).
     """
     if user is not None:
-        return user.cloud_token, user.cloud_email
+        return user.cloud_token, user.cloud_email, _normalise_region(user.cloud_region)
 
     # Fallback: global storage (auth disabled)
-    result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+    )
     settings = {s.key: s.value for s in result.scalars().all()}
-    return settings.get(CLOUD_TOKEN_KEY), settings.get(CLOUD_EMAIL_KEY)
+    return (
+        settings.get(CLOUD_TOKEN_KEY),
+        settings.get(CLOUD_EMAIL_KEY),
+        _normalise_region(settings.get(CLOUD_REGION_KEY)),
+    )
 
 
-async def store_token(db: AsyncSession, token: str, email: str, user: User | None = None) -> None:
-    """Store cloud token and email.
+async def store_token(db: AsyncSession, token: str, email: str, region: str, user: User | None = None) -> None:
+    """Store cloud token, email, and region.
 
     When a user is provided (auth enabled), stores on the user record.
     When user is None (auth disabled), stores in global Settings table.
     """
+    region = _normalise_region(region)
     if user is not None:
         # User object is from the auth dependency's session (detached),
         # so use a direct UPDATE via the route's db session.
         from sqlalchemy import update
 
-        await db.execute(update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email))
+        await db.execute(
+            update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email, cloud_region=region)
+        )
         await db.commit()
         return
 
     # Fallback: global storage (auth disabled)
-    for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email)]:
+    for key, value in [(CLOUD_TOKEN_KEY, token), (CLOUD_EMAIL_KEY, email), (CLOUD_REGION_KEY, region)]:
         result = await db.execute(select(Settings).where(Settings.key == key))
         setting = result.scalar_one_or_none()
         if setting:
@@ -92,7 +109,7 @@ async def store_token(db: AsyncSession, token: str, email: str, user: User | Non
 
 
 async def clear_token(db: AsyncSession, user: User | None = None) -> None:
-    """Clear stored cloud token and email.
+    """Clear stored cloud token, email, and region.
 
     When a user is provided (auth enabled), clears that user's credentials.
     When user is None (auth disabled), clears from global Settings table.
@@ -100,33 +117,62 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
     if user is not None:
         from sqlalchemy import update
 
-        await db.execute(update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None))
+        await db.execute(
+            update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None, cloud_region=None)
+        )
         await db.commit()
         return
 
     # Fallback: global storage (auth disabled)
-    result = await db.execute(select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY])))
+    result = await db.execute(
+        select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
+    )
     for setting in result.scalars().all():
         await db.delete(setting)
     await db.commit()
 
 
+async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> BambuCloudService | None:
+    """Build a per-request cloud service seeded with the caller's stored token + region.
+
+    Returns ``None`` when no token is stored, so callers can 401 without constructing
+    (and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
+    """
+    token, _email, region = await get_stored_token(db, user)
+    if not token:
+        return None
+    cloud = BambuCloudService(region=region)
+    cloud.set_token(token)
+    return cloud
+
+
 @router.get("/status", response_model=CloudAuthStatus)
 async def get_auth_status(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
-    """Get current cloud authentication status."""
-    token, email = await get_stored_token(db, current_user)
-    cloud = get_cloud_service()
+    """Get current cloud authentication status.
 
-    if token:
-        cloud.set_token(token)
+    Reads the stored credentials in one DB round-trip (we used to call
+    ``get_stored_token`` twice — once here and once inside
+    ``build_authenticated_cloud``). ``region`` is exposed so the frontend can
+    show "Connected (China)" after a reload without relying on local state.
+    """
+    token, email, region = await get_stored_token(db, current_user)
+    if not token:
+        return CloudAuthStatus(is_authenticated=False, email=None, region=None)
 
-    return CloudAuthStatus(
-        is_authenticated=cloud.is_authenticated,
-        email=email if cloud.is_authenticated else None,
-    )
+    cloud = BambuCloudService(region=region)
+    cloud.set_token(token)
+    try:
+        authenticated = cloud.is_authenticated
+        return CloudAuthStatus(
+            is_authenticated=authenticated,
+            email=email if authenticated else None,
+            region=region if authenticated else None,
+        )
+    finally:
+        await cloud.close()
 
 
 @router.post("/login", response_model=CloudLoginResponse)
@@ -145,14 +191,14 @@ async def login(
     After receiving/generating the code, call /cloud/verify to complete the login.
     For TOTP, include the tfa_key from this response in the verify request.
     """
-    cloud = get_cloud_service()
+    cloud = BambuCloudService(region=request.region)
 
     try:
         result = await cloud.login_request(request.email, request.password)
 
         if result.get("success") and cloud.access_token:
             # Direct login succeeded (rare)
-            await store_token(db, cloud.access_token, request.email, current_user)
+            await store_token(db, cloud.access_token, request.email, request.region, current_user)
 
         return CloudLoginResponse(
             success=result.get("success", False),
@@ -165,6 +211,8 @@ async def login(
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.post("/verify", response_model=CloudLoginResponse)
@@ -183,8 +231,11 @@ async def verify_code(
     For TOTP verification:
     - The user enters the 6-digit code from their authenticator app
     - Include the tfa_key from the /cloud/login response
+
+    ``request.region`` must match the region used in /cloud/login so that the
+    TOTP call hits the correct TFA endpoint (bambulab.com vs bambulab.cn).
     """
-    cloud = get_cloud_service()
+    cloud = BambuCloudService(region=request.region)
 
     try:
         # Use TOTP verification if tfa_key is provided
@@ -194,7 +245,7 @@ async def verify_code(
             result = await cloud.verify_code(request.email, request.code)
 
         if result.get("success") and cloud.access_token:
-            await store_token(db, cloud.access_token, request.email, current_user)
+            await store_token(db, cloud.access_token, request.email, request.region, current_user)
 
         return CloudLoginResponse(
             success=result.get("success", False),
@@ -205,6 +256,8 @@ async def verify_code(
         raise HTTPException(status_code=401, detail=str(e))
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.post("/token", response_model=CloudAuthStatus)
@@ -216,19 +269,22 @@ async def set_token(
     """
     Set access token directly.
 
-    For users who already have a token (e.g., from Bambu Studio).
+    For users who already have a token (e.g., from Bambu Studio). The
+    selected ``region`` is persisted alongside the token so every subsequent
+    request hits the right Bambu API endpoint, including after a restart.
     """
-    cloud = get_cloud_service()
+    cloud = BambuCloudService(region=request.region)
     cloud.set_token(request.access_token)
 
-    # Verify token works by trying to get profile
     try:
+        # Verify token works by trying to get profile
         await cloud.get_user_profile()
-        await store_token(db, request.access_token, "token-auth", current_user)
+        await store_token(db, request.access_token, "token-auth", request.region, current_user)
         return CloudAuthStatus(is_authenticated=True, email="token-auth")
     except BambuCloudError:
-        cloud.logout()
         raise HTTPException(status_code=401, detail="Invalid token")
+    finally:
+        await cloud.close()
 
 
 @router.post("/logout")
@@ -237,8 +293,6 @@ async def logout(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
 ):
     """Log out of Bambu Cloud."""
-    cloud = get_cloud_service()
-    cloud.logout()
     await clear_token(db, current_user)
     return {"success": True}
 
@@ -254,14 +308,8 @@ async def get_slicer_settings(
 
     Requires authentication.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -316,6 +364,8 @@ async def get_slicer_settings(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.get("/settings/{setting_id}")
@@ -329,14 +379,8 @@ async def get_setting_detail(
 
     Returns the full preset configuration.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -347,6 +391,8 @@ async def get_setting_detail(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.get("/filaments", response_model=list[SlicerSetting])
@@ -581,12 +627,9 @@ async def get_filament_info(
 
     # Phase 2: Try cloud for uncached IDs
     if unresolved_ids:
-        token, _ = await get_stored_token(db, current_user)
-        if token:
-            cloud = get_cloud_service()
-            cloud.set_token(token)
-
-            if cloud.is_authenticated:
+        cloud = await build_authenticated_cloud(db, current_user)
+        if cloud is not None and cloud.is_authenticated:
+            try:
                 still_unresolved: list[str] = []
                 for setting_id in unresolved_ids:
                     try:
@@ -615,6 +658,10 @@ async def get_filament_info(
                         still_unresolved.append(setting_id)
 
                 unresolved_ids = still_unresolved
+            finally:
+                await cloud.close()
+        elif cloud is not None:
+            await cloud.close()
 
     # Phase 3: Try local profiles for any IDs still without a name
     if unresolved_ids:
@@ -633,14 +680,8 @@ async def get_devices(
 
     Returns printers registered to the user's Bambu account.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -662,6 +703,8 @@ async def get_devices(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
@@ -680,14 +723,8 @@ async def get_firmware_updates(
 
     Requires cloud authentication.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -741,6 +778,8 @@ async def get_firmware_updates(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.post("/settings")
@@ -757,14 +796,8 @@ async def create_setting(
 
     Type should be: 'filament', 'print', or 'printer'
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -781,6 +814,8 @@ async def create_setting(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.put("/settings/{setting_id}")
@@ -795,14 +830,8 @@ async def update_setting(
 
     Updates the preset's name and/or settings on Bambu Cloud.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -817,6 +846,8 @@ async def update_setting(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
@@ -830,14 +861,8 @@ async def delete_setting(
 
     Removes the preset from Bambu Cloud. This cannot be undone.
     """
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        raise HTTPException(status_code=401, detail="Not authenticated")
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
         raise HTTPException(status_code=401, detail="Not authenticated")
 
     try:
@@ -851,6 +876,8 @@ async def delete_setting(
         raise HTTPException(status_code=401, detail="Authentication expired")
     except BambuCloudError as e:
         raise HTTPException(status_code=500, detail=str(e))
+    finally:
+        await cloud.close()
 
 
 # Path to field definition files
@@ -927,13 +954,10 @@ async def get_filament_id_map(
     if _filament_id_name_cache and time.time() - _filament_id_name_cache_time < FILAMENT_CACHE_TTL:
         return _filament_id_name_cache
 
-    token, _ = await get_stored_token(db, current_user)
-    if not token:
-        return _filament_id_name_cache or {}
-
-    cloud = get_cloud_service()
-    cloud.set_token(token)
-    if not cloud.is_authenticated:
+    cloud = await build_authenticated_cloud(db, current_user)
+    if cloud is None or not cloud.is_authenticated:
+        if cloud is not None:
+            await cloud.close()
         return _filament_id_name_cache or {}
 
     try:
@@ -961,6 +985,8 @@ async def get_filament_id_map(
         return result
     except Exception:
         return _filament_id_name_cache or {}
+    finally:
+        await cloud.close()
 
 
 @router.get("/fields/{preset_type}")

+ 34 - 29
backend/app/api/routes/inventory.py

@@ -786,7 +786,7 @@ async def list_assignments(
 async def assign_spool(
     data: SpoolAssignmentCreate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Assign a spool to an AMS slot and auto-configure via MQTT."""
     from backend.app.services.printer_manager import printer_manager
@@ -911,34 +911,39 @@ async def assign_spool(
                     # Use base_sf (version suffix stripped) for cloud API + MQTT
                     setting_id = base_sf
                     try:
-                        from backend.app.services.bambu_cloud import get_cloud_service
-
-                        cloud = get_cloud_service()
-                        if cloud.is_authenticated:
-                            detail = await cloud.get_setting_detail(base_sf)
-                            if detail.get("filament_id"):
-                                tray_info_idx = detail["filament_id"]
-                                logger.info(
-                                    "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
-                                    tray_info_idx,
-                                    sf,
-                                )
-                                # Use cloud preset name for tray_sub_brands if available
-                                cloud_name = detail.get("name", "")
-                                if cloud_name:
-                                    tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
-                            elif detail.get("base_id"):
-                                # Derive from base_id (e.g. "GFSL05" → "GFL05")
-                                bid = detail["base_id"].split("_")[0]
-                                if bid.startswith("GFS") and len(bid) >= 5:
-                                    tray_info_idx = f"GF{bid[3:]}"
-                                else:
-                                    tray_info_idx = bid
-                                logger.info(
-                                    "Spool assign: derived filament_id=%r from base_id=%r",
-                                    tray_info_idx,
-                                    detail["base_id"],
-                                )
+                        from backend.app.api.routes.cloud import build_authenticated_cloud
+
+                        cloud = await build_authenticated_cloud(db, current_user)
+                        if cloud is not None and cloud.is_authenticated:
+                            try:
+                                detail = await cloud.get_setting_detail(base_sf)
+                                if detail.get("filament_id"):
+                                    tray_info_idx = detail["filament_id"]
+                                    logger.info(
+                                        "Spool assign: resolved filament_id=%r from cloud for setting_id=%r",
+                                        tray_info_idx,
+                                        sf,
+                                    )
+                                    # Use cloud preset name for tray_sub_brands if available
+                                    cloud_name = detail.get("name", "")
+                                    if cloud_name:
+                                        tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
+                                elif detail.get("base_id"):
+                                    # Derive from base_id (e.g. "GFSL05" → "GFL05")
+                                    bid = detail["base_id"].split("_")[0]
+                                    if bid.startswith("GFS") and len(bid) >= 5:
+                                        tray_info_idx = f"GF{bid[3:]}"
+                                    else:
+                                        tray_info_idx = bid
+                                    logger.info(
+                                        "Spool assign: derived filament_id=%r from base_id=%r",
+                                        tray_info_idx,
+                                        detail["base_id"],
+                                    )
+                            finally:
+                                await cloud.close()
+                        elif cloud is not None:
+                            await cloud.close()
                     except Exception as e:
                         logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
 

+ 1 - 0
backend/app/core/database.py

@@ -1273,6 +1273,7 @@ async def run_migrations(conn):
     # Migration: Add per-user Bambu Cloud credential columns
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)")
     await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)")
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_region VARCHAR(10)")
 
     # Cleanup: Remove obsolete settings keys that are no longer used
     obsolete_keys = ["slicer_binary_path"]

+ 17 - 0
backend/app/main.py

@@ -3965,6 +3965,19 @@ async def lifespan(app: FastAPI):
     # Startup
     await init_db()
 
+    # Register an app-scoped httpx client for Bambu Cloud services so
+    # per-request BambuCloudService instances reuse the same connection pool
+    # (important for routes like /cloud/filament-info that chain many
+    # get_setting_detail calls). The shared client stores no region/token
+    # state, so the per-request ownership pattern that fixed the region-bleed
+    # bug is preserved.
+    import httpx as _httpx
+
+    from backend.app.services.bambu_cloud import set_shared_http_client
+
+    _shared_cloud_http_client = _httpx.AsyncClient(timeout=30.0)
+    set_shared_http_client(_shared_cloud_http_client)
+
     # Fix queue items stuck with invalid "aborted" status (should be "cancelled").
     # This can happen when a print was cancelled mid-print on versions before this fix.
     try:
@@ -4200,6 +4213,10 @@ async def lifespan(app: FastAPI):
 
     await mqtt_relay.disconnect(timeout=2)
 
+    # Drop the shared Bambu Cloud HTTP client we registered at startup.
+    set_shared_http_client(None)
+    await _shared_cloud_http_client.aclose()
+
     # Checkpoint WAL (SQLite only) and close all database connections
     from backend.app.core.db_dialect import is_sqlite
 

+ 2 - 0
backend/app/models/user.py

@@ -42,6 +42,8 @@ class User(Base):
     # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
     cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
+    # "global" or "china"; NULL treated as "global" for legacy rows.
+    cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
 
     # Relationship to groups through association table
     groups: Mapped[list[Group]] = relationship(

+ 8 - 1
backend/app/schemas/cloud.py

@@ -1,12 +1,16 @@
+from typing import Literal
+
 from pydantic import BaseModel, Field
 
+Region = Literal["global", "china"]
+
 
 class CloudLoginRequest(BaseModel):
     """Request to initiate cloud login."""
 
     email: str = Field(..., description="Bambu Lab account email")
     password: str = Field(..., description="Account password")
-    region: str = Field(default="global", description="Region: 'global' or 'china'")
+    region: Region = Field(default="global", description="Region: 'global' or 'china'")
 
 
 class CloudVerifyRequest(BaseModel):
@@ -15,6 +19,7 @@ class CloudVerifyRequest(BaseModel):
     email: str = Field(..., description="Bambu Lab account email")
     code: str = Field(..., description="6-digit verification code")
     tfa_key: str | None = Field(None, description="TFA key for TOTP verification (from login response)")
+    region: Region = Field(default="global", description="Region: 'global' or 'china'")
 
 
 class CloudLoginResponse(BaseModel):
@@ -32,12 +37,14 @@ class CloudAuthStatus(BaseModel):
 
     is_authenticated: bool
     email: str | None = None
+    region: Region | None = None
 
 
 class CloudTokenRequest(BaseModel):
     """Request to set access token directly."""
 
     access_token: str = Field(..., description="Bambu Lab access token")
+    region: Region = Field(default="global", description="Region: 'global' or 'china'")
 
 
 class SlicerSetting(BaseModel):

+ 41 - 16
backend/app/services/bambu_cloud.py

@@ -27,15 +27,41 @@ class BambuCloudAuthError(BambuCloudError):
     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
+    ``BambuCloudService`` instances can reuse its connection pool.
+
+    Pass ``None`` during shutdown to unregister. The service only holds a
+    reference (never closes a client it does not own), so region + token
+    state still stays per-request — this only shares the transport pool.
+    """
+    global _shared_http_client
+    _shared_http_client = client
+
+
 class BambuCloudService:
     """Service for interacting with Bambu Lab Cloud API."""
 
-    def __init__(self, region: str = "global"):
+    def __init__(self, region: str = "global", client: httpx.AsyncClient | None = None):
         self.base_url = BAMBU_API_BASE if region == "global" else BAMBU_API_BASE_CN
         self.access_token: str | None = None
         self.refresh_token: str | None = None
         self.token_expiry: datetime | None = None
-        self._client = httpx.AsyncClient(timeout=30.0)
+        # Prefer an explicitly-injected client (tests), else fall back to the
+        # app-scoped shared client (production), and finally create our own so
+        # scripts / tests that skip the lifespan still get a working service.
+        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:
@@ -511,17 +537,16 @@ class BambuCloudService:
             raise BambuCloudError(f"Request failed: {e}")
 
     async def close(self):
-        """Close the HTTP client."""
-        await self._client.aclose()
-
-
-# Singleton instance
-_cloud_service: BambuCloudService | None = None
-
-
-def get_cloud_service() -> BambuCloudService:
-    """Get the singleton cloud service instance."""
-    global _cloud_service
-    if _cloud_service is None:
-        _cloud_service = BambuCloudService()
-    return _cloud_service
+        """Close the HTTP client we own. No-op when sharing an app-scoped client."""
+        if self._owns_client:
+            await self._client.aclose()
+
+
+# Previously this module exposed a process-wide ``_cloud_service`` singleton
+# via ``get_cloud_service()`` / ``reset_cloud_service()``. That pattern leaked
+# region and token state across users (a China-region login would pin the
+# singleton to api.bambulab.cn until the next explicit reset), so the singleton
+# has been removed. Callers should construct a per-request
+# ``BambuCloudService(region=...)`` from the stored region and ``await
+# cloud.close()`` it when done. See ``routes.cloud.build_authenticated_cloud``
+# for the standard pattern.

+ 11 - 11
backend/app/services/github_backup.py

@@ -22,7 +22,6 @@ 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.services.bambu_cloud import get_cloud_service
 from backend.app.services.printer_manager import printer_manager
 
 logger = logging.getLogger(__name__)
@@ -415,16 +414,15 @@ class GitHubBackupService:
 
     async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
         """Collect Bambu Cloud profiles if authenticated."""
-        # Check if cloud is authenticated
-        cloud = get_cloud_service()
-
-        # Try to restore token from DB
-        result = await db.execute(select(Settings).where(Settings.key == "bambu_cloud_token"))
-        setting = result.scalar_one_or_none()
-        if setting and setting.value:
-            cloud.set_token(setting.value)
-
-        if not cloud.is_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.
+        from backend.app.api.routes.cloud import build_authenticated_cloud
+
+        cloud = await build_authenticated_cloud(db, user=None)
+        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
 
@@ -472,6 +470,8 @@ class GitHubBackupService:
 
         except Exception as e:
             logger.warning("Failed to collect cloud profiles: %s", e)
+        finally:
+            await cloud.close()
 
     async def _collect_settings(self, db: AsyncSession, files: dict):
         """Collect app settings."""

+ 222 - 16
backend/tests/integration/test_cloud_auth.py

@@ -6,7 +6,7 @@ Regression tests for:
 - Cloud endpoints use CLOUD_AUTH permission (not SETTINGS_READ)
 """
 
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 from httpx import AsyncClient
@@ -267,19 +267,21 @@ class TestCloudTokenStorage:
         """get_stored_token with user=None and no global token returns (None, None)."""
         from backend.app.api.routes.cloud import get_stored_token
 
-        token, email = await get_stored_token(db_session, user=None)
+        token, email, region = await get_stored_token(db_session, user=None)
         assert token is None
         assert email is None
+        assert region == "global"  # default for missing rows
 
     @pytest.mark.asyncio
     async def test_store_and_get_global_token(self, db_session):
         """store_token with user=None stores in global Settings table."""
         from backend.app.api.routes.cloud import get_stored_token, store_token
 
-        await store_token(db_session, "test-token-123", "test@example.com", user=None)
-        token, email = await get_stored_token(db_session, user=None)
+        await store_token(db_session, "test-token-123", "test@example.com", "global", user=None)
+        token, email, region = await get_stored_token(db_session, user=None)
         assert token == "test-token-123"
         assert email == "test@example.com"
+        assert region == "global"
 
     @pytest.mark.asyncio
     async def test_store_and_get_per_user_token(self, db_session):
@@ -293,7 +295,7 @@ class TestCloudTokenStorage:
         await db_session.commit()
         await db_session.refresh(user)
 
-        await store_token(db_session, "user-token-abc", "user@example.com", user=user)
+        await store_token(db_session, "user-token-abc", "user@example.com", "global", user=user)
 
         # Re-fetch user to verify persistence
         from sqlalchemy import select
@@ -302,6 +304,7 @@ class TestCloudTokenStorage:
         refreshed = result.scalar_one()
         assert refreshed.cloud_token == "user-token-abc"
         assert refreshed.cloud_email == "user@example.com"
+        assert refreshed.cloud_region == "global"
 
     @pytest.mark.asyncio
     async def test_per_user_token_does_not_affect_global(self, db_session):
@@ -316,17 +319,17 @@ class TestCloudTokenStorage:
         await db_session.refresh(user)
 
         # Store per-user token
-        await store_token(db_session, "per-user-token", "per-user@test.com", user=user)
+        await store_token(db_session, "per-user-token", "per-user@test.com", "global", user=user)
 
         # Global should still be empty
-        global_token, global_email = await get_stored_token(db_session, user=None)
+        global_token, global_email, _ = await get_stored_token(db_session, user=None)
         assert global_token is None
         assert global_email is None
 
     @pytest.mark.asyncio
     async def test_clear_per_user_token(self, db_session):
         """clear_token with user clears only that user's credentials."""
-        from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
+        from backend.app.api.routes.cloud import clear_token, store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
 
@@ -335,7 +338,7 @@ class TestCloudTokenStorage:
         await db_session.commit()
         await db_session.refresh(user)
 
-        await store_token(db_session, "to-clear", "clear@test.com", user=user)
+        await store_token(db_session, "to-clear", "clear@test.com", "china", user=user)
         await clear_token(db_session, user=user)
 
         from sqlalchemy import select
@@ -344,22 +347,24 @@ class TestCloudTokenStorage:
         refreshed = result.scalar_one()
         assert refreshed.cloud_token is None
         assert refreshed.cloud_email is None
+        assert refreshed.cloud_region is None
 
     @pytest.mark.asyncio
     async def test_clear_global_token(self, db_session):
         """clear_token with user=None clears from global Settings."""
         from backend.app.api.routes.cloud import clear_token, get_stored_token, store_token
 
-        await store_token(db_session, "global-token", "global@test.com", user=None)
+        await store_token(db_session, "global-token", "global@test.com", "global", user=None)
         await clear_token(db_session, user=None)
 
-        token, email = await get_stored_token(db_session, user=None)
+        token, email, region = await get_stored_token(db_session, user=None)
         assert token is None
         assert email is None
+        assert region == "global"  # normalised default
 
     @pytest.mark.asyncio
     async def test_two_users_independent_tokens(self, db_session):
-        """Two users should have completely independent cloud tokens."""
+        """Two users should have completely independent cloud tokens and regions."""
         from backend.app.api.routes.cloud import get_stored_token, store_token
         from backend.app.core.auth import get_password_hash
         from backend.app.models.user import User
@@ -371,8 +376,10 @@ class TestCloudTokenStorage:
         await db_session.refresh(user_a)
         await db_session.refresh(user_b)
 
-        await store_token(db_session, "token-a", "a@test.com", user=user_a)
-        await store_token(db_session, "token-b", "b@test.com", user=user_b)
+        # Different regions on purpose — a China user and a Global user must not
+        # bleed their region into each other's lookups.
+        await store_token(db_session, "token-a", "a@test.com", "china", user=user_a)
+        await store_token(db_session, "token-b", "b@test.com", "global", user=user_b)
 
         # Verify each user reads their own token (re-fetch from DB)
         from sqlalchemy import select
@@ -382,10 +389,209 @@ class TestCloudTokenStorage:
         fresh_a = result_a.scalar_one()
         fresh_b = result_b.scalar_one()
 
-        token_a, email_a = await get_stored_token(db_session, user=fresh_a)
-        token_b, email_b = await get_stored_token(db_session, user=fresh_b)
+        token_a, email_a, region_a = await get_stored_token(db_session, user=fresh_a)
+        token_b, email_b, region_b = await get_stored_token(db_session, user=fresh_b)
 
         assert token_a == "token-a"
         assert email_a == "a@test.com"
+        assert region_a == "china"
         assert token_b == "token-b"
         assert email_b == "b@test.com"
+        assert region_b == "global"
+
+
+class TestCloudRegionPersistence:
+    """Region must survive a DB round-trip so restarts don't silently flip users to api.bambulab.com."""
+
+    @pytest.mark.asyncio
+    async def test_region_survives_roundtrip_per_user(self, db_session):
+        """Stored China region is returned on subsequent get_stored_token calls."""
+        from backend.app.api.routes.cloud import get_stored_token, store_token
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.user import User
+
+        user = User(username="region-user", password_hash=get_password_hash("pass"), role="user")
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        await store_token(db_session, "cn-token", "token-auth", "china", user=user)
+
+        # Simulate "next request": re-fetch the user fresh from the DB.
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(User).where(User.id == user.id))
+        refreshed = result.scalar_one()
+
+        _token, _email, region = await get_stored_token(db_session, user=refreshed)
+        assert region == "china"
+
+    @pytest.mark.asyncio
+    async def test_region_survives_roundtrip_global_fallback(self, db_session):
+        """Stored China region in auth-disabled Settings fallback survives too."""
+        from backend.app.api.routes.cloud import get_stored_token, store_token
+
+        await store_token(db_session, "cn-token", "token-auth", "china", user=None)
+        _token, _email, region = await get_stored_token(db_session, user=None)
+        assert region == "china"
+
+    @pytest.mark.asyncio
+    async def test_invalid_region_is_normalised_to_global(self, db_session):
+        """Unknown region values fall back to 'global' rather than mis-route."""
+        from backend.app.api.routes.cloud import get_stored_token, store_token
+
+        await store_token(db_session, "t", "x@test.com", "mars", user=None)
+        _token, _email, region = await get_stored_token(db_session, user=None)
+        assert region == "global"
+
+    @pytest.mark.asyncio
+    async def test_build_authenticated_cloud_uses_stored_region(self, db_session):
+        """build_authenticated_cloud wires the stored region into the per-request service."""
+        from backend.app.api.routes.cloud import build_authenticated_cloud, store_token
+        from backend.app.core.auth import get_password_hash
+        from backend.app.models.user import User
+
+        user = User(username="cn-build", password_hash=get_password_hash("pass"), role="user")
+        db_session.add(user)
+        await db_session.commit()
+        await db_session.refresh(user)
+
+        await store_token(db_session, "cn-token", "token-auth", "china", user=user)
+
+        from sqlalchemy import select
+
+        result = await db_session.execute(select(User).where(User.id == user.id))
+        refreshed = result.scalar_one()
+
+        cloud = await build_authenticated_cloud(db_session, refreshed)
+        assert cloud is not None
+        try:
+            assert cloud.base_url == "https://api.bambulab.cn"
+            assert cloud.access_token == "cn-token"
+        finally:
+            await cloud.close()
+
+
+class TestCloudRouteRegionPlumbing:
+    """Route-level proof that region=china on the wire actually steers outbound
+    HTTP calls to api.bambulab.cn / bambulab.cn. This is the core bug the PR
+    fixes — unit tests prove the service does the right thing given the region,
+    storage tests prove the region persists, but only these tests prove the
+    route handlers plumb the region through end-to-end.
+
+    Auth is disabled (Settings-fallback path) to keep the fixture footprint
+    minimal; the region plumbing code path is identical for the per-user path.
+    """
+
+    @staticmethod
+    def _make_response(json_body: dict, status: int = 200):
+        """Build a MagicMock httpx.Response stand-in for patched posts/gets."""
+        response = MagicMock()
+        response.status_code = status
+        response.text = "{}"
+        response.json.return_value = json_body
+        response.cookies = {}
+        return response
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_token_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient):
+        """POST /cloud/token with region=china routes get_user_profile to api.bambulab.cn."""
+        import httpx
+
+        with (
+            patch("backend.app.core.auth.is_auth_enabled", return_value=False),
+            patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get,
+        ):
+            mock_get.return_value = self._make_response({"uid": "123", "email": "x"})
+
+            response = await async_client.post(
+                "/api/v1/cloud/token",
+                json={"access_token": "cn-token", "region": "china"},
+            )
+
+            assert response.status_code == 200
+            # The profile check call must have hit api.bambulab.cn, never .com
+            called_urls = [str(call.args[0]) for call in mock_get.call_args_list if call.args]
+            assert any("api.bambulab.cn" in url for url in called_urls), called_urls
+            assert not any("api.bambulab.com" in url for url in called_urls), called_urls
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_route_with_china_region_hits_cn_endpoint(self, async_client: AsyncClient):
+        """POST /cloud/login with region=china routes login_request to api.bambulab.cn."""
+        import httpx
+
+        with (
+            patch("backend.app.core.auth.is_auth_enabled", return_value=False),
+            patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock) as mock_post,
+        ):
+            mock_post.return_value = self._make_response({"loginType": "verifyCode"})
+
+            response = await async_client.post(
+                "/api/v1/cloud/login",
+                json={"email": "user@example.com", "password": "x", "region": "china"},
+            )
+
+            assert response.status_code == 200
+            called_urls = [str(call.args[0]) for call in mock_post.call_args_list if call.args]
+            assert any("api.bambulab.cn" in url for url in called_urls), called_urls
+            assert not any("api.bambulab.com" in url for url in called_urls), called_urls
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_verify_route_with_china_region_hits_cn_tfa_endpoint(self, async_client: AsyncClient):
+        """POST /cloud/verify with region=china + tfa_key routes TOTP to bambulab.cn."""
+        import httpx
+
+        with (
+            patch("backend.app.core.auth.is_auth_enabled", return_value=False),
+            patch.object(httpx.AsyncClient, "post", new_callable=AsyncMock) as mock_post,
+        ):
+            mock_post.return_value = self._make_response({"token": "t"})
+
+            response = await async_client.post(
+                "/api/v1/cloud/verify",
+                json={
+                    "email": "user@example.com",
+                    "code": "123456",
+                    "tfa_key": "tfa-xyz",
+                    "region": "china",
+                },
+            )
+
+            assert response.status_code == 200
+            called_urls = [str(call.args[0]) for call in mock_post.call_args_list if call.args]
+            # TOTP endpoint lives on bambulab.cn (without the api. prefix),
+            # NOT bambulab.com — that's exactly the bug we just fixed.
+            assert any("bambulab.cn/api/sign-in/tfa" in url for url in called_urls), called_urls
+            assert not any("bambulab.com" in url for url in called_urls), called_urls
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cloud_status_exposes_stored_region(self, async_client: AsyncClient):
+        """GET /cloud/status returns the stored region so the UI can render
+        'Connected (China)' after a reload."""
+        from backend.app.api.routes.cloud import store_token
+        from backend.app.core.database import async_session
+
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            async with async_session() as db:
+                await store_token(db, "cn-token", "token-auth", "china", user=None)
+
+            response = await async_client.get("/api/v1/cloud/status")
+            assert response.status_code == 200
+            data = response.json()
+            assert data["is_authenticated"] is True
+            assert data["region"] == "china"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cloud_status_region_is_null_when_unauthenticated(self, async_client: AsyncClient):
+        """No stored token ⇒ no region in the status payload."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/cloud/status")
+            assert response.status_code == 200
+            data = response.json()
+            assert data["is_authenticated"] is False
+            assert data["region"] is None

+ 55 - 0
backend/tests/unit/services/test_bambu_cloud.py

@@ -236,3 +236,58 @@ class TestBambuCloudTOTPVerification:
             headers = call_args[1]["headers"]
             assert "User-Agent" in headers
             assert "Mozilla" in headers["User-Agent"]
+
+
+class TestBambuCloudRegion:
+    """Region routing — China-region instances must hit api.bambulab.cn."""
+
+    def test_global_region_uses_com_base(self):
+        """Default / 'global' region should use api.bambulab.com."""
+        cloud = BambuCloudService()  # default region
+        assert cloud.base_url == "https://api.bambulab.com"
+
+        cloud_explicit = BambuCloudService(region="global")
+        assert cloud_explicit.base_url == "https://api.bambulab.com"
+
+    def test_china_region_uses_cn_base(self):
+        """'china' region should use api.bambulab.cn."""
+        cloud = BambuCloudService(region="china")
+        assert cloud.base_url == "https://api.bambulab.cn"
+
+    @pytest.mark.asyncio
+    async def test_china_region_login_hits_cn_endpoint(self):
+        """A login_request from a China-region instance must POST to api.bambulab.cn."""
+        cloud = BambuCloudService(region="china")
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {"loginType": "verifyCode"}
+
+        with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            await cloud.login_request("test@example.com", "password")
+
+            url = mock_post.call_args[0][0]
+            assert "api.bambulab.cn" in url
+            assert "api.bambulab.com" not in url
+
+    @pytest.mark.asyncio
+    async def test_china_region_totp_hits_cn_tfa_endpoint(self):
+        """TOTP verification from a China-region instance uses the CN TFA endpoint."""
+        cloud = BambuCloudService(region="china")
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.text = '{"token": "t"}'
+        mock_response.json.return_value = {"token": "t"}
+        mock_response.cookies = {}
+
+        with patch.object(cloud._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            await cloud.verify_totp("tfa-key", "123456")
+
+            url = mock_post.call_args[0][0]
+            assert "bambulab.cn/api/sign-in/tfa" in url
+            assert "bambulab.com" not in url

+ 5 - 4
frontend/src/api/client.ts

@@ -957,6 +957,7 @@ export interface MQTTStatus {
 export interface CloudAuthStatus {
   is_authenticated: boolean;
   email: string | null;
+  region?: 'global' | 'china' | null;
 }
 
 export interface CloudLoginResponse {
@@ -3642,15 +3643,15 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ email, password, region }),
     }),
-  cloudVerify: (email: string, code: string, tfaKey?: string) =>
+  cloudVerify: (email: string, code: string, tfaKey?: string, region: string = 'global') =>
     request<CloudLoginResponse>('/cloud/verify', {
       method: 'POST',
-      body: JSON.stringify({ email, code, tfa_key: tfaKey }),
+      body: JSON.stringify({ email, code, tfa_key: tfaKey, region }),
     }),
-  cloudSetToken: (access_token: string) =>
+  cloudSetToken: (access_token: string, region: string = 'global') =>
     request<CloudAuthStatus>('/cloud/token', {
       method: 'POST',
-      body: JSON.stringify({ access_token }),
+      body: JSON.stringify({ access_token, region }),
     }),
   cloudLogout: () =>
     request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),

+ 34 - 14
frontend/src/pages/ProfilesPage.tsx

@@ -128,7 +128,7 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   });
 
   const verifyMutation = useMutation({
-    mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined),
+    mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined, region),
     onSuccess: (result) => {
       if (result.success) {
         showToast(t('profiles.login.toast.loggedIn'));
@@ -141,7 +141,7 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   });
 
   const tokenMutation = useMutation({
-    mutationFn: () => api.cloudSetToken(token),
+    mutationFn: () => api.cloudSetToken(token, region),
     onSuccess: () => {
       showToast(t('profiles.login.toast.tokenSet'));
       onSuccess();
@@ -231,18 +231,31 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
           )}
 
           {step === 'token' && (
-            <div>
-              <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.accessToken')}</label>
-              <p className="text-xs text-bambu-gray mb-2">{t('profiles.login.accessTokenHint')}</p>
-              <textarea
-                value={token}
-                onChange={(e) => setToken(e.target.value)}
-                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none resize-none"
-                placeholder="eyJ..."
-                rows={4}
-                required
-              />
-            </div>
+            <>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.accessToken')}</label>
+                <p className="text-xs text-bambu-gray mb-2">{t('profiles.login.accessTokenHint')}</p>
+                <textarea
+                  value={token}
+                  onChange={(e) => setToken(e.target.value)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono placeholder-bambu-gray-dark focus:border-bambu-green focus:outline-none resize-none"
+                  placeholder="eyJ..."
+                  rows={4}
+                  required
+                />
+              </div>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.region')}</label>
+                <select
+                  value={region}
+                  onChange={(e) => setRegion(e.target.value)}
+                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                >
+                  <option value="global">{t('profiles.login.regionGlobal')}</option>
+                  <option value="china">{t('profiles.login.regionChina')}</option>
+                </select>
+              </div>
+            </>
           )}
 
           <div className="flex gap-2 max-[550px]:flex-wrap max-[550px]:items-center">
@@ -2909,6 +2922,13 @@ export function ProfilesPage() {
                 <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
                 <span className="text-sm text-bambu-gray">
                   {t('profiles.connectedAs')} <span className="text-white">{status.email}</span>
+                  {status.region && (
+                    <span className="ml-2 px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
+                      {status.region === 'china'
+                        ? t('profiles.login.regionChina')
+                        : t('profiles.login.regionGlobal')}
+                    </span>
+                  )}
                 </span>
               </div>
               <Button