Pārlūkot izejas kodu

Explain Bambu Cloud's CAPTCHA challenge instead of repeating it (#2790)

A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
flagged the network and was answering the sign-in with HTTP 418 and a challenge
body: {"captchaId": "...", "error": "We need you to confirm you are not a
robot"}.

Bambuddy had no idea what that was. The reply is well-formed JSON, so
_detect_cloudflare_challenge -- which triggers on an unparseable body, CF
markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
fell through to its generic error path, which lifts data["message"] or
data["error"] out and hands it to the UI verbatim. The user was left to conclude
their password was wrong or that Bambuddy was broken. Four sign-in attempts
inside eighteen seconds appear in their log, each one more evidence for the
thing that had flagged them.

is_captcha_challenge matches on the 418 status plus a challenge marker in the
body -- captchaId is the reliable one, the wording is matched too because Bambu
has shipped it under more than one phrasing. A bare 418 with no marker is
has shipped it under more than one phrasing. A bare 418 with no marker is
deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
that was never offered is the exact confusion this issue is about.

login_request, verify_code and verify_totp now return reason="captcha" with an
explanation covering the three things the reporter had no way to find out: the
credentials are not the problem, the block is keyed to the public IP address
rather than the account, and it clears by itself within a few hours.

Sign-in requests are then held back for 300s so Bambuddy stops deepening the
block. Keyed per origin, not per service: TOTP verification posts to
bambulab.com while everything else posts to api.bambulab.com, and a challenge
seen on one must not strand somebody halfway through a two-factor sign-in on the
other. Entries expire on read, so the map cannot grow past one per region. The
token endpoint is deliberately left ungated -- it is the way out.

The UI shows a persistent panel rather than a toast. A toast names a problem the
user cannot act on and then vanishes; this one stays put and carries a one-click
route to "Use access token instead", which is the only thing that works while
the challenge lasts, since that path does not touch the challenged endpoint.

MakerWorld meets the same challenge from the same edge and now shares the
detection. It used to require the literal word "robot" in the error text and
reported any other wording as an unexplained block.

The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
bundle came back with zero findings while their log was full of the failure.

Its advice for a failed FTPS handshake was corrected at the same time: it still
blamed firewalls and outdated firmware, which the #2780 investigation ruled out
last release -- it is the printer's own file service wedging, and the fix is to
restart the printer. The wiki said so already; the health panel did not.
maziggy 4 nedēļas atpakaļ
vecāks
revīzija
604fa44593

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
CHANGELOG.md


+ 2 - 0
backend/app/api/routes/cloud.py

@@ -528,6 +528,7 @@ async def login(
             message=result.get("message", "Unknown error"),
             message=result.get("message", "Unknown error"),
             verification_type=result.get("verification_type"),
             verification_type=result.get("verification_type"),
             tfa_key=result.get("tfa_key"),
             tfa_key=result.get("tfa_key"),
+            reason=result.get("reason"),
         )
         )
     except BambuCloudAuthError as e:
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
         raise HTTPException(status_code=401, detail=str(e))
@@ -573,6 +574,7 @@ async def verify_code(
             success=result.get("success", False),
             success=result.get("success", False),
             needs_verification=False,
             needs_verification=False,
             message=result.get("message", "Unknown error"),
             message=result.get("message", "Unknown error"),
+            reason=result.get("reason"),
         )
         )
     except BambuCloudAuthError as e:
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
         raise HTTPException(status_code=401, detail=str(e))

+ 6 - 0
backend/app/schemas/cloud.py

@@ -30,6 +30,12 @@ class CloudLoginResponse(BaseModel):
     message: str
     message: str
     verification_type: str | None = None  # "email" or "totp"
     verification_type: str | None = None  # "email" or "totp"
     tfa_key: str | None = None  # Key needed for TOTP verification
     tfa_key: str | None = None  # Key needed for TOTP verification
+    # Machine-readable cause of a failure, when we know it. Currently only
+    # "captcha" — Bambu's anti-abuse layer is challenging this network and no
+    # credential will be accepted until it clears (#2790). The UI needs this to
+    # explain the situation in place, rather than flashing ``message`` as a
+    # toast that vanishes and leaves the user retrying a password that is fine.
+    reason: str | None = None
 
 
 
 
 class CloudAuthStatus(BaseModel):
 class CloudAuthStatus(BaseModel):

+ 168 - 0
backend/app/services/bambu_cloud.py

@@ -124,6 +124,110 @@ def _detect_cloudflare_challenge(response) -> str | None:
     return None
     return None
 
 
 
 
+# Bambu's own anti-abuse layer — distinct from the Cloudflare edge above —
+# answers a request it has flagged with HTTP 418 and a challenge body:
+#
+#     {"captchaId": "...", "error": "We need you to confirm you are not a robot"}
+#
+# The flag is keyed to the source IP and covers api.bambulab.com as a whole:
+# the same 418 turns up on the login endpoint and on the design-service
+# endpoints MakerWorld imports use. It clears on its own after a few hours of
+# quiet traffic, and there is no server-side solve — a CAPTCHA is designed to be
+# unanswerable without a real browser, and the challenge id is of no use to us
+# because we have nowhere to render the widget.
+#
+# It reaches ``login_request`` as a perfectly well-formed JSON body, so
+# ``_detect_cloudflare_challenge`` above never fires on it. Before #2790 the
+# generic error path then lifted Bambu's sentence out of ``error`` and showed it
+# as a bare toast: the reporter saw "We need you to confirm you are not a robot"
+# with no challenge, no explanation and nothing to click, and filed it as a
+# Bambuddy bug.
+_CAPTCHA_HTTP_STATUS = 418
+
+# Markers that identify a 418 as the CAPTCHA challenge rather than some other
+# refusal. ``captchaId`` is the reliable one; the wording is matched too because
+# Bambu has shipped the challenge under more than one phrasing.
+_CAPTCHA_BODY_MARKERS = ("captchaid", "captcha", "robot")
+
+CAPTCHA_USER_MESSAGE = (
+    "Bambu Cloud is challenging this network with a CAPTCHA before it will accept a sign-in, "
+    "and there is no way to answer it from Bambuddy. Your email and password are not the "
+    "problem. The block is tied to your public IP address and normally clears by itself within "
+    "a few hours — retrying repeatedly extends it. To sign in now, use 'Use access token "
+    "instead' and paste a token taken from a browser session."
+)
+
+# How long to stop sending sign-in requests to a Bambu region after it answered
+# with a CAPTCHA challenge. The reporter's log shows four attempts in eighteen
+# seconds, which is exactly the traffic pattern that deepens the block: every
+# extra request is more evidence for the thing that flagged us. Five minutes is
+# short against the hours the block itself lasts — the point is not to wait it
+# out here, only to stop Bambuddy from making it worse while the user reads the
+# explanation.
+_CAPTCHA_COOLOFF_SECONDS = 300.0
+
+# API base URL -> monotonic time its cool-off expires. Keyed by base URL because
+# the block lives at the edge in front of one region: being challenged on
+# api.bambulab.com says nothing about api.bambulab.cn.
+_captcha_blocked_until: dict[str, float] = {}
+
+
+def is_captcha_challenge(response) -> bool:
+    """Whether Bambu answered with an anti-abuse CAPTCHA challenge.
+
+    Requires the 418 status *and* a challenge marker in the body, so an
+    unrelated 418 is not reported to the user as "solve a CAPTCHA" — that would
+    send them looking for a widget that was never there, which is the exact
+    confusion #2790 is about. Callers that want to say something about a bare
+    418 must handle it themselves.
+
+    Shared by the Bambu Cloud and MakerWorld services: same edge, same body.
+    """
+    try:
+        status = int(getattr(response, "status_code", 0) or 0)
+    except (TypeError, ValueError):
+        return False
+    if status != _CAPTCHA_HTTP_STATUS:
+        return False
+    try:
+        data = response.json()
+    except Exception:
+        data = None
+    if isinstance(data, dict):
+        # Field *names* count as well as their text: the challenge is
+        # identified by carrying a ``captchaId`` at all, whatever it says.
+        parts = [str(key) for key in data]
+        parts += [str(data[key]) for key in ("captchaId", "error", "message", "detail") if data.get(key)]
+        haystack = " ".join(parts).lower()
+    else:
+        # Not JSON (or not an object) — fall back to the raw body so a
+        # challenge served as HTML is still recognised rather than reported as
+        # an unexplained failure.
+        try:
+            haystack = (response.text or "").lower()
+        except Exception:
+            return False
+    return any(marker in haystack for marker in _CAPTCHA_BODY_MARKERS)
+
+
+def captcha_cooloff_active(base_url: str) -> bool:
+    """Whether sign-in requests to ``base_url`` are still held back after a
+    CAPTCHA challenge. Expired entries are dropped on the way past, so the dict
+    cannot grow past one entry per region."""
+    deadline = _captcha_blocked_until.get(base_url)
+    if deadline is None:
+        return False
+    if time.monotonic() >= deadline:
+        del _captcha_blocked_until[base_url]
+        return False
+    return True
+
+
+def note_captcha_challenge(base_url: str) -> None:
+    """Start the cool-off for ``base_url`` after a challenge was seen."""
+    _captcha_blocked_until[base_url] = time.monotonic() + _CAPTCHA_COOLOFF_SECONDS
+
+
 # The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
 # The `/v1/iot-service/api/slicer/setting` endpoint subtree — the plural GET
 # for the list, the singular GET/DELETE for a specific preset by setting_id, and
 # for the list, the singular GET/DELETE for a specific preset by setting_id, and
 # the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
 # the POST for create — requires a `version` query parameter in the XX.YY.ZZ.WW
