Browse Source

Add TOTP authenticator support for Bambu Cloud login (fixes #182)

TOTP (Two-Factor Authentication):
- Detect TOTP vs email verification from Bambu API loginType response
- Use dedicated TFA endpoint on bambulab.com (not api.bambulab.com)
- Include browser-like headers to bypass Cloudflare protection
- Extract token from JSON response or cookies
- Frontend shows appropriate messages for each verification type
- Added i18n translations for TOTP UI (en, de, ja)

Closes #182
maziggy 3 months ago
parent
commit
ea93535bfc

+ 15 - 0
CHANGELOG.md

@@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.7b] - Not released
 
 ### Enhancements
+- **TOTP Authenticator Support for Bambu Cloud** (Issue #182):
+  - Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud
+  - Accounts with authenticator apps (Google Authenticator, Authy, etc.) now work correctly
+  - Proper detection of verification type: email code vs TOTP code
+  - Uses browser-like headers to bypass Cloudflare protection on TFA endpoint
+  - Frontend shows appropriate message for each verification type
+  - Added translations for TOTP UI in English, German, and Japanese
 - **Spoolman: Open in Spoolman Button** (Issue #210):
   - FilamentHoverCard now shows "Open in Spoolman" button when spool is already linked in Spoolman
   - Button links directly to the spool's page in Spoolman for quick editing
@@ -18,6 +25,14 @@ All notable changes to Bambuddy will be documented in this file.
   - Components translated: ConfirmModal, LinkSpoolModal, FilamentHoverCard, Layout
   - Added locale parity test to ensure English and German stay in sync
 
+### Fixed
+- **Authentication Required for Downloads** (Issue #231):
+  - Fixed support bundle download returning 401 Unauthorized when auth is enabled
+  - Fixed archive export (CSV/XLSX) failing with authentication enabled
+  - Fixed statistics export failing with authentication enabled
+  - Fixed printer file ZIP download failing with authentication enabled
+  - Root cause: These endpoints used raw `fetch()` without Authorization header
+
 ## [0.1.6.2] - 2026-02-02
 
 > **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.

+ 21 - 6
backend/app/api/routes/cloud.py

@@ -93,8 +93,12 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
     """
     Initiate login to Bambu Cloud.
 
-    This will typically trigger a verification code to be sent to the user's email.
-    After receiving the code, call /cloud/verify to complete the login.
+    This will trigger either:
+    - Email verification: A code is sent to the user's email
+    - TOTP verification: User enters code from their authenticator app
+
+    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()
 
@@ -112,6 +116,8 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
             success=result.get("success", False),
             needs_verification=result.get("needs_verification", False),
             message=result.get("message", "Unknown error"),
+            verification_type=result.get("verification_type"),
+            tfa_key=result.get("tfa_key"),
         )
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
@@ -122,15 +128,24 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
 @router.post("/verify", response_model=CloudLoginResponse)
 async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
     """
-    Complete login with verification code.
+    Complete login with verification code (email or TOTP).
+
+    For email verification:
+    - After calling /cloud/login, the user receives an email with a 6-digit code
+    - Submit the code with email address
 
-    After calling /cloud/login, the user will receive an email with a 6-digit code.
-    Submit that code here to complete authentication.
+    For TOTP verification:
+    - The user enters the 6-digit code from their authenticator app
+    - Include the tfa_key from the /cloud/login response
     """
     cloud = get_cloud_service()
 
     try:
-        result = await cloud.verify_code(request.email, request.code)
+        # Use TOTP verification if tfa_key is provided
+        if request.tfa_key:
+            result = await cloud.verify_totp(request.tfa_key, request.code)
+        else:
+            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)

+ 5 - 2
backend/app/schemas/cloud.py

@@ -10,10 +10,11 @@ class CloudLoginRequest(BaseModel):
 
 
 class CloudVerifyRequest(BaseModel):
-    """Request to verify login with 2FA code."""
+    """Request to verify login with 2FA code (email or TOTP)."""
 
     email: str = Field(..., description="Bambu Lab account email")
-    code: str = Field(..., description="6-digit verification code from email")
+    code: str = Field(..., description="6-digit verification code")
+    tfa_key: str | None = Field(None, description="TFA key for TOTP verification (from login response)")
 
 
 class CloudLoginResponse(BaseModel):
@@ -22,6 +23,8 @@ class CloudLoginResponse(BaseModel):
     success: bool
     needs_verification: bool = False
     message: str
+    verification_type: str | None = None  # "email" or "totp"
+    tfa_key: str | None = None  # Key needed for TOTP verification
 
 
 class CloudAuthStatus(BaseModel):

+ 110 - 8
backend/app/services/bambu_cloud.py

@@ -56,9 +56,9 @@ class BambuCloudService:
 
     async def login_request(self, email: str, password: str) -> dict:
         """
-        Initiate login - this will trigger a verification code email.
+        Initiate login - this will trigger either email verification or TOTP prompt.
 
-        Returns dict with login status and whether verification is needed.
+        Returns dict with login status, verification type, and tfaKey if needed.
         """
         try:
             response = await self._client.post(
@@ -71,12 +71,33 @@ class BambuCloudService:
             )
 
             data = response.json()
+            logger.debug(
+                f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
+            )
 
             if response.status_code == 200:
-                # Check if we need verification code
-                # Bambu API returns loginType or may require tfaKey
-                if data.get("loginType") == "verifyCode" or "tfaKey" in data:
-                    return {"success": False, "needs_verification": True, "message": "Verification code sent to email"}
+                login_type = data.get("loginType")
+                tfa_key = data.get("tfaKey")
+
+                # TOTP authentication required
+                if login_type == "tfa" or (tfa_key and login_type != "verifyCode"):
+                    return {
+                        "success": False,
+                        "needs_verification": True,
+                        "verification_type": "totp",
+                        "tfa_key": tfa_key,
+                        "message": "Enter the code from your authenticator app",
+                    }
+
+                # Email verification required
+                if login_type == "verifyCode":
+                    return {
+                        "success": False,
+                        "needs_verification": True,
+                        "verification_type": "email",
+                        "tfa_key": None,
+                        "message": "Verification code sent to email",
+                    }
 
                 # Direct login success (rare, usually needs 2FA)
                 if "accessToken" in data:
@@ -93,7 +114,7 @@ class BambuCloudService:
 
     async def verify_code(self, email: str, code: str) -> dict:
         """
-        Complete login with verification code.
+        Complete login with email verification code.
         """
         try:
             response = await self._client.post(
@@ -106,6 +127,7 @@ class BambuCloudService:
             )
 
             data = response.json()
+            logger.debug(f"Email verify response: status={response.status_code}, hasToken={'accessToken' in data}")
 
             if response.status_code == 200 and "accessToken" in data:
                 self._set_tokens(data)
@@ -114,9 +136,89 @@ class BambuCloudService:
             return {"success": False, "message": data.get("message", "Verification failed")}
 
         except Exception as e:
-            logger.error(f"Verification failed: {e}")
+            logger.error(f"Email verification failed: {e}")
             raise BambuCloudAuthError(f"Verification failed: {e}")
 
+    async def verify_totp(self, tfa_key: str, code: str) -> dict:
+        """
+        Complete login with TOTP code from authenticator app.
+
+        Args:
+            tfa_key: The tfaKey returned from initial login request
+            code: 6-digit TOTP code from authenticator app
+        """
+        try:
+            # TFA endpoint is on bambulab.com, NOT api.bambulab.com
+            # Requires browser-like headers to bypass Cloudflare
+            tfa_url = "https://bambulab.com/api/sign-in/tfa"
+            if "bambulab.cn" in self.base_url:
+                tfa_url = "https://bambulab.cn/api/sign-in/tfa"
+
+            browser_headers = {
+                "Content-Type": "application/json",
+                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+                "Accept": "application/json, text/plain, */*",
+                "Accept-Language": "en-US,en;q=0.9",
+                "Origin": "https://bambulab.com",
+                "Referer": "https://bambulab.com/",
+            }
+
+            response = await self._client.post(
+                tfa_url,
+                headers=browser_headers,
+                json={
+                    "tfaKey": tfa_key,
+                    "tfaCode": code,
+                },
+            )
+
+            logger.debug(
+                f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
+            )
+
+            # Handle empty response
+            if not response.text or not response.text.strip():
+                logger.warning(f"TOTP verification returned empty response (status {response.status_code})")
+                return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
+
+            try:
+                data = response.json()
+            except Exception as json_err:
+                logger.error(f"Failed to parse TOTP response: {json_err}, body: {response.text[:500]}")
+                return {"success": False, "message": "Invalid response from Bambu Cloud"}
+
+            # Token might be in accessToken, token field, or cookies
+            access_token = data.get("accessToken") or data.get("token")
+
+            # Also check cookies for token
+            if not access_token:
+                for cookie in response.cookies:
+                    if "token" in cookie.lower():
+                        access_token = response.cookies.get(cookie)
+                        break
+
+            if response.status_code == 200 and access_token:
+                self.access_token = access_token
+                self.refresh_token = data.get("refreshToken")
+                from datetime import datetime, timedelta
+
+                self.token_expiry = datetime.now() + timedelta(days=30)
+                return {"success": True, "message": "Login successful"}
+
+            # Provide helpful error message
+            error_msg = data.get("message", "")
+            if "expired" in error_msg.lower():
+                return {"success": False, "message": "TOTP session expired. Please try logging in again."}
+            if not error_msg:
+                error_msg = f"TOTP verification failed (status {response.status_code})"
+
+            return {"success": False, "message": error_msg}
+
+        except Exception as e:
+            logger.error(f"TOTP verification failed: {e}")
+            # Return error instead of raising - don't trigger 401/500
+            return {"success": False, "message": f"TOTP verification error: {e}"}
+
     def _set_tokens(self, data: dict):
         """Set tokens from login response."""
         self.access_token = data.get("accessToken")

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

@@ -0,0 +1,238 @@
+"""Tests for Bambu Cloud service - TOTP and email verification flows."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.bambu_cloud import BambuCloudService
+
+
+class TestBambuCloudLogin:
+    """Test login flow detection (email vs TOTP)."""
+
+    @pytest.fixture
+    def cloud_service(self):
+        """Create a BambuCloudService instance."""
+        return BambuCloudService()
+
+    @pytest.mark.asyncio
+    async def test_login_detects_email_verification(self, cloud_service):
+        """When loginType is verifyCode, should return email verification type."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "loginType": "verifyCode",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.login_request("test@example.com", "password")
+
+            assert result["success"] is False
+            assert result["needs_verification"] is True
+            assert result["verification_type"] == "email"
+            assert result["tfa_key"] is None
+            assert "email" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    async def test_login_detects_totp(self, cloud_service):
+        """When loginType is tfa, should return TOTP verification type with tfaKey."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "loginType": "tfa",
+            "tfaKey": "test-tfa-key-123",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.login_request("test@example.com", "password")
+
+            assert result["success"] is False
+            assert result["needs_verification"] is True
+            assert result["verification_type"] == "totp"
+            assert result["tfa_key"] == "test-tfa-key-123"
+            assert "authenticator" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    async def test_login_direct_success(self, cloud_service):
+        """When accessToken is returned directly, should succeed without verification."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "accessToken": "test-access-token",
+            "refreshToken": "test-refresh-token",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.login_request("test@example.com", "password")
+
+            assert result["success"] is True
+            assert result["needs_verification"] is False
+            assert cloud_service.access_token == "test-access-token"
+
+    @pytest.mark.asyncio
+    async def test_login_failure(self, cloud_service):
+        """When login fails, should return error message."""
+        mock_response = MagicMock()
+        mock_response.status_code = 401
+        mock_response.json.return_value = {
+            "message": "Invalid credentials",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.login_request("test@example.com", "wrong-password")
+
+            assert result["success"] is False
+            assert result["needs_verification"] is False
+            assert "Invalid credentials" in result["message"]
+
+
+class TestBambuCloudEmailVerification:
+    """Test email verification flow."""
+
+    @pytest.fixture
+    def cloud_service(self):
+        """Create a BambuCloudService instance."""
+        return BambuCloudService()
+
+    @pytest.mark.asyncio
+    async def test_verify_code_success(self, cloud_service):
+        """When email code is correct, should return success with token."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "accessToken": "test-access-token",
+            "refreshToken": "test-refresh-token",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_code("test@example.com", "123456")
+
+            assert result["success"] is True
+            assert cloud_service.access_token == "test-access-token"
+
+    @pytest.mark.asyncio
+    async def test_verify_code_failure(self, cloud_service):
+        """When email code is incorrect, should return failure."""
+        mock_response = MagicMock()
+        mock_response.status_code = 400
+        mock_response.json.return_value = {
+            "message": "Invalid verification code",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_code("test@example.com", "000000")
+
+            assert result["success"] is False
+            assert "Invalid" in result["message"] or "Verification failed" in result["message"]
+
+
+class TestBambuCloudTOTPVerification:
+    """Test TOTP verification flow."""
+
+    @pytest.fixture
+    def cloud_service(self):
+        """Create a BambuCloudService instance."""
+        return BambuCloudService()
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_success(self, cloud_service):
+        """When TOTP code is correct, should return success with token."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.text = '{"token": "test-access-token"}'
+        mock_response.json.return_value = {
+            "token": "test-access-token",
+        }
+        mock_response.cookies = {}
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            assert result["success"] is True
+            assert cloud_service.access_token == "test-access-token"
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_uses_correct_endpoint(self, cloud_service):
+        """TOTP verification should use bambulab.com, not api.bambulab.com."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.text = '{"token": "test-token"}'
+        mock_response.json.return_value = {"token": "test-token"}
+        mock_response.cookies = {}
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            # Check the URL used
+            call_args = mock_post.call_args
+            url = call_args[0][0]
+            assert "bambulab.com/api/sign-in/tfa" in url
+            assert "api.bambulab.com" not in url
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_empty_response(self, cloud_service):
+        """When TOTP returns empty response, should handle gracefully."""
+        mock_response = MagicMock()
+        mock_response.status_code = 400
+        mock_response.text = ""
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            assert result["success"] is False
+            assert "empty response" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_cloudflare_blocked(self, cloud_service):
+        """When Cloudflare blocks request, should handle gracefully."""
+        mock_response = MagicMock()
+        mock_response.status_code = 403
+        mock_response.text = "<!DOCTYPE html><html><head><title>Just a moment...</title>"
+        # json() raises an error when response is HTML
+        mock_response.json.side_effect = ValueError("No JSON")
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            assert result["success"] is False
+            assert "Invalid response" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_includes_browser_headers(self, cloud_service):
+        """TOTP verification should include browser-like headers to bypass Cloudflare."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.text = '{"token": "test-token"}'
+        mock_response.json.return_value = {"token": "test-token"}
+        mock_response.cookies = {}
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            # Check headers include User-Agent
+            call_args = mock_post.call_args
+            headers = call_args[1]["headers"]
+            assert "User-Agent" in headers
+            assert "Mozilla" in headers["User-Agent"]

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

@@ -769,6 +769,8 @@ export interface CloudLoginResponse {
   success: boolean;
   needs_verification: boolean;
   message: string;
+  verification_type?: 'email' | 'totp' | null;
+  tfa_key?: string | null;
 }
 
 export interface SlicerSetting {
@@ -2620,10 +2622,10 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ email, password, region }),
     }),
-  cloudVerify: (email: string, code: string) =>
+  cloudVerify: (email: string, code: string, tfaKey?: string) =>
     request<CloudLoginResponse>('/cloud/verify', {
       method: 'POST',
-      body: JSON.stringify({ email, code }),
+      body: JSON.stringify({ email, code, tfa_key: tfaKey }),
     }),
   cloudSetToken: (access_token: string) =>
     request<CloudAuthStatus>('/cloud/token', {

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -1656,7 +1656,9 @@ export default {
       regionGlobal: 'Global',
       regionChina: 'China',
       verificationCode: 'Bestätigungscode',
+      totpCode: 'Authenticator-Code',
       checkEmail: 'Prüfen Sie Ihre E-Mail ({{email}}) für einen 6-stelligen Code',
+      enterTotpHint: 'Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein',
       accessToken: 'Zugriffstoken',
       accessTokenHint: 'Fügen Sie Ihr Bambu Lab Zugriffstoken ein (aus Bambu Studio)',
       back: 'Zurück',
@@ -1668,6 +1670,7 @@ export default {
       toast: {
         loggedIn: 'Erfolgreich angemeldet',
         codeSent: 'Bestätigungscode an Ihre E-Mail gesendet',
+        enterTotp: 'Geben Sie den Code aus Ihrer Authenticator-App ein',
         tokenSet: 'Token erfolgreich gesetzt',
       },
     },

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -1656,7 +1656,9 @@ export default {
       regionGlobal: 'Global',
       regionChina: 'China',
       verificationCode: 'Verification Code',
+      totpCode: 'Authenticator Code',
       checkEmail: 'Check your email ({{email}}) for a 6-digit code',
+      enterTotpHint: 'Enter the 6-digit code from your authenticator app',
       accessToken: 'Access Token',
       accessTokenHint: 'Paste your Bambu Lab access token (from Bambu Studio)',
       back: 'Back',
@@ -1668,6 +1670,7 @@ export default {
       toast: {
         loggedIn: 'Logged in successfully',
         codeSent: 'Verification code sent to your email',
+        enterTotp: 'Enter code from your authenticator app',
         tokenSet: 'Token set successfully',
       },
     },

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -731,7 +731,9 @@ export default {
     regionGlobal: 'グローバル',
     regionChina: '中国',
     verificationCode: '認証コード',
+    totpCode: '認証アプリコード',
     checkEmail: 'メール ({{email}}) に届いた6桁のコードを入力してください',
+    enterTotpHint: '認証アプリの6桁のコードを入力してください',
     accessToken: 'アクセストークン',
     accessTokenHint: 'Bambu Labのアクセストークンを貼り付け(Bambu Studioから取得)',
     login: 'ログイン',
@@ -741,6 +743,7 @@ export default {
     loginWithEmail: 'メールでログイン',
     loggedIn: 'ログインしました',
     verificationSent: '認証コードをメールに送信しました',
+    enterTotp: '認証アプリのコードを入力してください',
     tokenSet: 'トークンを設定しました',
     loggedOut: 'ログアウトしました',
     logout: 'ログアウト',

+ 18 - 4
frontend/src/pages/ProfilesPage.tsx

@@ -117,6 +117,8 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   const [code, setCode] = useState('');
   const [token, setToken] = useState('');
   const [region, setRegion] = useState('global');
+  const [verificationType, setVerificationType] = useState<'email' | 'totp' | null>(null);
+  const [tfaKey, setTfaKey] = useState<string | null>(null);
 
   const loginMutation = useMutation({
     mutationFn: () => api.cloudLogin(email, password, region),
@@ -125,7 +127,13 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
         showToast(t('profiles.login.toast.loggedIn'));
         onSuccess();
       } else if (result.needs_verification) {
-        showToast(t('profiles.login.toast.codeSent'));
+        setVerificationType(result.verification_type || 'email');
+        setTfaKey(result.tfa_key || null);
+        if (result.verification_type === 'totp') {
+          showToast(t('profiles.login.toast.enterTotp'));
+        } else {
+          showToast(t('profiles.login.toast.codeSent'));
+        }
         setStep('code');
       } else {
         showToast(result.message, 'error');
@@ -135,7 +143,7 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   });
 
   const verifyMutation = useMutation({
-    mutationFn: () => api.cloudVerify(email, code),
+    mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined),
     onSuccess: (result) => {
       if (result.success) {
         showToast(t('profiles.login.toast.loggedIn'));
@@ -217,8 +225,14 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
 
           {step === 'code' && (
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">{t('profiles.login.verificationCode')}</label>
-              <p className="text-xs text-bambu-gray mb-2">{t('profiles.login.checkEmail', { email })}</p>
+              <label className="block text-sm text-bambu-gray mb-1">
+                {verificationType === 'totp' ? t('profiles.login.totpCode') : t('profiles.login.verificationCode')}
+              </label>
+              <p className="text-xs text-bambu-gray mb-2">
+                {verificationType === 'totp'
+                  ? t('profiles.login.enterTotpHint')
+                  : t('profiles.login.checkEmail', { email })}
+              </p>
               <input
                 type="text"
                 value={code}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BXCvE4aN.js


+ 1 - 1
static/index.html

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

Some files were not shown because too many files changed in this diff