@@ -317,12 +421,62 @@ class BambuCloudService:
             headers["Authorization"] = f"Bearer {self.access_token}"
             headers["Authorization"] = f"Bearer {self.access_token}"
         return headers
         return headers
 
 
+    def _captcha_refusal(self) -> dict:
+        """The result every sign-in call returns while Bambu is challenging us.
+
+        ``reason`` is what lets the UI tell this apart from a wrong password and
+        render the explanation next to the access-token route, instead of
+        flashing Bambu's own one-liner as a toast that then disappears (#2790).
+        """
+        return {
+            "success": False,
+            "needs_verification": False,
+            "reason": "captcha",
+            "message": CAPTCHA_USER_MESSAGE,
+        }
+
+    def _captcha_cooloff_holds(self, origin: str | None = None) -> bool:
+        """Whether to refuse a sign-in locally because Bambu just challenged us.
+
+        Keyed by the origin the call actually goes to. The TOTP step talks to
+        ``bambulab.com`` while everything else talks to ``api.bambulab.com``, and
+        a challenge seen on one must not strand a user halfway through a
+        two-factor sign-in on the other.
+        """
+        origin = origin or self.base_url
+        if not captcha_cooloff_active(origin):
+            return False
+        logger.warning(
+            "Bambu Cloud is challenging this network with a CAPTCHA — not sending the sign-in to %s. "
+            "The challenge cannot be answered from Bambuddy and normally clears within a few hours.",
+            origin,
+        )
+        return True
+
+    def _note_captcha(self, response, origin: str | None = None) -> bool:
+        """Record and log a CAPTCHA challenge. Returns whether it was one."""
+        if not is_captcha_challenge(response):
+            return False
+        origin = origin or self.base_url
+        logger.warning(
+            "Bambu Cloud is challenging this network with a CAPTCHA (HTTP %s from %s). Sign-in cannot "
+            "complete until the challenge clears; pausing sign-in requests for %.0fs so retries do not "
+            "extend the block.",
+            response.status_code,
+            origin,
+            _CAPTCHA_COOLOFF_SECONDS,
+        )
+        note_captcha_challenge(origin)
+        return True
+
     async def login_request(self, email: str, password: str) -> dict:
     async def login_request(self, email: str, password: str) -> dict:
         """
         """
         Initiate login - this will trigger either email verification or TOTP prompt.
         Initiate login - this will trigger either email verification or TOTP prompt.
 
 
         Returns dict with login status, verification type, and tfaKey if needed.
         Returns dict with login status, verification type, and tfaKey if needed.
         """
         """
+        if self._captcha_cooloff_holds():
+            return self._captcha_refusal()
         try:
         try:
             response = await self._client.post(
             response = await self._client.post(
                 f"{self.base_url}/v1/user-service/user/login",
                 f"{self.base_url}/v1/user-service/user/login",
@@ -333,6 +487,9 @@ class BambuCloudService:
                 },
                 },
             )
             )
 
 
+            if self._note_captcha(response):
+                return self._captcha_refusal()
+
             try:
             try:
                 data = response.json()
                 data = response.json()
             except Exception as json_err:
             except Exception as json_err:
@@ -388,6 +545,8 @@ class BambuCloudService:
         """
         """
         Complete login with email verification code.
         Complete login with email verification code.
         """
         """
+        if self._captcha_cooloff_holds():
+            return self._captcha_refusal()
         try:
         try:
             response = await self._client.post(
             response = await self._client.post(
                 f"{self.base_url}/v1/user-service/user/login",
                 f"{self.base_url}/v1/user-service/user/login",
@@ -398,6 +557,9 @@ class BambuCloudService:
                 },
                 },
             )
             )
 
 
+            if self._note_captcha(response):
+                return self._captcha_refusal()
+
             try:
             try:
                 data = response.json()
                 data = response.json()
             except Exception as json_err:
             except Exception as json_err:
@@ -472,6 +634,9 @@ class BambuCloudService:
             web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
             web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
             tfa_url = f"{web_origin}/api/sign-in/tfa"
             tfa_url = f"{web_origin}/api/sign-in/tfa"
 
 
+            if self._captcha_cooloff_holds(web_origin):
+                return self._captcha_refusal()
+
             # #2696: the web origin is CSRF-protected (double submit). Without
             # #2696: the web origin is CSRF-protected (double submit). Without
             # both halves the endpoint 403s before it ever evaluates the code,
             # both halves the endpoint 403s before it ever evaluates the code,
             # which surfaced to users as a permanent, misleading "Invalid code".
             # which surfaced to users as a permanent, misleading "Invalid code".
@@ -509,6 +674,9 @@ class BambuCloudService:
                 f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
                 f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
             )
             )
 
 
+            if self._note_captcha(response, web_origin):
+                return self._captcha_refusal()
+
             # Handle empty response
             # Handle empty response
             if not response.text or not response.text.strip():
             if not response.text or not response.text.strip():
                 logger.warning("TOTP verification returned empty response (status %s)", response.status_code)
                 logger.warning("TOTP verification returned empty response (status %s)", response.status_code)

+ 11 - 0
backend/app/services/log_health.py

@@ -123,6 +123,17 @@ SIGNATURES: tuple[LogSignature, ...] = (
         logger_prefix="backend.app.services.camera",
         logger_prefix="backend.app.services.camera",
         min_count=3,
         min_count=3,
     ),
     ),
+    LogSignature(
+        # Bambu's anti-abuse layer is challenging this network with a CAPTCHA,
+        # so no Bambu Cloud sign-in can complete. Nothing in the install is
+        # broken and no credential will help — see bambu_cloud.is_captcha_challenge.
+        id="bambu-cloud-captcha",
+        patterns=_compile(r"challenging this network with a CAPTCHA"),
+        severity="warning",
+        category="environment",
+        wiki_anchor="bambu-cloud-captcha",
+        logger_prefix="backend.app.services.bambu_cloud",
+    ),
     LogSignature(
     LogSignature(
         # SQLite write contention. Surfaces inside exception tracebacks; folded
         # SQLite write contention. Surfaces inside exception tracebacks; folded
         # continuation lines are part of the entry message, so this still
         # continuation lines are part of the entry message, so this still

+ 18 - 12
backend/app/services/makerworld.py

@@ -28,7 +28,7 @@ from urllib.parse import urlparse
 import certifi
 import certifi
 import httpx
 import httpx
 
 
-from backend.app.services.bambu_cloud import is_expiry_401
+from backend.app.services.bambu_cloud import is_captcha_challenge, is_expiry_401
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -331,18 +331,24 @@ class MakerWorldService:
         if response.status_code == 404:
         if response.status_code == 404:
             raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}")
             raise MakerWorldNotFoundError(f"MakerWorld resource not found: {path}")
         if response.status_code == 418:
         if response.status_code == 418:
-            # MakerWorld's anti-abuse layer challenges the source IP with a
-            # CAPTCHA (``{"captchaId":"...","error":"We need to confirm..."}``).
-            # This is application-level, not Cloudflare-edge, and clears
-            # on its own within 1–4 hours of quiet traffic. There's no
-            # server-side solve — CAPTCHAs are intentionally unsolvable
-            # without a real browser. Surface the upstream message so the
-            # user can recognise it and reach for the "Open on MakerWorld"
-            # fallback instead of thinking the feature is broken.
-            upstream = _extract_upstream_error(response)
-            if upstream and "robot" in upstream.lower():
+            # Bambu's anti-abuse layer challenges the source IP with a CAPTCHA
+            # (``{"captchaId":"...","error":"We need to confirm..."}``). This is
+            # application-level, not Cloudflare-edge, and clears on its own
+            # within 1–4 hours of quiet traffic. There's no server-side solve —
+            # CAPTCHAs are intentionally unsolvable without a real browser.
+            # Surface the upstream message so the user can recognise it and
+            # reach for the "Open on MakerWorld" fallback instead of thinking
+            # the feature is broken.
+            #
+            # The same challenge also lands on the Bambu Cloud sign-in endpoint,
+            # so the shape test lives in ``bambu_cloud`` and is shared (#2790).
+            # It used to be a bare "robot" substring check on the error text,
+            # which missed a challenge worded any other way.
+            if is_captcha_challenge(response):
+                upstream = _extract_upstream_error(response)
+                detail = f" ({upstream})" if upstream else ""
                 raise MakerWorldUnavailableError(
                 raise MakerWorldUnavailableError(
-                    f"MakerWorld is challenging this IP with a CAPTCHA ({upstream}). "
+                    f"MakerWorld is challenging this IP with a CAPTCHA{detail}. "
                     "This usually clears within a few hours. In the meantime, use "
                     "This usually clears within a few hours. In the meantime, use "
                     "'Open on MakerWorld' below to download the 3MF manually."
                     "'Open on MakerWorld' below to download the 3MF manually."
                 )
                 )

+ 242 - 0
backend/tests/unit/test_cloud_captcha_2790.py

@@ -0,0 +1,242 @@
+"""Tests for Bambu's anti-abuse CAPTCHA challenge on sign-in (#2790).
+
+Bambu's own anti-abuse layer -- not the Cloudflare edge -- answers a request it
+has flagged with ``HTTP 418`` and ``{"captchaId": ..., "error": "We need you to
+confirm you are not a robot"}``. It is keyed to the source IP, no credential
+will be accepted until it clears, and there is no server-side solve.
+
+That body is well-formed JSON, so the Cloudflare detector never fired on it and
+``login_request`` fell through to its generic error path, which lifted Bambu's
+sentence out of ``error`` and returned it verbatim. The reporter got a bare
+toast reading "We need you to confirm you are not a robot" -- no challenge to
+answer, no explanation, nothing to click -- and filed it as a Bambuddy bug.
+
+These tests pin: the challenge is recognised by shape rather than by wording,
+all three sign-in calls report it as ``reason="captcha"`` with an explanation
+instead of Bambu's raw string, retries are held back per-origin so Bambuddy
+stops deepening the block, and the scanner names it in the next support bundle.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+import pytest
+
+from backend.app.services import bambu_cloud as bc
+from backend.app.services.bambu_cloud import BambuCloudService
+
+# Bambu's actual challenge body, as seen on both the login endpoint and the
+# design-service endpoints MakerWorld imports use.
+_CAPTCHA_BODY = {
+    "captchaId": "3f2a9c1e64b04d7f",
+    "error": "We need you to confirm you are not a robot",
+}
+
+
+@pytest.fixture(autouse=True)
+def _clear_captcha_cooloff():
+    """The cool-off map is module-level; don't leak it across tests."""
+    bc._captcha_blocked_until.clear()
+    yield
+    bc._captcha_blocked_until.clear()
+
+
+def _response(status_code: int, body: object | None = None, *, text: str | None = None):
+    resp = MagicMock()
+    resp.status_code = status_code
+    if body is None and text is not None:
+        resp.json = MagicMock(side_effect=ValueError("not json"))
+    else:
+        resp.json = MagicMock(return_value=body if body is not None else {})
+    resp.text = text if text is not None else "{}"
+    return resp
+
+
+def _service(response) -> BambuCloudService:
+    svc = BambuCloudService(client=MagicMock(spec=httpx.AsyncClient))
+    svc._client.post = AsyncMock(return_value=response)
+    svc._client.get = AsyncMock(return_value=response)
+    return svc
+
+
+class TestChallengeIsRecognisedByShape:
+    def test_captcha_id_marks_the_challenge(self):
+        assert bc.is_captcha_challenge(_response(418, _CAPTCHA_BODY)) is True
+
+    def test_wording_alone_is_enough(self):
+        """No captchaId, but the text says what it is. Bambu has shipped the
+        challenge under more than one body shape."""
+        assert bc.is_captcha_challenge(_response(418, {"error": "please confirm you are not a robot"})) is True
+
+    def test_a_418_without_a_marker_is_not_reported_as_a_captcha(self):
+        """Telling a user to solve a CAPTCHA that was never offered is the exact
+        confusion this issue is about -- don't invent one for any stray 418."""
+        assert bc.is_captcha_challenge(_response(418, {"error": "Too many requests"})) is False
+
+    def test_status_alone_does_not_decide_it(self):
+        """A captchaId on a 200 is not a refusal -- only the 418 is."""
+        assert bc.is_captcha_challenge(_response(200, _CAPTCHA_BODY)) is False
+
+    def test_a_non_json_challenge_is_still_recognised(self):
+        resp = _response(418, None, text="<html><body>captcha required</body></html>")
+        assert bc.is_captcha_challenge(resp) is True
+
+    def test_a_non_json_body_without_markers_is_not(self):
+        resp = _response(418, None, text="<html><body>Service unavailable</body></html>")
+        assert bc.is_captcha_challenge(resp) is False
+
+
+class TestSignInReportsTheChallenge:
+    @pytest.mark.asyncio
+    async def test_login_explains_instead_of_echoing_bambu(self):
+        svc = _service(_response(418, _CAPTCHA_BODY))
+
+        result = await svc.login_request("user@example.com", "pw")
+
+        assert result["success"] is False
+        assert result["needs_verification"] is False
+        assert result["reason"] == "captcha"
+        # The regression in one line: this used to BE Bambu's sentence.
+        assert result["message"] != _CAPTCHA_BODY["error"]
+        assert "CAPTCHA" in result["message"]
+        # The two things the reporter had no way to know.
+        assert "password" in result["message"].lower()
+        assert "access token" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    async def test_email_code_verification_reports_it_too(self):
+        svc = _service(_response(418, _CAPTCHA_BODY))
+
+        result = await svc.verify_code("user@example.com", "123456")
+
+        assert result["reason"] == "captcha"
+        assert result["message"] != _CAPTCHA_BODY["error"]
+
+    @pytest.mark.asyncio
+    async def test_totp_verification_reports_it_too(self):
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")):
+            result = await svc.verify_totp("tfa-key", "123456")
+
+        assert result["reason"] == "captcha"
+        assert result["message"] != _CAPTCHA_BODY["error"]
+
+    @pytest.mark.asyncio
+    async def test_an_ordinary_rejection_is_unchanged(self):
+        """Wrong password still says what Bambu said, and carries no reason --
+        the UI must keep toasting those rather than showing the CAPTCHA panel."""
+        svc = _service(_response(400, {"error": "Login failed"}))
+
+        result = await svc.login_request("user@example.com", "wrong")
+
+        assert result["message"] == "Login failed"
+        assert result.get("reason") is None
+        assert not bc.captcha_cooloff_active(svc.base_url)
+
+
+class TestRetriesAreHeldBack:
+    @pytest.mark.asyncio
+    async def test_a_second_attempt_is_not_sent_to_bambu(self):
+        """The reporter's log shows four attempts in eighteen seconds. Every one
+        of them is more evidence for the thing that flagged us."""
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        await svc.login_request("user@example.com", "pw")
+        assert svc._client.post.await_count == 1
+
+        result = await svc.login_request("user@example.com", "pw")
+
+        assert svc._client.post.await_count == 1
+        assert result["reason"] == "captcha"
+
+    @pytest.mark.asyncio
+    async def test_the_cooloff_covers_a_fresh_service_instance(self):
+        """Services are built per request, so the cool-off has to outlive one."""
+        await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw")
+
+        second = _service(_response(200, {"loginType": "verifyCode"}))
+        result = await second.login_request("user@example.com", "pw")
+
+        second._client.post.assert_not_awaited()
+        assert result["reason"] == "captcha"
+
+    @pytest.mark.asyncio
+    async def test_the_cooloff_expires(self):
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        await svc.login_request("user@example.com", "pw")
+
+        bc._captcha_blocked_until[svc.base_url] = bc.time.monotonic() - 1
+        svc._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"}))
+        result = await svc.login_request("user@example.com", "pw")
+
+        assert result["needs_verification"] is True
+        assert bc._captcha_blocked_until == {}, "the expired entry should be dropped on the way past"
+
+    @pytest.mark.asyncio
+    async def test_a_challenge_on_the_api_host_does_not_strand_a_totp_sign_in(self):
+        """TOTP verification goes to bambulab.com, everything else to
+        api.bambulab.com. Blocking one on the other's behalf would leave a user
+        halfway through two-factor with no way forward."""
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        await svc.login_request("user@example.com", "pw")
+
+        svc._client.post = AsyncMock(return_value=_response(200, {"accessToken": "tok"}))
+        with patch.object(svc, "_fetch_csrf_token", AsyncMock(return_value="csrf-token")):
+            result = await svc.verify_totp("tfa-key", "123456")
+
+        assert result["success"] is True
+
+    @pytest.mark.asyncio
+    async def test_the_china_region_is_tracked_separately(self):
+        """The block lives at the edge in front of one region."""
+        await _service(_response(418, _CAPTCHA_BODY)).login_request("user@example.com", "pw")
+
+        cn = BambuCloudService(region="china", client=MagicMock(spec=httpx.AsyncClient))
+        cn._client.post = AsyncMock(return_value=_response(200, {"loginType": "verifyCode"}))
+        result = await cn.login_request("user@example.com", "pw")
+
+        cn._client.post.assert_awaited_once()
+        assert result["needs_verification"] is True
+
+
+class TestMakerWorldSharesTheDetector:
+    @pytest.mark.asyncio
+    async def test_a_challenge_worded_differently_is_still_named(self):
+        """MakerWorld used to require the literal word "robot" in the error text
+        and reported anything else as an unexplained block."""
+        from backend.app.services.makerworld import MakerWorldService, MakerWorldUnavailableError
+
+        svc = MakerWorldService(client=MagicMock(spec=httpx.AsyncClient), auth_token="tok")
+        svc._client.get = AsyncMock(return_value=_response(418, {"captchaId": "abc", "error": "verification required"}))
+
+        with pytest.raises(MakerWorldUnavailableError) as exc:
+            await svc._get_json("/design/1")
+
+        assert "CAPTCHA" in str(exc.value)
+        assert "Open on MakerWorld" in str(exc.value)
+
+
+class TestTheSupportBundleNamesIt:
+    def test_the_warning_we_log_matches_the_signature(self, tmp_path, monkeypatch, caplog):
+        """The reporter's bundle came back with zero log-health findings while
+        the log was full of the failure -- tie the two ends together."""
+        from backend.app.core.config import settings as app_settings
+        from backend.app.services.log_health import scan_logs
+
+        svc = _service(_response(418, _CAPTCHA_BODY))
+        with caplog.at_level("WARNING", logger="backend.app.services.bambu_cloud"):
+            svc._note_captcha(_response(418, _CAPTCHA_BODY))
+        logged = caplog.records[-1].getMessage()
+
+        log_file = tmp_path / "bambuddy.log"
+        log_file.write_text(
+            f"2026-08-08 05:15:37,068 WARNING [backend.app.services.bambu_cloud] {logged}\n",
+            encoding="utf-8",
+        )
+        monkeypatch.setattr(app_settings, "log_dir", tmp_path)
+
+        findings = scan_logs().findings
+
+        assert [f.signature_id for f in findings] == ["bambu-cloud-captcha"]
+        assert findings[0].wiki_anchor == "bambu-cloud-captcha"

+ 78 - 0
frontend/src/__tests__/pages/CloudLoginCaptcha.test.tsx

@@ -0,0 +1,78 @@
+/**
+ * Bambu Cloud sign-in when Bambu is challenging the network with a CAPTCHA (#2790).
+ *
+ * The backend answers `reason: 'captcha'`, meaning no credential will be
+ * accepted until the challenge clears and there is nothing in Bambuddy that can
+ * answer it. A toast is the wrong shape for that: it names a problem the user
+ * cannot act on and then disappears. The reporter saw Bambu's own sentence,
+ * "We need you to confirm you are not a robot", flash by with no challenge
+ * behind it and filed it as a bug.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useTranslation } from 'react-i18next';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { LoginForm } from '../../pages/ProfilesPage';
+import { server } from '../mocks/server';
+
+function Harness() {
+  const { t } = useTranslation();
+  return <LoginForm onSuccess={() => {}} t={t} />;
+}
+
+async function submitCredentials() {
+  const user = userEvent.setup();
+  await user.type(await screen.findByPlaceholderText('your@email.com'), 'user@example.com');
+  await user.type(screen.getByPlaceholderText('••••••••'), 'hunter2');
+  await user.click(screen.getByRole('button', { name: /login/i }));
+  return user;
+}
+
+describe('Bambu Cloud sign-in blocked by a CAPTCHA', () => {
+  it('explains the challenge in place and offers the token route', async () => {
+    server.use(
+      http.post('/api/v1/cloud/login', () =>
+        HttpResponse.json({
+          success: false,
+          needs_verification: false,
+          reason: 'captcha',
+          message: 'We need you to confirm you are not a robot',
+        }),
+      ),
+    );
+
+    render(<Harness />);
+    const user = await submitCredentials();
+
+    const panel = await screen.findByRole('alert');
+    expect(panel).toHaveTextContent(/Bambu Cloud is asking for a CAPTCHA/i);
+    // The two things the reporter had no way to find out.
+    expect(panel).toHaveTextContent(/email and password are not the problem/i);
+    expect(panel).toHaveTextContent(/clears by itself within a few hours/i);
+
+    // Bambu's raw sentence is never what the user is left holding.
+    expect(screen.queryByText('We need you to confirm you are not a robot')).not.toBeInTheDocument();
+
+    // The one action that does work is one click away.
+    await user.click(within(panel).getByRole('button', { name: /use access token instead/i }));
+    expect(await screen.findByPlaceholderText('eyJ...')).toBeInTheDocument();
+    expect(screen.queryByText(/Bambu Cloud is asking for a CAPTCHA/i)).not.toBeInTheDocument();
+  });
+
+  it('leaves an ordinary rejection as a toast', async () => {
+    server.use(
+      http.post('/api/v1/cloud/login', () =>
+        HttpResponse.json({ success: false, needs_verification: false, message: 'Login failed' }),
+      ),
+    );
+
+    render(<Harness />);
+    await submitCredentials();
+
+    await waitFor(() => expect(screen.getByText('Login failed')).toBeInTheDocument());
+    expect(screen.queryByText(/Bambu Cloud is asking for a CAPTCHA/i)).not.toBeInTheDocument();
+  });
+});

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

@@ -1401,6 +1401,12 @@ export interface CloudLoginResponse {
   message: string;
   message: string;
   verification_type?: 'email' | 'totp' | null;
   verification_type?: 'email' | 'totp' | null;
   tfa_key?: string | null;
   tfa_key?: string | null;
+  /**
+   * Machine-readable cause of a failure. 'captcha' means Bambu's anti-abuse
+   * layer is challenging this network and no credential will be accepted until
+   * it clears — the UI must explain that in place rather than toast `message`.
+   */
+  reason?: 'captcha' | string | null;
 }
 }
 
 
 // Orca Cloud types — paste-flow PKCE handshake against auth.orcaslicer.com.
 // Orca Cloud types — paste-flow PKCE handshake against auth.orcaslicer.com.

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

@@ -3487,6 +3487,8 @@ export default {
       verifyButton: 'Bestätigen',
       verifyButton: 'Bestätigen',
       setTokenButton: 'Token setzen',
       setTokenButton: 'Token setzen',
       useToken: 'Stattdessen Zugriffstoken verwenden',
       useToken: 'Stattdessen Zugriffstoken verwenden',
+      captchaTitle: 'Bambu Cloud verlangt ein CAPTCHA',
+      captchaBody: 'Bambu fordert für dein Netzwerk eine CAPTCHA-Prüfung, bevor eine Anmeldung akzeptiert wird, und diese Prüfung lässt sich aus Bambuddy heraus nicht beantworten. E-Mail und Passwort sind nicht das Problem. Die Sperre hängt an deiner öffentlichen IP-Adresse und löst sich normalerweise innerhalb weniger Stunden von selbst — wiederholte Versuche verlängern sie. Um dich jetzt anzumelden, verwende stattdessen ein Zugriffstoken aus einer Browser-Sitzung.',
       useEmail: 'Stattdessen mit E-Mail anmelden',
       useEmail: 'Stattdessen mit E-Mail anmelden',
       toast: {
       toast: {
         loggedIn: 'Erfolgreich angemeldet',
         loggedIn: 'Erfolgreich angemeldet',
@@ -6683,8 +6685,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: 'Sicherer Dateiübertragungs-Handshake fehlgeschlagen',
         name: 'Sicherer Dateiübertragungs-Handshake fehlgeschlagen',
-        cause: 'Der TLS-Handshake mit dem Dateiübertragungs-Server des Druckers ist fehlgeschlagen. Häufig liegt das an einer Firewall oder veralteter Drucker-Firmware.',
-        fix: 'Aktualisiere die Drucker-Firmware und prüfe, dass keine Firewall oder Proxy die Verbindung auf Port 990 abfängt.',
+        cause: 'Der Dateidienst des Druckers antwortet auf Port 990 ohne TLS. Sein Dateiserver hat sich aufgehängt — ein Fehler im Drucker, kein Firewall- oder Firmware-Problem.',
+        fix: 'Starte den Drucker neu. Bis dahin lassen sich Druckdateien, Vorschaubilder und Timelapses nicht abrufen; das Drucken selbst ist nicht betroffen.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud verlangt ein CAPTCHA',
+        cause: 'Der Missbrauchsschutz von Bambu prüft dieses Netzwerk, daher kann keine Bambu-Cloud-Anmeldung abgeschlossen werden. Das hängt an der öffentlichen IP-Adresse, nicht an deinem Konto oder dieser Installation.',
+        fix: 'Warte ab — normalerweise löst es sich innerhalb weniger Stunden, wiederholte Anmeldeversuche verlängern es. Zwischenzeitlich kannst du dich mit einem Zugriffstoken aus einer Browser-Sitzung anmelden.',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: 'Druckerverbindung bricht ständig ab',
         name: 'Druckerverbindung bricht ständig ab',

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

@@ -3516,6 +3516,8 @@ export default {
       verifyButton: 'Verify',
       verifyButton: 'Verify',
       setTokenButton: 'Set Token',
       setTokenButton: 'Set Token',
       useToken: 'Use access token instead',
       useToken: 'Use access token instead',
+      captchaTitle: 'Bambu Cloud is asking for a CAPTCHA',
+      captchaBody: 'Bambu is challenging your network before it will accept a sign-in, and the challenge cannot be answered from Bambuddy. Your email and password are not the problem. The block is tied to your public IP address and normally clears by itself within a few hours — retrying repeatedly makes it last longer. To sign in now, use an access token from a browser session instead.',
       useEmail: 'Login with email instead',
       useEmail: 'Login with email instead',
       toast: {
       toast: {
         loggedIn: 'Logged in successfully',
         loggedIn: 'Logged in successfully',
@@ -6732,8 +6734,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: 'Secure file-transfer handshake failed',
         name: 'Secure file-transfer handshake failed',
-        cause: 'The TLS handshake with the printer\'s file-transfer server failed. This is often a firewall or outdated printer firmware.',
-        fix: 'Update the printer firmware and check that no firewall or proxy intercepts the connection on port 990.',
+        cause: 'The printer\'s file service answered port 990 without TLS. Its file server has wedged — a printer-side fault, not a firewall or firmware problem.',
+        fix: 'Restart the printer. Until then print files, covers and timelapses cannot be fetched; printing itself is unaffected.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud is asking for a CAPTCHA',
+        cause: 'Bambu\'s anti-abuse layer is challenging this network, so no Bambu Cloud sign-in can complete. It is tied to the public IP address, not to your account or this installation.',
+        fix: 'Wait — it normally clears within a few hours, and repeated sign-in attempts prolong it. To connect meanwhile, sign in with an access token taken from a browser session.',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: 'Printer connection keeps dropping',
         name: 'Printer connection keeps dropping',

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

@@ -3490,6 +3490,8 @@ export default {
       verifyButton: 'Verificar',
       verifyButton: 'Verificar',
       setTokenButton: 'Establecer token',
       setTokenButton: 'Establecer token',
       useToken: 'Usar token de acceso en su lugar',
       useToken: 'Usar token de acceso en su lugar',
+      captchaTitle: 'Bambu Cloud solicita un CAPTCHA',
+      captchaBody: 'Bambu está exigiendo un CAPTCHA a tu red antes de aceptar un inicio de sesión, y ese desafío no se puede responder desde Bambuddy. Tu correo y tu contraseña no son el problema. El bloqueo está ligado a tu dirección IP pública y suele desaparecer solo en unas horas; reintentar repetidamente lo prolonga. Para entrar ahora, usa un token de acceso obtenido en una sesión del navegador.',
       useEmail: 'Iniciar sesión con correo en su lugar',
       useEmail: 'Iniciar sesión con correo en su lugar',
       toast: {
       toast: {
         loggedIn: 'Sesión iniciada correctamente',
         loggedIn: 'Sesión iniciada correctamente',
@@ -6692,8 +6694,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: 'Falló el protocolo de enlace seguro de transferencia de archivos',
         name: 'Falló el protocolo de enlace seguro de transferencia de archivos',
-        cause: 'El protocolo de enlace TLS con el servidor de transferencia de archivos de la impresora falló. Suele deberse a un cortafuegos o a un firmware de impresora desactualizado.',
-        fix: 'Actualiza el firmware de la impresora y comprueba que ningún cortafuegos o proxy intercepte la conexión en el puerto 990.',
+        cause: 'El servicio de archivos de la impresora respondió en el puerto 990 sin TLS. Su servidor de archivos se ha bloqueado: es un fallo de la impresora, no del cortafuegos ni del firmware.',
+        fix: 'Reinicia la impresora. Hasta entonces no se pueden descargar archivos de impresión, portadas ni timelapses; la impresión en sí no se ve afectada.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud solicita un CAPTCHA',
+        cause: 'La protección antiabuso de Bambu está verificando esta red, por lo que ningún inicio de sesión en Bambu Cloud puede completarse. Depende de la dirección IP pública, no de tu cuenta ni de esta instalación.',
+        fix: 'Espera: suele resolverse en unas horas y los intentos repetidos lo prolongan. Mientras tanto, conéctate con un token de acceso obtenido en una sesión del navegador.',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: 'La conexión con la impresora se cae continuamente',
         name: 'La conexión con la impresora se cae continuamente',

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

@@ -3476,6 +3476,8 @@ export default {
       verifyButton: 'Vérifier',
       verifyButton: 'Vérifier',
       setTokenButton: 'Définir Jeton',
       setTokenButton: 'Définir Jeton',
       useToken: 'Utiliser jeton d\'accès',
       useToken: 'Utiliser jeton d\'accès',
+      captchaTitle: 'Bambu Cloud demande un CAPTCHA',
+      captchaBody: 'Bambu impose un CAPTCHA à votre réseau avant d\'accepter une connexion, et ce défi ne peut pas être résolu depuis Bambuddy. Votre e-mail et votre mot de passe ne sont pas en cause. Le blocage est lié à votre adresse IP publique et disparaît généralement de lui-même en quelques heures ; réessayer sans cesse le prolonge. Pour vous connecter maintenant, utilisez plutôt un jeton d\'accès issu d\'une session de navigateur.',
       useEmail: 'Connexion par email',
       useEmail: 'Connexion par email',
       toast: {
       toast: {
         loggedIn: 'Connecté avec succès',
         loggedIn: 'Connecté avec succès',
@@ -6673,8 +6675,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: 'Échec de la négociation sécurisée du transfert de fichiers',
         name: 'Échec de la négociation sécurisée du transfert de fichiers',
-        cause: 'La négociation TLS avec le serveur de transfert de fichiers de l\'imprimante a échoué. C\'est souvent dû à un pare-feu ou à un micrologiciel d\'imprimante obsolète.',
-        fix: 'Mettez à jour le micrologiciel de l\'imprimante et vérifiez qu\'aucun pare-feu ou proxy n\'intercepte la connexion sur le port 990.',
+        cause: 'Le service de fichiers de l\'imprimante a répondu sur le port 990 sans TLS. Son serveur de fichiers est bloqué : c\'est une panne côté imprimante, pas un problème de pare-feu ou de micrologiciel.',
+        fix: 'Redémarrez l\'imprimante. D\'ici là, les fichiers d\'impression, les vignettes et les timelapses ne peuvent pas être récupérés ; l\'impression elle-même n\'est pas affectée.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud demande un CAPTCHA',
+        cause: 'La protection anti-abus de Bambu contrôle ce réseau, aucune connexion à Bambu Cloud ne peut donc aboutir. Cela dépend de l\'adresse IP publique, pas de votre compte ni de cette installation.',
+        fix: 'Patientez : cela disparaît généralement en quelques heures, et les tentatives répétées le prolongent. En attendant, connectez-vous avec un jeton d\'accès issu d\'une session de navigateur.',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: 'La connexion à l\'imprimante se coupe sans cesse',
         name: 'La connexion à l\'imprimante se coupe sans cesse',

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

@@ -3475,6 +3475,8 @@ export default {
       verifyButton: 'Verifica',
       verifyButton: 'Verifica',
       setTokenButton: 'Imposta token',
       setTokenButton: 'Imposta token',
       useToken: 'Usa access token invece',
       useToken: 'Usa access token invece',
+      captchaTitle: 'Bambu Cloud richiede un CAPTCHA',
+      captchaBody: 'Bambu sta richiedendo un CAPTCHA alla tua rete prima di accettare un accesso, e la verifica non può essere completata da Bambuddy. Email e password non sono il problema. Il blocco è legato al tuo indirizzo IP pubblico e di solito si risolve da solo entro qualche ora; riprovare di continuo lo prolunga. Per accedere subito, usa invece un token di accesso preso da una sessione del browser.',
       useEmail: 'Accedi con email invece',
       useEmail: 'Accedi con email invece',
       toast: {
       toast: {
         loggedIn: 'Accesso riuscito',
         loggedIn: 'Accesso riuscito',
@@ -6672,8 +6674,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: 'Handshake sicuro del trasferimento file non riuscito',
         name: 'Handshake sicuro del trasferimento file non riuscito',
-        cause: 'L\'handshake TLS con il server di trasferimento file della stampante non è riuscito. Spesso è dovuto a un firewall o a un firmware della stampante obsoleto.',
-        fix: 'Aggiorna il firmware della stampante e verifica che nessun firewall o proxy intercetti la connessione sulla porta 990.',
+        cause: 'Il servizio file della stampante ha risposto sulla porta 990 senza TLS. Il suo server file si è bloccato: è un guasto della stampante, non un problema di firewall o firmware.',
+        fix: 'Riavvia la stampante. Fino ad allora non è possibile scaricare file di stampa, copertine e timelapse; la stampa in sé non è interessata.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud richiede un CAPTCHA',
+        cause: 'Il sistema antiabuso di Bambu sta verificando questa rete, quindi nessun accesso a Bambu Cloud può andare a buon fine. Dipende dall\'indirizzo IP pubblico, non dal tuo account né da questa installazione.',
+        fix: 'Attendi: di solito si risolve entro qualche ora e i tentativi ripetuti lo prolungano. Nel frattempo accedi con un token di accesso preso da una sessione del browser.',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: 'La connessione alla stampante cade di continuo',
         name: 'La connessione alla stampante cade di continuo',

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

@@ -3487,6 +3487,8 @@ export default {
       verifyButton: '認証',
       verifyButton: '認証',
       setTokenButton: 'トークンを設定',
       setTokenButton: 'トークンを設定',
       useToken: 'アクセストークンを使用',
       useToken: 'アクセストークンを使用',
+      captchaTitle: 'Bambu Cloud が CAPTCHA を要求しています',
+      captchaBody: 'Bambu がサインインを受け付ける前にネットワークへ CAPTCHA を要求しており、この認証は Bambuddy からは応答できません。メールアドレスやパスワードの問題ではありません。ブロックはグローバル IP アドレスに紐づいており、通常は数時間で自動的に解除されます。繰り返し再試行すると解除が遅くなります。今すぐサインインするには、ブラウザーのセッションから取得したアクセストークンを使用してください。',
       useEmail: 'メールでログイン',
       useEmail: 'メールでログイン',
       toast: {
       toast: {
         loggedIn: 'ログインしました',
         loggedIn: 'ログインしました',
@@ -6684,8 +6686,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: 'セキュアなファイル転送のハンドシェイクに失敗しました',
         name: 'セキュアなファイル転送のハンドシェイクに失敗しました',
-        cause: 'プリンターのファイル転送サーバーとの TLS ハンドシェイクに失敗しました。多くはファイアウォールか、プリンターのファームウェアが古いことが原因です。',
-        fix: 'プリンターのファームウェアを更新し、ファイアウォールやプロキシがポート 990 の接続を妨げていないか確認してください。',
+        cause: 'プリンターのファイルサービスがポート 990 で TLS を使わずに応答しました。プリンター側のファイルサーバーがハングした状態で、ファイアウォールやファームウェアの問題ではありません。',
+        fix: 'プリンターを再起動してください。それまで印刷ファイル・サムネイル・タイムラプスは取得できませんが、印刷自体には影響ありません。',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud が CAPTCHA を要求しています',
+        cause: 'Bambu の不正利用対策がこのネットワークを検証しているため、Bambu Cloud へのサインインを完了できません。アカウントやこのインストール環境ではなく、グローバル IP アドレスに紐づいた制限です。',
+        fix: '待つのが基本です。通常は数時間で解除され、サインインを繰り返すと長引きます。それまでは、ブラウザーのセッションから取得したアクセストークンでサインインしてください。',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: 'プリンター接続が繰り返し切断されます',
         name: 'プリンター接続が繰り返し切断されます',

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

@@ -3311,6 +3311,8 @@ export default {
       verifyButton: '인증',
       verifyButton: '인증',
       setTokenButton: '토큰 설정',
       setTokenButton: '토큰 설정',
       useToken: '액세스 토큰 대신 사용',
       useToken: '액세스 토큰 대신 사용',
+      captchaTitle: 'Bambu Cloud가 CAPTCHA를 요구합니다',
+      captchaBody: 'Bambu가 로그인을 처리하기 전에 네트워크에 CAPTCHA 확인을 요구하고 있으며, 이 확인은 Bambuddy에서 응답할 수 없습니다. 이메일과 비밀번호의 문제가 아닙니다. 차단은 공용 IP 주소에 연결되어 있으며 보통 몇 시간 안에 저절로 풀립니다. 반복해서 재시도하면 오히려 더 오래 지속됩니다. 지금 로그인하려면 브라우저 세션에서 가져온 액세스 토큰을 사용하세요.',
       useEmail: '이메일로 로그인',
       useEmail: '이메일로 로그인',
       toast: {
       toast: {
         loggedIn: '성공적으로 로그인되었습니다',
         loggedIn: '성공적으로 로그인되었습니다',
@@ -6756,8 +6758,13 @@ export default {
       },
       },
       "ftp-ssl-error": {
       "ftp-ssl-error": {
         name: '보안 파일 전송 핸드셰이크 실패',
         name: '보안 파일 전송 핸드셰이크 실패',
-        cause: '프린터의 파일 전송 서버와의 TLS 핸드셰이크가 실패했습니다. 주로 방화벽이나 오래된 프린터 펌웨어가 원인입니다.',
-        fix: '프린터 펌웨어를 업데이트하고 방화벽이나 프록시가 포트 990에서 연결을 가로채지 않는지 확인하세요.'
+        cause: '프린터의 파일 서비스가 990 포트에서 TLS 없이 응답했습니다. 프린터 쪽 파일 서버가 멈춘 상태이며 방화벽이나 펌웨어 문제가 아닙니다.',
+        fix: '프린터를 재시작하세요. 그때까지 인쇄 파일, 커버 이미지, 타임랩스를 가져올 수 없지만 인쇄 자체에는 영향이 없습니다.'
+      },
+      "bambu-cloud-captcha": {
+        name: 'Bambu Cloud가 CAPTCHA를 요구합니다',
+        cause: 'Bambu의 남용 방지 계층이 이 네트워크를 확인하고 있어 Bambu Cloud 로그인을 완료할 수 없습니다. 계정이나 이 설치 환경이 아니라 공용 IP 주소에 연결된 문제입니다.',
+        fix: '기다리세요. 보통 몇 시간 안에 해제되며 로그인을 반복하면 더 길어집니다. 그동안에는 브라우저 세션에서 가져온 액세스 토큰으로 로그인하세요.'
       },
       },
       "mqtt-connection-flapping": {
       "mqtt-connection-flapping": {
         name: '프린터 연결이 계속 끊김',
         name: '프린터 연결이 계속 끊김',

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

@@ -3475,6 +3475,8 @@ export default {
       verifyButton: 'Verificar',
       verifyButton: 'Verificar',
       setTokenButton: 'Definir Token',
       setTokenButton: 'Definir Token',
       useToken: 'Usar token de acesso em vez disso',
       useToken: 'Usar token de acesso em vez disso',
+      captchaTitle: 'O Bambu Cloud está pedindo um CAPTCHA',
+      captchaBody: 'O Bambu está exigindo um CAPTCHA da sua rede antes de aceitar um login, e esse desafio não pode ser respondido pelo Bambuddy. Seu e-mail e sua senha não são o problema. O bloqueio está ligado ao seu endereço IP público e costuma passar sozinho em algumas horas; tentar de novo repetidamente prolonga o bloqueio. Para entrar agora, use um token de acesso obtido em uma sessão do navegador.',
       useEmail: 'Entrar com email em vez disso',
       useEmail: 'Entrar com email em vez disso',
       toast: {
       toast: {
         loggedIn: 'Conectado com sucesso',
         loggedIn: 'Conectado com sucesso',
@@ -6672,8 +6674,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: 'Falha no handshake seguro de transferência de arquivos',
         name: 'Falha no handshake seguro de transferência de arquivos',
-        cause: 'O handshake TLS com o servidor de transferência de arquivos da impressora falhou. Geralmente é causado por um firewall ou firmware desatualizado da impressora.',
-        fix: 'Atualize o firmware da impressora e verifique se nenhum firewall ou proxy intercepta a conexão na porta 990.',
+        cause: 'O serviço de arquivos da impressora respondeu na porta 990 sem TLS. O servidor de arquivos dela travou: é uma falha da impressora, não do firewall nem do firmware.',
+        fix: 'Reinicie a impressora. Até lá, arquivos de impressão, capas e timelapses não podem ser baixados; a impressão em si não é afetada.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'O Bambu Cloud está pedindo um CAPTCHA',
+        cause: 'A proteção antiabuso do Bambu está verificando esta rede, então nenhum login no Bambu Cloud consegue ser concluído. Isso depende do endereço IP público, não da sua conta nem desta instalação.',
+        fix: 'Aguarde: costuma passar em algumas horas e novas tentativas prolongam o bloqueio. Enquanto isso, conecte-se com um token de acesso obtido em uma sessão do navegador.',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: 'A conexão com a impressora cai repetidamente',
         name: 'A conexão com a impressora cai repetidamente',

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

@@ -3303,6 +3303,8 @@ export default {
       verifyButton: "Подтвердить",
       verifyButton: "Подтвердить",
       setTokenButton: "Сохранить токен",
       setTokenButton: "Сохранить токен",
       useToken: "Использовать токен доступа",
       useToken: "Использовать токен доступа",
+      captchaTitle: "Bambu Cloud требует пройти CAPTCHA",
+      captchaBody: "Bambu требует от вашей сети пройти CAPTCHA, прежде чем принять вход, и ответить на эту проверку из Bambuddy невозможно. Дело не в почте и не в пароле. Блокировка привязана к вашему публичному IP-адресу и обычно снимается сама в течение нескольких часов, а повторные попытки только продлевают её. Чтобы войти сейчас, используйте токен доступа из сессии браузера.",
       useEmail: "Войти по email",
       useEmail: "Войти по email",
       toast: {
       toast: {
         loggedIn: "Вход выполнен",
         loggedIn: "Вход выполнен",
@@ -6313,8 +6315,13 @@ export default {
       },
       },
       "ftp-ssl-error": {
       "ftp-ssl-error": {
         name: "Ошибка защищённого соединения с файловой службой",
         name: "Ошибка защищённого соединения с файловой службой",
-        cause: "Не удалось выполнить TLS-рукопожатие с сервером передачи файлов принтера. Частые причины — межсетевой экран или устаревшая прошивка принтера.",
-        fix: "Обновите прошивку принтера и убедитесь, что межсетевой экран или прокси не перехватывает соединение на порту 990.",
+        cause: "Файловая служба принтера ответила на порту 990 без TLS. Её файловый сервер завис — это неисправность самого принтера, а не межсетевого экрана или прошивки.",
+        fix: "Перезапустите принтер. До этого файлы печати, обложки и таймлапсы получить нельзя, но на саму печать это не влияет.",
+      },
+      "bambu-cloud-captcha": {
+        name: "Bambu Cloud требует пройти CAPTCHA",
+        cause: "Защита Bambu от злоупотреблений проверяет эту сеть, поэтому вход в Bambu Cloud завершить невозможно. Это связано с публичным IP-адресом, а не с вашей учётной записью или этой установкой.",
+        fix: "Подождите: обычно проверка снимается за несколько часов, а повторные попытки входа её продлевают. Пока что войдите с помощью токена доступа из сессии браузера.",
       },
       },
       "mqtt-connection-flapping": {
       "mqtt-connection-flapping": {
         name: "Подключение к принтеру постоянно обрывается",
         name: "Подключение к принтеру постоянно обрывается",

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

@@ -3491,6 +3491,8 @@ export default {
       verifyButton: 'Doğrula',
       verifyButton: 'Doğrula',
       setTokenButton: 'Belirteç Ayarla',
       setTokenButton: 'Belirteç Ayarla',
       useToken: 'Erişim belirteci kullan',
       useToken: 'Erişim belirteci kullan',
+      captchaTitle: 'Bambu Cloud CAPTCHA istiyor',
+      captchaBody: 'Bambu, oturum açmayı kabul etmeden önce ağınızdan CAPTCHA doğrulaması istiyor ve bu doğrulama Bambuddy üzerinden yanıtlanamaz. Sorun e-postanız veya parolanız değil. Engel genel IP adresinize bağlıdır ve genellikle birkaç saat içinde kendiliğinden kalkar; sürekli yeniden denemek süreyi uzatır. Şimdi oturum açmak için tarayıcı oturumundan alınan bir erişim belirteci kullanın.',
       useEmail: 'E-posta ile giriş yap',
       useEmail: 'E-posta ile giriş yap',
       toast: {
       toast: {
         loggedIn: 'Başarıyla giriş yapıldı',
         loggedIn: 'Başarıyla giriş yapıldı',
@@ -6623,8 +6625,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: 'Güvenli dosya aktarım el sıkışması başarısız',
         name: 'Güvenli dosya aktarım el sıkışması başarısız',
-        cause: 'Yazıcının dosya aktarım sunucusuyla TLS el sıkışması başarısız oldu. Bu genellikle bir güvenlik duvarı veya eski yazıcı firmware\'idir.',
-        fix: 'Yazıcı firmware\'ini güncelleyin ve port 990\'daki bağlantıyı hiçbir güvenlik duvarı veya proxy\'nin engellemediğini kontrol edin.',
+        cause: 'Yazıcının dosya hizmeti 990 numaralı bağlantı noktasında TLS olmadan yanıt verdi. Dosya sunucusu takılmış durumda: bu, güvenlik duvarı veya firmware sorunu değil, yazıcı kaynaklı bir arızadır.',
+        fix: 'Yazıcıyı yeniden başlatın. O zamana kadar baskı dosyaları, kapak görselleri ve timelapse videoları alınamaz; baskının kendisi etkilenmez.',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud CAPTCHA istiyor',
+        cause: 'Bambu\'nun kötüye kullanım koruması bu ağı denetliyor, bu yüzden hiçbir Bambu Cloud oturum açma işlemi tamamlanamıyor. Bu, hesabınıza veya bu kuruluma değil, genel IP adresine bağlıdır.',
+        fix: 'Bekleyin: genellikle birkaç saat içinde kalkar ve tekrarlanan denemeler süreyi uzatır. Bu sırada tarayıcı oturumundan alınan bir erişim belirteciyle bağlanın.',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: 'Yazıcı bağlantısı sürekli düşüyor',
         name: 'Yazıcı bağlantısı sürekli düşüyor',

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

@@ -3516,6 +3516,8 @@ export default {
       verifyButton: "Підтвердити",
       verifyButton: "Підтвердити",
       setTokenButton: "Установити токен",
       setTokenButton: "Установити токен",
       useToken: "Натомість використати токен доступу",
       useToken: "Натомість використати токен доступу",
+      captchaTitle: "Bambu Cloud вимагає пройти CAPTCHA",
+      captchaBody: "Bambu вимагає від вашої мережі пройти CAPTCHA, перш ніж прийняти вхід, і відповісти на цю перевірку з Bambuddy неможливо. Річ не в пошті та не в паролі. Блокування прив'язане до вашої публічної IP-адреси і зазвичай зникає саме протягом кількох годин, а повторні спроби лише подовжують його. Щоб увійти зараз, скористайтеся токеном доступу із сеансу браузера.",
       useEmail: "Увійти за допомогою електронної пошти",
       useEmail: "Увійти за допомогою електронної пошти",
       toast: {
       toast: {
         loggedIn: "Успішно ввійшли",
         loggedIn: "Успішно ввійшли",
@@ -6727,8 +6729,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: "Помилка захищеного з’єднання для передавання файлів",
         name: "Помилка захищеного з’єднання для передавання файлів",
-        cause: "Не вдалося виконати TLS-узгодження із сервером передавання файлів принтера. Частою причиною є брандмауер або застаріла прошивка принтера.",
-        fix: "Оновіть прошивку принтера та переконайтеся, що брандмауер або проксі-сервер не перехоплюють з’єднання через порт 990.",
+        cause: "Файлова служба принтера відповіла на порту 990 без TLS. Її файловий сервер завис — це несправність самого принтера, а не брандмауера чи прошивки.",
+        fix: "Перезапустіть принтер. До того часу файли друку, обкладинки та таймлапси отримати неможливо, але на сам друк це не впливає.",
+      },
+      'bambu-cloud-captcha': {
+        name: "Bambu Cloud вимагає пройти CAPTCHA",
+        cause: "Захист Bambu від зловживань перевіряє цю мережу, тож завершити вхід у Bambu Cloud неможливо. Це пов'язано з публічною IP-адресою, а не з вашим обліковим записом чи цією інсталяцією.",
+        fix: "Зачекайте: зазвичай перевірка зникає за кілька годин, а повторні спроби входу її подовжують. Тим часом увійдіть за допомогою токена доступу із сеансу браузера.",
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: "Підключення до принтера постійно падає",
         name: "Підключення до принтера постійно падає",

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

@@ -3475,6 +3475,8 @@ export default {
       verifyButton: '验证',
       verifyButton: '验证',
       setTokenButton: '设置令牌',
       setTokenButton: '设置令牌',
       useToken: '改用访问令牌',
       useToken: '改用访问令牌',
+      captchaTitle: 'Bambu Cloud 要求进行 CAPTCHA 验证',
+      captchaBody: 'Bambu 在接受登录之前要求你的网络通过 CAPTCHA 验证,而该验证无法在 Bambuddy 中完成。这与你的邮箱和密码无关。限制与你的公网 IP 地址绑定,通常几小时后会自动解除;反复重试只会延长限制。若要立即登录,请改用从浏览器会话中获取的访问令牌。',
       useEmail: '改用邮箱登录',
       useEmail: '改用邮箱登录',
       toast: {
       toast: {
         loggedIn: '登录成功',
         loggedIn: '登录成功',
@@ -6671,8 +6673,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: '安全文件传输握手失败',
         name: '安全文件传输握手失败',
-        cause: '与打印机文件传输服务器的 TLS 握手失败。通常是防火墙或打印机固件过旧所致。',
-        fix: '请更新打印机固件,并检查没有防火墙或代理拦截 990 端口上的连接。',
+        cause: '打印机的文件服务在 990 端口上没有使用 TLS 响应。其文件服务器已卡死,这是打印机侧的故障,与防火墙或固件无关。',
+        fix: '请重启打印机。在此之前无法获取打印文件、封面图和延时视频;打印本身不受影响。',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud 要求进行 CAPTCHA 验证',
+        cause: 'Bambu 的防滥用机制正在验证此网络,因此无法完成 Bambu Cloud 登录。这与公网 IP 地址有关,与你的账号或此安装无关。',
+        fix: '请等待:通常几小时后会自动解除,反复登录只会延长时间。在此期间,可使用从浏览器会话中获取的访问令牌登录。',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: '打印机连接反复断开',
         name: '打印机连接反复断开',

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

@@ -3475,6 +3475,8 @@ export default {
       verifyButton: '驗證',
       verifyButton: '驗證',
       setTokenButton: '設定權杖',
       setTokenButton: '設定權杖',
       useToken: '改用存取權杖',
       useToken: '改用存取權杖',
+      captchaTitle: 'Bambu Cloud 要求進行 CAPTCHA 驗證',
+      captchaBody: 'Bambu 在接受登入之前要求你的網路通過 CAPTCHA 驗證,而該驗證無法在 Bambuddy 中完成。這與你的電子郵件和密碼無關。限制與你的公開 IP 位址綁定,通常幾小時後會自動解除;反覆重試只會延長限制。若要立即登入,請改用從瀏覽器工作階段取得的存取權杖。',
       useEmail: '改用信箱登入',
       useEmail: '改用信箱登入',
       toast: {
       toast: {
         loggedIn: '登入成功',
         loggedIn: '登入成功',
@@ -6671,8 +6673,13 @@ export default {
       },
       },
       'ftp-ssl-error': {
       'ftp-ssl-error': {
         name: '安全檔案傳輸交握失敗',
         name: '安全檔案傳輸交握失敗',
-        cause: '與印表機檔案傳輸伺服器的 TLS 交握失敗。通常是防火牆或印表機韌體過舊所致。',
-        fix: '請更新印表機韌體,並檢查沒有防火牆或代理伺服器攔截 990 連接埠上的連線。',
+        cause: '印表機的檔案服務在 990 連接埠上未使用 TLS 回應。其檔案伺服器已卡住,這是印表機端的故障,與防火牆或韌體無關。',
+        fix: '請重新啟動印表機。在此之前無法取得列印檔案、封面圖和縮時影片;列印本身不受影響。',
+      },
+      'bambu-cloud-captcha': {
+        name: 'Bambu Cloud 要求進行 CAPTCHA 驗證',
+        cause: 'Bambu 的防濫用機制正在驗證此網路,因此無法完成 Bambu Cloud 登入。這與公開 IP 位址有關,與你的帳號或此安裝無關。',
+        fix: '請等待:通常幾小時後會自動解除,反覆登入只會延長時間。在此期間,可使用從瀏覽器工作階段取得的存取權杖登入。',
       },
       },
       'mqtt-connection-flapping': {
       'mqtt-connection-flapping': {
         name: '印表機連線反覆中斷',
         name: '印表機連線反覆中斷',

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

@@ -41,6 +41,7 @@ import {
   Minus as MinusIcon,
   Minus as MinusIcon,
   Plus as PlusIcon,
   Plus as PlusIcon,
   HardDrive,
   HardDrive,
+  ShieldAlert,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { formatRelativeTime } from '../utils/date';
 import { formatRelativeTime } from '../utils/date';
@@ -95,7 +96,7 @@ function isUserPreset(settingId: string): boolean {
 // LOGIN FORM
 // LOGIN FORM
 // ============================================================================
 // ============================================================================
 
 
-function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
+export function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   const { showToast } = useToast();
   const { showToast } = useToast();
   const [step, setStep] = useState<LoginStep>('email');
   const [step, setStep] = useState<LoginStep>('email');
   const [email, setEmail] = useState('');
   const [email, setEmail] = useState('');
@@ -105,13 +106,20 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
   const [region, setRegion] = useState('global');
   const [region, setRegion] = useState('global');
   const [verificationType, setVerificationType] = useState<'email' | 'totp' | null>(null);
   const [verificationType, setVerificationType] = useState<'email' | 'totp' | null>(null);
   const [tfaKey, setTfaKey] = useState<string | null>(null);
   const [tfaKey, setTfaKey] = useState<string | null>(null);
+  // Bambu is challenging this network with a CAPTCHA (#2790). A toast is the
+  // wrong shape for it: nothing the user types will help, the remedy is to wait
+  // or switch to a token, and both need to stay on screen while they read.
+  const [captchaBlocked, setCaptchaBlocked] = useState(false);
 
 
   const loginMutation = useMutation({
   const loginMutation = useMutation({
     mutationFn: () => api.cloudLogin(email, password, region),
     mutationFn: () => api.cloudLogin(email, password, region),
     onSuccess: (result) => {
     onSuccess: (result) => {
+      setCaptchaBlocked(result.reason === 'captcha');
       if (result.success) {
       if (result.success) {
         showToast(t('profiles.login.toast.loggedIn'));
         showToast(t('profiles.login.toast.loggedIn'));
         onSuccess();
         onSuccess();
+      } else if (result.reason === 'captcha') {
+        return; // The panel below says everything a toast could, and stays put.
       } else if (result.needs_verification) {
       } else if (result.needs_verification) {
         setVerificationType(result.verification_type || 'email');
         setVerificationType(result.verification_type || 'email');
         setTfaKey(result.tfa_key || null);
         setTfaKey(result.tfa_key || null);
@@ -125,20 +133,27 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
         showToast(result.message, 'error');
         showToast(result.message, 'error');
       }
       }
     },
     },
-    onError: (error: Error) => showToast(error.message, 'error'),
+    onError: (error: Error) => {
+      setCaptchaBlocked(false);
+      showToast(error.message, 'error');
+    },
   });
   });
 
 
   const verifyMutation = useMutation({
   const verifyMutation = useMutation({
     mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined, region),
     mutationFn: () => api.cloudVerify(email, code, tfaKey || undefined, region),
     onSuccess: (result) => {
     onSuccess: (result) => {
+      setCaptchaBlocked(result.reason === 'captcha');
       if (result.success) {
       if (result.success) {
         showToast(t('profiles.login.toast.loggedIn'));
         showToast(t('profiles.login.toast.loggedIn'));
         onSuccess();
         onSuccess();
-      } else {
+      } else if (result.reason !== 'captcha') {
         showToast(result.message, 'error');
         showToast(result.message, 'error');
       }
       }
     },
     },
-    onError: (error: Error) => showToast(error.message, 'error'),
+    onError: (error: Error) => {
+      setCaptchaBlocked(false);
+      showToast(error.message, 'error');
+    },
   });
   });
 
 
   const tokenMutation = useMutation({
   const tokenMutation = useMutation({
@@ -170,6 +185,29 @@ function LoginForm({ onSuccess, t }: { onSuccess: () => void; t: TFunction }) {
           <p className="text-sm text-bambu-gray mt-1">{t('profiles.login.subtitle')}</p>
           <p className="text-sm text-bambu-gray mt-1">{t('profiles.login.subtitle')}</p>
         </div>
         </div>
 
 
+        {captchaBlocked && step !== 'token' && (
+          <div role="alert" className="mb-4 p-3 rounded-lg border border-amber-500/40 bg-amber-500/10">
+            <div className="flex items-start gap-2">
+              <ShieldAlert className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
+              <div>
+                <p className="text-sm font-medium text-amber-300">{t('profiles.login.captchaTitle')}</p>
+                <p className="text-xs text-bambu-gray mt-1">{t('profiles.login.captchaBody')}</p>
+                <button
+                  type="button"
+                  onClick={() => {
+                    setCaptchaBlocked(false);
+                    setStep('token');
+                  }}
+                  className="mt-2 text-xs text-bambu-green hover:underline flex items-center gap-1"
+                >
+                  <Key className="w-3 h-3" />
+                  {t('profiles.login.useToken')}
+                </button>
+              </div>
+            </div>
+          </div>
+        )}
+
         <form onSubmit={handleSubmit} className="space-y-4">
         <form onSubmit={handleSubmit} className="space-y-4">
           {step === 'email' && (
           {step === 'email' && (
             <>
             <>

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 0
static/assets/index-B_-QmIIf.css


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
static/assets/index-C8gzJuHW.js


Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 1
static/assets/index-DJ8Q_OV9.css


+ 2 - 2
static/index.html

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

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels