Parcourir la source

feat(auth): SSO autologin + disable local username/password login (#1589)

  Adds a global local_login_enabled setting plus a per-provider
  is_autologin flag on OIDCProvider so operators who run their own SSO
  enabled, or if the calling admin has no UserOIDCLink — either would
  lock everyone out. App-layer invariant: at most one provider can carry
  is_autologin; setting it on one clears it on every other.

  /auth/advanced-auth/status surfaces both new fields so the LoginPage
  decides UI in one query. The env-var bypass flips the reported
  local_login_enabled back to true so the SPA matches what the route
  will accept.
maziggy il y a 2 mois
Parent
commit
70857af393

+ 11 - 0
.env.example

@@ -55,3 +55,14 @@ LOG_TO_FILE=true
 # In Docker, also bind-mount the host path into the container at the same
 # location (see docker-compose.yml for the matching volume snippet).
 # BAMBUDDY_EXTERNAL_ROOTS=
+
+# Local-login recovery bypass (#1589) — set to "true" / "1" / "yes" to
+# accept username + password credentials on /auth/login (and to allow the
+# /auth/forgot-password flow) even when the in-app setting "Disable local
+# login" is turned on. This is the documented "SSO is broken, let me back
+# in" path for an operator whose only normal sign-in route is via OIDC.
+# /auth/advanced-auth/status also reports local_login_enabled=true while
+# this is set, so the login page shows the credentials form to match.
+# LDAP is governed by its own ldap_enabled toggle and is not affected.
+# Leave unset for normal operation.
+# BAMBUDDY_LOCAL_LOGIN=true

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
CHANGELOG.md


+ 88 - 3
backend/app/api/routes/auth.py

@@ -112,6 +112,19 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
 )
 
 
+# #1589: read at call time, not import time, so tests can monkeypatch os.environ
+# between cases without re-importing the module.
+def _local_login_env_bypass() -> bool:
+    """Return True when ``BAMBUDDY_LOCAL_LOGIN`` env var is set truthy.
+
+    Bypasses the ``local_login_enabled`` DB setting on the local-credentials
+    code path AND the forgot-password endpoint so a server admin can recover
+    an install whose SSO provider is unreachable. Accepted truthy values:
+    ``true``, ``1``, ``yes`` (case-insensitive).
+    """
+    return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
+
+
 def _get_client_ip(request: Request) -> str:
     """Return the real client IP for rate-limiting purposes.
 
@@ -378,6 +391,13 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
     client_ip = _get_client_ip(raw_request)
     await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
 
+    # Initialize `user` up front so every downstream branch can read/write
+    # it without UnboundLocalError. The LDAP success path sets it inside its
+    # own block; the local-credentials and email-credentials paths set it
+    # below. The original code relied on the local-credentials path running
+    # unconditionally to bind `user`; #1589 made that path skippable, so the
+    # init has to live here.
+    user = None
     # Check if LDAP is enabled
     ldap_user = None
     ldap_settings = await _get_ldap_settings(db)
@@ -415,12 +435,30 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
             logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
             ldap_user = None
 
+    # #1589: local username/password gate. LDAP keeps its own switch
+    # (ldap_enabled) and is not affected — a delegated directory has its
+    # own policy and lockouts and is closer to SSO than to local creds.
+    # The env-var BAMBUDDY_LOCAL_LOGIN=true bypasses this gate so a server
+    # admin can recover an install whose SSO provider is unreachable
+    # without editing the DB.
+    from backend.app.models.settings import Settings as _Settings_for_local_login
+
+    local_login_allowed = ldap_user is not None or _local_login_env_bypass()
+    if not local_login_allowed:
+        setting_row = await db.execute(
+            select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+        )
+        row = setting_row.scalar_one_or_none()
+        # Default True when the row is absent — matches AppSettings default
+        # so fresh installs and tests behave like every release before #1589.
+        local_login_allowed = row is None or row.value.lower() == "true"
+
     # Try username-based authentication (skip if already authenticated via LDAP)
-    if not ldap_user:
+    if not ldap_user and local_login_allowed:
         user = await authenticate_user(db, request.username, request.password)
 
     # If username auth failed and advanced auth is enabled, try email-based authentication
-    if not user and not ldap_user:
+    if not user and not ldap_user and local_login_allowed:
         advanced_auth = await is_advanced_auth_enabled(db)
         if advanced_auth:
             user = await authenticate_user_by_email(db, request.username, request.password)
@@ -428,6 +466,11 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
     if not user:
         await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
         await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
+        # Same generic 401 either way — never tell the client whether the
+        # username exists or whether local login was disabled. The Settings
+        # UI and /auth/advanced-auth/status are the channels for that state;
+        # leaking it here would help credential-stuffing distinguish "local
+        # disabled" from "wrong password" across an install fleet.
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Incorrect username or password",
@@ -813,12 +856,39 @@ async def disable_advanced_auth(
 
 @router.get("/advanced-auth/status")
 async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
-    """Get advanced authentication status."""
+    """Get advanced authentication status.
+
+    Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
+    so the LoginPage can decide whether to render the credentials form and
+    whether to redirect unauthenticated visitors directly to an SSO
+    provider, in a single query. ``BAMBUDDY_LOCAL_LOGIN=true`` flips the
+    reported value back to True so the recovery path is visible.
+    """
+    from backend.app.models.oidc_provider import OIDCProvider
+    from backend.app.models.settings import Settings as _Settings_for_local_login
+
     advanced_auth_enabled = await is_advanced_auth_enabled(db)
     smtp_configured = await get_smtp_settings(db) is not None
+
+    setting_row = await db.execute(
+        select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+    )
+    row = setting_row.scalar_one_or_none()
+    db_local_enabled = row is None or row.value.lower() == "true"
+    local_login_enabled = db_local_enabled or _local_login_env_bypass()
+
+    # Autologin provider must be both flagged AND enabled — disabling a
+    # provider should not silently keep redirecting visitors to it.
+    autologin = await db.execute(
+        select(OIDCProvider.id).where(OIDCProvider.is_autologin.is_(True), OIDCProvider.is_enabled.is_(True)).limit(1)
+    )
+    autologin_provider_id = autologin.scalar_one_or_none()
+
     return {
         "advanced_auth_enabled": advanced_auth_enabled,
         "smtp_configured": smtp_configured,
+        "local_login_enabled": local_login_enabled,
+        "autologin_provider_id": autologin_provider_id,
     }
 
 
@@ -884,6 +954,21 @@ async def forgot_password(
     secure link instead of a plaintext temporary password.  The new password is
     set only when the user clicks the link and POSTs to /forgot-password/confirm.
     """
+    # #1589: forgot-password is a local-credentials flow — useless when local
+    # login is disabled (the reset wouldn't grant access anyway). Same gate as
+    # /auth/login, with the same env-var bypass for SSO-broken recovery.
+    if not _local_login_env_bypass():
+        from backend.app.models.settings import Settings as _Settings_for_local_login
+
+        setting_row = await db.execute(
+            select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+        )
+        row = setting_row.scalar_one_or_none()
+        if row is not None and row.value.lower() != "true":
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="Local login is disabled — use SSO instead.",
+            )
     # Check if advanced auth is enabled
     advanced_auth = await is_advanced_auth_enabled(db)
     if not advanced_auth:

+ 17 - 1
backend/app/api/routes/mfa.py

@@ -34,7 +34,7 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Requ
 from fastapi.responses import RedirectResponse
 from jwt import PyJWKClient
 from passlib.context import CryptContext
-from sqlalchemy import delete, select
+from sqlalchemy import delete, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload, undefer
 
@@ -1388,11 +1388,17 @@ async def create_oidc_provider(
         icon_content_type=icon_content_type,
         icon_etag=icon_etag,
         default_group_id=body.default_group_id,
+        is_autologin=body.is_autologin,
     )
     # SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
     # Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
     _enforce_auto_link_safety(provider)
     db.add(provider)
+    # #1589: at most one provider may be the autologin target. When a new one
+    # is created with the flag set, clear it on all others first so the
+    # session still satisfies the invariant after add.
+    if body.is_autologin:
+        await db.execute(update(OIDCProvider).where(OIDCProvider.is_autologin.is_(True)).values(is_autologin=False))
     await db.commit()
     await db.refresh(provider)
     return _build_provider_response(provider)
@@ -1471,6 +1477,16 @@ async def update_oidc_provider(
     # partial updates that each pass schema validation individually but are unsafe together.
     _enforce_auto_link_safety(provider)
 
+    # #1589: at most one provider may be the autologin target. Clear the flag
+    # on every other provider when this one becomes the autologin. Excludes
+    # the current row so SQLAlchemy doesn't fight our in-memory set above.
+    if body.is_autologin is True:
+        await db.execute(
+            update(OIDCProvider)
+            .where(OIDCProvider.id != provider.id, OIDCProvider.is_autologin.is_(True))
+            .values(is_autologin=False)
+        )
+
     await db.commit()
     await db.refresh(provider)
     return _build_provider_response(provider)

+ 32 - 3
backend/app/api/routes/settings.py

@@ -5,10 +5,10 @@ import zipfile
 from datetime import datetime
 from pathlib import Path
 
-from fastapi import APIRouter, Depends, File, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, JSONResponse
 from pydantic import BaseModel, Field
-from sqlalchemy import delete, select
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
@@ -139,6 +139,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "default_nozzle_offset_cali",
             "ldap_enabled",
             "ldap_auto_provision",
+            "local_login_enabled",
         ]:
             settings_dict[setting.key] = setting.value.lower() == "true"
         elif setting.key in [
@@ -204,11 +205,39 @@ async def get_settings(
 async def update_settings(
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
 
+    # Safety refusals on disabling local login (#1589). Two failure modes
+    # would otherwise lock everyone out of the install:
+    #   1. No enabled OIDC provider exists — nobody could authenticate.
+    #   2. The caller has no UserOIDCLink — they would lock themselves out
+    #      even if other admins are linked.
+    # Either case returns HTTP 400 instead of silently saving. The
+    # ``BAMBUDDY_LOCAL_LOGIN=true`` env-var bypass on /auth/login is a
+    # separate recovery path; the refusals here protect the *default*
+    # configuration where the env var is absent.
+    if update_data.get("local_login_enabled") is False:
+        from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+
+        enabled_count = await db.scalar(select(func.count(OIDCProvider.id)).where(OIDCProvider.is_enabled.is_(True)))
+        if not enabled_count:
+            raise HTTPException(
+                status_code=400,
+                detail="Cannot disable local login: no OIDC provider is enabled.",
+            )
+        if current_user is not None:
+            caller_links = await db.scalar(
+                select(func.count(UserOIDCLink.id)).where(UserOIDCLink.user_id == current_user.id)
+            )
+            if not caller_links:
+                raise HTTPException(
+                    status_code=400,
+                    detail="Cannot disable local login: your account has no OIDC link, so you would lock yourself out.",
+                )
+
     # Check if any MQTT settings are being updated
     mqtt_keys = {
         "mqtt_enabled",

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

@@ -3222,6 +3222,13 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
 
+    # Migration: Add is_autologin column to oidc_providers (#1589). Postgres
+    # rejects ``DEFAULT 0`` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
+
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)

+ 7 - 0
backend/app/models/oidc_provider.py

@@ -121,6 +121,13 @@ class OIDCProvider(Base):
     # SHA-256 hex of icon_data, served as the ETag header so clients can
     # revalidate via If-None-Match and receive 304 Not Modified.
     icon_etag: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
+    # When True, the LoginPage redirects unauthenticated visitors straight to
+    # this provider's authorize URL on mount (#1589). At most one provider can
+    # carry this flag at a time; setting it on a new provider clears it on the
+    # previous one. The frontend always falls back to the local form if the
+    # authorize-URL fetch fails or times out, and ``/login?fallback=local``
+    # plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
+    is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
 
     @property
     def has_icon(self) -> bool:

+ 3 - 0
backend/app/schemas/auth.py

@@ -413,6 +413,7 @@ class OIDCProviderCreate(BaseModel):
     require_email_verified: bool = True
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool = False  # #1589 — at most one provider may carry this
 
     @field_validator("issuer_url")
     @classmethod
@@ -469,6 +470,7 @@ class OIDCProviderUpdate(BaseModel):
     require_email_verified: bool | None = None
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool | None = None  # #1589
 
     @field_validator("scopes")
     @classmethod
@@ -515,6 +517,7 @@ class OIDCProviderResponse(BaseModel):
     require_email_verified: bool = True
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool = False  # #1589
     # Set explicitly in the route handler from `icon_content_type is not None`
     # rather than `@computed_field` (project policy) or `icon_data is not None`
     # (would trigger an async lazy-load on the deferred BLOB column).

+ 16 - 0
backend/app/schemas/settings.py

@@ -339,6 +339,21 @@ class AppSettings(BaseModel):
         description="JSON array of 3 fan-speed preset values in % (0-100). Empty = use defaults [50, 75, 100]",
     )
 
+    # Local login (#1589) — when False, /auth/login rejects username+password
+    # credentials with HTTP 403 and the login page hides the credentials form,
+    # leaving only the OIDC SSO provider buttons. LDAP is governed by its own
+    # `ldap_enabled` toggle and is not affected. The env-var
+    # ``BAMBUDDY_LOCAL_LOGIN=true`` bypasses this gate at the route level so a
+    # server admin can recover an install whose SSO provider is unreachable
+    # without editing the DB.
+    local_login_enabled: bool = Field(
+        default=True,
+        description=(
+            "Allow username + password login on /auth/login. Disable when only SSO should be usable. "
+            "BAMBUDDY_LOCAL_LOGIN=true on the server overrides this to keep a recovery path open."
+        ),
+    )
+
     # LDAP authentication (#794)
     ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
     ldap_server_url: str = Field(default="", description="LDAP server URL (e.g., ldap://ldap.example.com:389)")
@@ -423,6 +438,7 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
+    local_login_enabled: bool | None = None
     language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | None = None

+ 204 - 0
backend/tests/integration/test_local_login_gate.py

@@ -0,0 +1,204 @@
+"""Integration tests for the local login gate + autologin (#1589).
+
+Covers the four contracts described on the GitHub issue:
+1. POST /auth/login rejects local credentials when local_login_enabled=false
+   AND the BAMBUDDY_LOCAL_LOGIN env var is not set.
+2. The BAMBUDDY_LOCAL_LOGIN=true env var bypasses the gate (recovery path).
+3. POST /auth/forgot-password is gated by the same flag (with the same bypass).
+4. GET /auth/advanced-auth/status surfaces both new fields so the LoginPage
+   can render the right UI in a single query.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+from backend.app.services.ldap_service import LDAPUserInfo
+
+
+async def _set_setting(db: AsyncSession, key: str, value: str) -> None:
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    row = result.scalar_one_or_none()
+    if row is None:
+        db.add(Settings(key=key, value=value))
+    else:
+        row.value = value
+    await db.commit()
+
+
+async def _enable_auth(async_client: AsyncClient, username: str = "gateadm") -> None:
+    """Set up an auth-enabled install with a known admin so /auth/login is reachable."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": username,
+            "admin_password": "GatePass1!",
+        },
+    )
+
+
+class TestLocalLoginGate:
+    """The `local_login_enabled` setting blocks /auth/login + /auth/forgot-password
+    when the env-var recovery bypass is not in play."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_default_allows_local_credentials(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Default install (setting absent) keeps the pre-#1589 behaviour."""
+        await _enable_auth(async_client, "gatedefault")
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatedefault", "password": "GatePass1!"},
+        )
+        assert response.status_code == 200, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_rejected_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """With local_login_enabled=false and no env bypass, valid creds are
+        rejected with the same generic 401 as bad creds (no UI-stating leak)."""
+        await _enable_auth(async_client, "gatedeny")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatedeny", "password": "GatePass1!"},
+        )
+        assert response.status_code == 401
+        # Same wording as wrong-password 401 — never leaks whether local
+        # login is disabled (would help credential stuffing prioritise).
+        assert "Incorrect username or password" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_var_bypasses_local_disabled_gate(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """BAMBUDDY_LOCAL_LOGIN=true opens the recovery path even when the
+        DB setting forbids local login (SSO-broken admin recovery)."""
+        await _enable_auth(async_client, "gatebypass")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatebypass", "password": "GatePass1!"},
+        )
+        assert response.status_code == 200, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_rejected_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """Forgot-password is a local-credentials flow — useless when local
+        login is off (the reset wouldn't grant access anyway)."""
+        await _enable_auth(async_client, "gatefp")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        response = await async_client.post(
+            "/api/v1/auth/forgot-password",
+            json={"email": "x@example.com"},
+        )
+        assert response.status_code == 403
+        assert "Local login is disabled" in response.json()["detail"]
+
+
+class TestLdapLoginNotAffectedByGate:
+    """LDAP keeps its own ldap_enabled switch and bypasses local_login_enabled
+    entirely. This is the regression suite for the refactor in #1589 — without
+    these tests, an LDAP user could fail to log in when local login is
+    disabled even though the gate is supposed to leave LDAP alone."""
+
+    async def _enable_ldap(self, db: AsyncSession) -> None:
+        for key, value in {
+            "ldap_enabled": "true",
+            "ldap_server_url": "ldaps://ldap.test",
+            "ldap_bind_dn": "cn=svc,dc=test,dc=com",
+            "ldap_bind_password": "x",
+            "ldap_search_base": "dc=test,dc=com",
+            "ldap_user_filter": "(uid={username})",
+            "ldap_security": "ldaps",
+            "ldap_group_mapping": "{}",
+            "ldap_auto_provision": "true",
+            "ldap_default_group": "",
+        }.items():
+            await _set_setting(db, key, value)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ldap_login_succeeds_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """LDAP-authenticated login must still issue a JWT even when the
+        local-login gate is off and no env-var bypass is set. The original
+        cut of #1589 wiped the LDAP-bound `user` variable in this branch."""
+        await _enable_auth(async_client, "ldapseed")
+        await self._enable_ldap(db_session)
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        fake_ldap = LDAPUserInfo(
+            username="ldapuser",
+            email="ldapuser@test.com",
+            display_name="LDAP User",
+            groups=[],
+        )
+        with patch(
+            "backend.app.services.ldap_service.authenticate_ldap_user",
+            return_value=fake_ldap,
+        ):
+            response = await async_client.post(
+                "/api/v1/auth/login",
+                json={"username": "ldapuser", "password": "anything"},
+            )
+
+        assert response.status_code == 200, response.text
+        assert "access_token" in response.json()
+        assert response.json()["user"]["username"] == "ldapuser"
+
+
+class TestAdvancedAuthStatusSurfacesGate:
+    """The /auth/advanced-auth/status endpoint feeds the LoginPage's render
+    decisions in a single query — it must surface both new #1589 fields."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_includes_local_login_and_autologin(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+        response = await async_client.get("/api/v1/auth/advanced-auth/status")
+        assert response.status_code == 200
+        result = response.json()
+        assert "local_login_enabled" in result
+        assert "autologin_provider_id" in result
+        # Default install: local on, no autologin provider.
+        assert result["local_login_enabled"] is True
+        assert result["autologin_provider_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_var_bypass_flips_status_back_to_true(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """When the DB setting is false but the env-var bypass is set, the
+        status reports local_login_enabled=true so the LoginPage shows the
+        credentials form (matching what the route will actually accept)."""
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
+
+        response = await async_client.get("/api/v1/auth/advanced-auth/status")
+        assert response.status_code == 200
+        assert response.json()["local_login_enabled"] is True

+ 1 - 0
backend/tests/unit/test_orphan_auth_cleanup_migration.py

@@ -52,6 +52,7 @@ def _register_all_models():
     # not re-exported from __init__.py.
     from backend.app.models import (  # noqa: F401
         external_link,
+        print_log,
         print_queue,
         project_bom,
         slot_preset,

+ 31 - 14
frontend/src/__tests__/components/CameraTile.test.tsx

@@ -3,6 +3,16 @@ import { act, screen } from '@testing-library/react';
 import { render } from '../utils';
 import { CameraTile } from '../../components/CameraTile';
 
+// The shared render() util mounts AuthProvider, which fires an async
+// /auth/me probe on mount. Each test absorbs that settle with a single
+// `await act(async () => {})` after render so the AuthProvider state
+// update doesn't bleed into the assertion phase as an act() warning.
+async function flushMicrotasks() {
+  await act(async () => {
+    await Promise.resolve();
+  });
+}
+
 describe('CameraTile', () => {
   beforeEach(() => {
     vi.useFakeTimers();
@@ -14,7 +24,7 @@ describe('CameraTile', () => {
     vi.restoreAllMocks();
   });
 
-  it('renders the live stream URL in live mode', () => {
+  it('renders the live stream URL in live mode', async () => {
     render(
       <CameraTile
         printerId={42}
@@ -24,12 +34,13 @@ describe('CameraTile', () => {
         connected
       />,
     );
+    await flushMicrotasks();
     const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
     expect(img.src).toContain('/api/v1/printers/42/camera/stream');
     expect(img.src).toContain('fps=8');
   });
 
-  it('renders the snapshot URL and refreshes on the interval', () => {
+  it('renders the snapshot URL and refreshes on the interval', async () => {
     render(
       <CameraTile
         printerId={7}
@@ -39,10 +50,11 @@ describe('CameraTile', () => {
         connected
       />,
     );
+    await flushMicrotasks();
     const initial = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
     expect(initial).toContain('/api/v1/printers/7/camera/snapshot');
 
-    act(() => {
+    await act(async () => {
       vi.advanceTimersByTime(1500);
     });
     const refreshed = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
@@ -50,7 +62,7 @@ describe('CameraTile', () => {
     expect(refreshed).not.toBe(initial);
   });
 
-  it('shows an offline placeholder when not connected', () => {
+  it('shows an offline placeholder when not connected', async () => {
     render(
       <CameraTile
         printerId={1}
@@ -60,10 +72,11 @@ describe('CameraTile', () => {
         connected={false}
       />,
     );
+    await flushMicrotasks();
     expect(screen.queryByAltText('A1-Offline')).toBeNull();
   });
 
-  it('shows the paused placeholder in paused mode', () => {
+  it('shows the paused placeholder in paused mode', async () => {
     render(
       <CameraTile
         printerId={9}
@@ -73,6 +86,7 @@ describe('CameraTile', () => {
         connected
       />,
     );
+    await flushMicrotasks();
     expect(screen.queryByAltText('H2D-Booth')).toBeNull();
   });
 
@@ -89,17 +103,20 @@ describe('CameraTile', () => {
         connected
       />,
     );
+    await flushMicrotasks();
     fetchMock.mockClear();
 
-    rerender(
-      <CameraTile
-        printerId={11}
-        printerName="X1C-Stop"
-        mode="snapshot"
-        snapshotIntervalMs={5000}
-        connected
-      />,
-    );
+    await act(async () => {
+      rerender(
+        <CameraTile
+          printerId={11}
+          printerName="X1C-Stop"
+          mode="snapshot"
+          snapshotIntervalMs={5000}
+          connected
+        />,
+      );
+    });
 
     const stopCalls = fetchMock.mock.calls.filter(([url]) =>
       String(url).includes('/api/v1/printers/11/camera/stop'),

+ 6 - 1
frontend/src/__tests__/mocks/handlers.ts

@@ -493,7 +493,12 @@ export const handlers = [
     HttpResponse.json({ totp_enabled: false, email_otp_enabled: false, backup_codes_remaining: 0 })
   ),
   http.get('/api/v1/auth/advanced-auth/status', () =>
-    HttpResponse.json({ advanced_auth_enabled: false, smtp_configured: false })
+    HttpResponse.json({
+      advanced_auth_enabled: false,
+      smtp_configured: false,
+      local_login_enabled: true,
+      autologin_provider_id: null,
+    })
   ),
   http.get('/api/v1/auth/ldap/status', () =>
     HttpResponse.json({ ldap_enabled: false, ldap_configured: false })

+ 2 - 0
frontend/src/__tests__/pages/NotificationsPage.test.tsx

@@ -20,6 +20,8 @@ const mockPreferences = {
 const mockAdvancedAuthEnabled = {
   advanced_auth_enabled: true,
   smtp_configured: true,
+  local_login_enabled: true,
+  autologin_provider_id: null,
 };
 
 const mockSettingsWithNotifications = {

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

@@ -1087,6 +1087,10 @@ export interface AppSettings {
   check_updates: boolean;
   check_printer_firmware: boolean;
   include_beta_updates: boolean;
+  // #1589: false hides the local username/password form on the login page;
+  // BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to
+  // true so the env-var recovery path is visible to the SPA.
+  local_login_enabled: boolean;
   language: string;
   notification_language: string;
   // AMS threshold settings
@@ -3191,6 +3195,9 @@ export interface OIDCProvider {
   // includes this field in the response (Pydantic default-False is
   // populated unconditionally in the route handler).
   has_icon: boolean;
+  // #1589: when true, the LoginPage redirects unauthenticated visitors
+  // straight to this provider on mount. At most one provider may carry this.
+  is_autologin: boolean;
 }
 
 export interface OIDCProviderCreate {
@@ -3206,6 +3213,7 @@ export interface OIDCProviderCreate {
   require_email_verified?: boolean;
   icon_url?: string | null;
   default_group_id?: number | null;
+  is_autologin?: boolean;  // #1589
 }
 
 export interface OIDCLink {
@@ -3228,6 +3236,13 @@ export interface TestSMTPResponse {
 export interface AdvancedAuthStatus {
   advanced_auth_enabled: boolean;
   smtp_configured: boolean;
+  // #1589: false hides the username/password form on the LoginPage; the env
+  // var BAMBUDDY_LOCAL_LOGIN=true on the server flips this back to true so
+  // the recovery path remains visible.
+  local_login_enabled: boolean;
+  // #1589: when set, LoginPage redirects to this provider's authorize URL
+  // on mount unless ?fallback=local is in the URL or the redirect times out.
+  autologin_provider_id: number | null;
 }
 
 export interface LDAPStatus {

+ 9 - 0
frontend/src/components/OIDCProviderSettings.tsx

@@ -23,6 +23,7 @@ const EMPTY_FORM: OIDCProviderCreate = {
   require_email_verified: true,
   icon_url: undefined,
   default_group_id: null,
+  is_autologin: false,
 };
 
 // ─── Provider form (create / edit) ───────────────────────────────────────────
@@ -149,6 +150,13 @@ function ProviderForm({
             <p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
           </div>
         </label>
+        <label className="flex items-center gap-3 cursor-pointer w-full">
+          <Toggle checked={form.is_autologin ?? false} onChange={(v) => set('is_autologin', v)} />
+          <div>
+            <p className="text-white text-sm">{t('settings.oidc.form.autologin')}</p>
+            <p className="text-bambu-gray text-xs">{t('settings.oidc.form.autologinDesc')}</p>
+          </div>
+        </label>
       </div>
 
       <div>
@@ -447,6 +455,7 @@ export function OIDCProviderSettings() {
                     require_email_verified: provider.require_email_verified,
                     icon_url: provider.icon_url ?? undefined,
                     default_group_id: provider.default_group_id ?? null,
+                    is_autologin: provider.is_autologin,
                   }}
                   onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
                   onCancel={() => setEditingId(null)}

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

@@ -1865,6 +1865,10 @@ export default {
     checkPrinterFirmware: 'Drucker-Firmware prüfen',
     includeBetaUpdates: 'Beta-Versionen einschließen',
     includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',
+    localLogin: {
+      disable: 'Lokale Benutzername-/Passwort-Anmeldung deaktivieren',
+      disableHint: 'Wenn aktiviert, ist nur die Anmeldung über SSO möglich. LDAP ist davon nicht betroffen. Setzen Sie BAMBUDDY_LOCAL_LOGIN=true auf dem Server, um einen Wiederherstellungsweg offen zu halten.',
+    },
     // Queue
     enableRetry: 'Wiederholung aktivieren',
     // Home Assistant
@@ -2535,6 +2539,8 @@ export default {
         defaultGroup: 'Standardgruppe',
         defaultGroupDesc: 'Gruppe, der automatisch erstellte Benutzer zugewiesen werden. Fallback auf Viewers, wenn nicht gesetzt.',
         defaultGroupViewersFallback: 'Viewers (Standard)',
+        autologin: 'Automatische Anmeldung',
+        autologinDesc: 'Nicht angemeldete Besucher direkt zu diesem Anbieter weiterleiten. Diese Option kann nur für einen Anbieter aktiv sein.',
       },
     },
 
@@ -2683,6 +2689,8 @@ export default {
     signingIn: 'Anmeldung läuft...',
     rememberMe: 'Angemeldet bleiben',
     forgotPassword: 'Passwort vergessen?',
+    autologinFailed: 'Automatische SSO-Anmeldung fehlgeschlagen. Bitte wählen Sie unten einen Anbieter.',
+    localDisabledNotice: 'Lokale Anmeldung ist deaktiviert. Bitte verwenden Sie einen der SSO-Anbieter unten.',
     loginSuccess: 'Erfolgreich angemeldet',
     loginFailed: 'Anmeldung fehlgeschlagen',
     enterCredentials: 'Bitte Benutzername und Passwort eingeben',

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

@@ -1879,6 +1879,10 @@ export default {
     checkPrinterFirmware: 'Check printer firmware',
     includeBetaUpdates: 'Include beta versions',
     includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',
+    localLogin: {
+      disable: 'Disable local username/password login',
+      disableHint: 'When enabled, only SSO providers can sign in. LDAP is unaffected. Set BAMBUDDY_LOCAL_LOGIN=true on the server to keep a recovery path.',
+    },
     // Queue
     enableRetry: 'Enable retry',
     // Home Assistant
@@ -2550,6 +2554,8 @@ export default {
         defaultGroup: 'Default Group',
         defaultGroupDesc: 'Group assigned to auto-created users. Falls back to Viewers if not set.',
         defaultGroupViewersFallback: 'Viewers (default)',
+        autologin: 'Autologin',
+        autologinDesc: 'Redirect unauthenticated visitors straight to this provider. Only one provider can carry this flag.',
       },
     },
 
@@ -2698,6 +2704,8 @@ export default {
     signingIn: 'Logging in...',
     rememberMe: 'Remember Me',
     forgotPassword: 'Forgot your password?',
+    autologinFailed: 'Automatic SSO sign-in failed. Pick a provider below to continue.',
+    localDisabledNotice: 'Local sign-in is disabled. Use one of the SSO providers below.',
     loginSuccess: 'Logged in successfully',
     loginFailed: 'Login failed',
     enterCredentials: 'Please enter username and password',

+ 8 - 0
frontend/src/i18n/locales/es.ts

@@ -1868,6 +1868,10 @@ export default {
     checkPrinterFirmware: 'Comprobar el firmware de la impresora',
     includeBetaUpdates: 'Incluir versiones beta',
     includeBetaUpdatesDesc: 'Notificar sobre versiones beta y preliminares al buscar actualizaciones',
+    localLogin: {
+      disable: 'Deshabilitar el inicio de sesión local con usuario/contraseña',
+      disableHint: 'Cuando se habilita, solo los proveedores SSO pueden iniciar sesión. LDAP no se ve afectado. Defina BAMBUDDY_LOCAL_LOGIN=true en el servidor para mantener una vía de recuperación.',
+    },
     // Queue
     enableRetry: 'Activar reintentos',
     // Home Assistant
@@ -2538,6 +2542,8 @@ export default {
         defaultGroup: 'Grupo predeterminado',
         defaultGroupDesc: 'Grupo asignado a los usuarios creados automáticamente. Si no se establece, se usa Visores como alternativa.',
         defaultGroupViewersFallback: 'Visores (predeterminado)',
+        autologin: 'Inicio automático',
+        autologinDesc: 'Redirigir a los visitantes no autenticados directamente a este proveedor. Solo un proveedor puede llevar esta marca.',
       },
     },
 
@@ -2686,6 +2692,8 @@ export default {
     signingIn: 'Iniciando sesión...',
     rememberMe: 'Recordarme',
     forgotPassword: '¿Olvidó su contraseña?',
+    autologinFailed: 'El inicio de sesión SSO automático falló. Elija un proveedor abajo para continuar.',
+    localDisabledNotice: 'El inicio de sesión local está deshabilitado. Use uno de los proveedores SSO de abajo.',
     loginSuccess: 'Sesión iniciada correctamente',
     loginFailed: 'Error al iniciar sesión',
     enterCredentials: 'Introduzca el nombre de usuario y la contraseña',

+ 8 - 0
frontend/src/i18n/locales/fr.ts

@@ -1821,6 +1821,10 @@ export default {
     checkPrinterFirmware: 'Vérifier le firmware imprimante',
     includeBetaUpdates: 'Inclure les versions bêta',
     includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',
+    localLogin: {
+      disable: 'Désactiver la connexion locale par nom d\'utilisateur/mot de passe',
+      disableHint: 'Quand activée, seuls les fournisseurs SSO peuvent se connecter. LDAP n\'est pas affecté. Définissez BAMBUDDY_LOCAL_LOGIN=true sur le serveur pour conserver une voie de récupération.',
+    },
     // Queue
     enableRetry: 'Activer la rétentative',
     // Home Assistant
@@ -2478,6 +2482,8 @@ export default {
         defaultGroup: 'Groupe par défaut',
         defaultGroupDesc: 'Groupe attribué aux utilisateurs créés automatiquement. Repli sur Viewers si non défini.',
         defaultGroupViewersFallback: 'Viewers (par défaut)',
+        autologin: 'Connexion automatique',
+        autologinDesc: 'Rediriger les visiteurs non authentifiés directement vers ce fournisseur. Un seul fournisseur peut porter cet indicateur.',
       },
     },
 
@@ -2672,6 +2678,8 @@ export default {
     signingIn: 'Connexion...',
     rememberMe: 'Se souvenir de moi',
     forgotPassword: 'Mot de passe oublié ?',
+    autologinFailed: 'La connexion SSO automatique a échoué. Choisissez un fournisseur ci-dessous pour continuer.',
+    localDisabledNotice: 'La connexion locale est désactivée. Utilisez l\'un des fournisseurs SSO ci-dessous.',
     loginSuccess: 'Connecté avec succès',
     loginFailed: 'Échec de connexion',
     enterCredentials: 'Entrez vos identifiants',

+ 8 - 0
frontend/src/i18n/locales/it.ts

@@ -1821,6 +1821,10 @@ export default {
     checkPrinterFirmware: 'Controlla firmware stampante',
     includeBetaUpdates: 'Includi versioni beta',
     includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',
+    localLogin: {
+      disable: 'Disabilita l\'accesso locale con nome utente/password',
+      disableHint: 'Quando attivato, solo i provider SSO possono accedere. LDAP non è interessato. Imposta BAMBUDDY_LOCAL_LOGIN=true sul server per mantenere un percorso di ripristino.',
+    },
     // Queue
     enableRetry: 'Abilita retry',
     // Home Assistant
@@ -2477,6 +2481,8 @@ export default {
         defaultGroup: 'Gruppo predefinito',
         defaultGroupDesc: 'Gruppo assegnato agli utenti creati automaticamente. Ritorno a Viewers se non impostato.',
         defaultGroupViewersFallback: 'Viewers (predefinito)',
+        autologin: 'Accesso automatico',
+        autologinDesc: 'Reindirizza i visitatori non autenticati direttamente a questo provider. Solo un provider può avere questo flag.',
       },
     },
 
@@ -2671,6 +2677,8 @@ export default {
     signingIn: 'Accesso in corso...',
     rememberMe: 'Ricordami',
     forgotPassword: 'Hai dimenticato la password?',
+    autologinFailed: 'Accesso SSO automatico fallito. Scegli un provider qui sotto per continuare.',
+    localDisabledNotice: 'L\'accesso locale è disabilitato. Usa uno dei provider SSO qui sotto.',
     loginSuccess: 'Accesso riuscito',
     loginFailed: 'Accesso fallito',
     enterCredentials: 'Inserisci nome utente e password',

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

@@ -1864,6 +1864,10 @@ export default {
     checkPrinterFirmware: 'プリンターファームウェアの確認',
     includeBetaUpdates: 'ベータ版を含める',
     includeBetaUpdatesDesc: 'アップデート確認時にベータ版およびプレリリース版を通知する',
+    localLogin: {
+      disable: 'ローカルのユーザー名/パスワードログインを無効化',
+      disableHint: '有効にすると、SSOプロバイダーのみでサインインできます。LDAPには影響しません。復旧用のパスを残すには、サーバーで BAMBUDDY_LOCAL_LOGIN=true を設定してください。',
+    },
     // Queue
     enableRetry: 'リトライを有効化',
     // Home Assistant
@@ -2534,6 +2538,8 @@ export default {
         defaultGroup: 'デフォルトグループ',
         defaultGroupDesc: '自動作成ユーザーに割り当てられるグループ。未設定の場合はViewersにフォールバックします。',
         defaultGroupViewersFallback: 'Viewers(デフォルト)',
+        autologin: '自動サインイン',
+        autologinDesc: '未認証の訪問者をこのプロバイダーに直接リダイレクトします。このフラグを付けられるプロバイダーは1つだけです。',
       },
     },
 
@@ -2683,6 +2689,8 @@ export default {
     signingIn: 'ログイン中...',
     rememberMe: 'ログイン状態を保持する',
     forgotPassword: 'パスワードをお忘れですか?',
+    autologinFailed: 'SSOへの自動サインインに失敗しました。下から続行するプロバイダーを選択してください。',
+    localDisabledNotice: 'ローカルサインインは無効化されています。下のSSOプロバイダーをご利用ください。',
     loginSuccess: 'ログインしました',
     loginFailed: 'ログインに失敗しました',
     enterCredentials: 'ユーザー名とパスワードを入力してください',

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

@@ -1765,6 +1765,10 @@ export default {
     checkPrinterFirmware: '프린터 펌웨어 확인',
     includeBetaUpdates: '베타 버전 포함',
     includeBetaUpdatesDesc: '업데이트 확인 시 베타 및 사전 릴리스 버전에 대해 알림',
+    localLogin: {
+      disable: '로컬 사용자명/비밀번호 로그인 비활성화',
+      disableHint: '활성화하면 SSO 공급자로만 로그인할 수 있습니다. LDAP는 영향을 받지 않습니다. 서버에서 BAMBUDDY_LOCAL_LOGIN=true 를 설정하면 복구 경로가 유지됩니다.'
+    },
     enableRetry: '재시도 활성화',
     homeAssistantDescription: 'Home Assistant를 통해 스마트 플러그 제어',
     environmentManagedLabel: '(환경 변수 관리)',
@@ -2384,7 +2388,9 @@ export default {
         requireEmailVerifiedAutoLink: '이 설정을 변경하려면 먼저 자동 연결을 비활성화하세요.',
         defaultGroup: '기본 그룹',
         defaultGroupDesc: '자동 생성된 사용자에게 할당되는 그룹. 설정되지 않으면 Viewers로 대체됩니다.',
-        defaultGroupViewersFallback: 'Viewers (기본값)'
+        defaultGroupViewersFallback: 'Viewers (기본값)',
+        autologin: '자동 로그인',
+        autologinDesc: '인증되지 않은 방문자를 이 공급자로 바로 리디렉션합니다. 이 플래그를 가질 수 있는 공급자는 하나뿐입니다.'
       },
       refreshIcon: '아이콘 새로고침',
       removeIcon: '아이콘 제거',
@@ -2526,6 +2532,8 @@ export default {
     signingIn: '로그인 중...',
     rememberMe: '로그인 유지',
     forgotPassword: '비밀번호를 잊으셨나요?',
+    autologinFailed: 'SSO 자동 로그인에 실패했습니다. 아래에서 계속할 공급자를 선택하세요.',
+    localDisabledNotice: '로컬 로그인이 비활성화되어 있습니다. 아래 SSO 공급자 중 하나를 사용하세요.',
     loginSuccess: '성공적으로 로그인되었습니다',
     loginFailed: '로그인 실패',
     enterCredentials: '사용자명과 비밀번호를 입력하세요',

+ 8 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1821,6 +1821,10 @@ export default {
     checkPrinterFirmware: 'Verificar firmware da impressora',
     includeBetaUpdates: 'Incluir versões beta',
     includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',
+    localLogin: {
+      disable: 'Desativar login local com usuário/senha',
+      disableHint: 'Quando ativado, somente provedores SSO podem fazer login. O LDAP não é afetado. Defina BAMBUDDY_LOCAL_LOGIN=true no servidor para manter um caminho de recuperação.',
+    },
     // Queue
     enableRetry: 'Habilitar tentativa',
     // Home Assistant
@@ -2477,6 +2481,8 @@ export default {
         defaultGroup: 'Grupo padrão',
         defaultGroupDesc: 'Grupo atribuído aos usuários criados automaticamente. Retorna a Viewers se não definido.',
         defaultGroupViewersFallback: 'Viewers (padrão)',
+        autologin: 'Login automático',
+        autologinDesc: 'Redirecionar visitantes não autenticados diretamente para este provedor. Apenas um provedor pode ter esta marcação.',
       },
     },
 
@@ -2671,6 +2677,8 @@ export default {
     signingIn: 'Entrando...',
     rememberMe: 'Lembrar de mim',
     forgotPassword: 'Esqueceu sua senha?',
+    autologinFailed: 'O login SSO automático falhou. Escolha um provedor abaixo para continuar.',
+    localDisabledNotice: 'O login local está desativado. Use um dos provedores SSO abaixo.',
     loginSuccess: 'Login realizado com sucesso',
     loginFailed: 'Falha no login',
     enterCredentials: 'Por favor, insira nome de usuário e senha',

+ 8 - 0
frontend/src/i18n/locales/tr.ts

@@ -1868,6 +1868,10 @@ export default {
     checkPrinterFirmware: 'Yazıcı firmware\'ini kontrol et',
     includeBetaUpdates: 'Beta sürümleri dahil et',
     includeBetaUpdatesDesc: 'Güncellemeleri kontrol ederken beta ve önyayım sürümleri hakkında bildir',
+    localLogin: {
+      disable: 'Yerel kullanıcı adı/şifre ile oturum açmayı devre dışı bırak',
+      disableHint: 'Etkinleştirildiğinde yalnızca SSO sağlayıcıları ile oturum açılabilir. LDAP etkilenmez. Bir kurtarma yolu açık tutmak için sunucuda BAMBUDDY_LOCAL_LOGIN=true ayarlayın.',
+    },
     // Kuyruk
     enableRetry: 'Yeniden denemeyi etkinleştir',
     // Home Assistant
@@ -2538,6 +2542,8 @@ export default {
         defaultGroup: 'Varsayılan Grup',
         defaultGroupDesc: 'Otomatik oluşturulan kullanıcılara atanan grup. Ayarlanmazsa Viewers\'a geri döner.',
         defaultGroupViewersFallback: 'Viewers (varsayılan)',
+        autologin: 'Otomatik oturum açma',
+        autologinDesc: 'Kimlik doğrulaması yapılmamış ziyaretçileri doğrudan bu sağlayıcıya yönlendir. Bu işareti yalnızca bir sağlayıcı taşıyabilir.',
       },
     },
 
@@ -2686,6 +2692,8 @@ export default {
     signingIn: 'Giriş yapılıyor...',
     rememberMe: 'Beni Hatırla',
     forgotPassword: 'Parolanızı mı unuttunuz?',
+    autologinFailed: 'Otomatik SSO girişi başarısız oldu. Devam etmek için aşağıdan bir sağlayıcı seçin.',
+    localDisabledNotice: 'Yerel oturum açma devre dışı. Aşağıdaki SSO sağlayıcılarından birini kullanın.',
     loginSuccess: 'Başarıyla giriş yapıldı',
     loginFailed: 'Giriş başarısız',
     enterCredentials: 'Lütfen kullanıcı adı ve parola girin',

+ 8 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1866,6 +1866,10 @@ export default {
     checkPrinterFirmware: '检查打印机固件',
     includeBetaUpdates: '包含测试版本',
     includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
+    localLogin: {
+      disable: '禁用本地用户名/密码登录',
+      disableHint: '启用后,只能通过SSO提供商登录。LDAP不受影响。在服务器上设置 BAMBUDDY_LOCAL_LOGIN=true 可保留恢复通道。',
+    },
     // Queue
     enableRetry: '启用重试',
     // Home Assistant
@@ -2522,6 +2526,8 @@ export default {
         defaultGroup: '默认组',
         defaultGroupDesc: '自动创建用户时分配的组。未设置时回退到 Viewers。',
         defaultGroupViewersFallback: 'Viewers(默认)',
+        autologin: '自动登录',
+        autologinDesc: '将未认证的访问者直接重定向到该提供商。只有一个提供商可以携带此标志。',
       },
     },
 
@@ -2671,6 +2677,8 @@ export default {
     signingIn: '登录中...',
     rememberMe: '记住我',
     forgotPassword: '忘记密码?',
+    autologinFailed: '自动SSO登录失败。请在下方选择一个提供商以继续。',
+    localDisabledNotice: '本地登录已禁用。请使用下方的SSO提供商之一。',
     loginSuccess: '登录成功',
     loginFailed: '登录失败',
     enterCredentials: '请输入用户名和密码',

+ 8 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1866,6 +1866,10 @@ export default {
     checkPrinterFirmware: '檢查印表機韌體',
     includeBetaUpdates: '包含測試版本',
     includeBetaUpdatesDesc: '檢查更新時通知測試版和預發布版本',
+    localLogin: {
+      disable: '停用本機使用者名稱/密碼登入',
+      disableHint: '啟用後,只能透過SSO提供者登入。LDAP不受影響。在伺服器上設定 BAMBUDDY_LOCAL_LOGIN=true 可保留復原途徑。',
+    },
     // Queue
     enableRetry: '啟用重試',
     // Home Assistant
@@ -2522,6 +2526,8 @@ export default {
         defaultGroup: '預設群組',
         defaultGroupDesc: '自動建立使用者時分配的群組。未設定時回退到 Viewers。',
         defaultGroupViewersFallback: 'Viewers(預設)',
+        autologin: '自動登入',
+        autologinDesc: '將未驗證的訪客直接重新導向至此提供者。此旗標僅能由一個提供者持有。',
       },
     },
 
@@ -2671,6 +2677,8 @@ export default {
     signingIn: '登入中...',
     rememberMe: '記住我',
     forgotPassword: '忘記密碼?',
+    autologinFailed: '自動SSO登入失敗。請在下方選擇一個提供者以繼續。',
+    localDisabledNotice: '本機登入已停用。請使用下方的SSO提供者之一。',
     loginSuccess: '登入成功',
     loginFailed: '登入失敗',
     enterCredentials: '請輸入使用者名稱和密碼',

+ 50 - 0
frontend/src/pages/LoginPage.tsx

@@ -165,6 +165,42 @@ export function LoginPage() {
     queryFn: () => api.getOIDCProviders(),
   });
 
+  // #1589: autologin redirect with fallback. When the backend reports an
+  // `autologin_provider_id`, redirect unauthenticated visitors directly to
+  // that provider's authorize URL on mount — unless the URL carries
+  // `?fallback=local` (the documented recovery path that pairs with the
+  // server-side BAMBUDDY_LOCAL_LOGIN env-var bypass). The authorize-URL
+  // fetch is raced against a 5-second timeout; on timeout or fetch error
+  // we skip the redirect and render the normal page, surfacing a banner
+  // so the user understands why autologin didn't kick in.
+  const [autologinFailed, setAutologinFailed] = useState(false);
+  const autologinAttemptedRef = useRef(false);
+  useEffect(() => {
+    if (autologinAttemptedRef.current) return;
+    const fallbackQuery = searchParams.get('fallback');
+    if (fallbackQuery === 'local') return;
+    if (!advancedAuthStatus || !advancedAuthStatus.autologin_provider_id) return;
+    // Don't redirect mid-OIDC-exchange (we're already coming back from the IdP).
+    const hash = window.location.hash;
+    if (hash.startsWith('#oidc_token=') || searchParams.get('oidc_error')) return;
+    autologinAttemptedRef.current = true;
+
+    const providerId = advancedAuthStatus.autologin_provider_id;
+    const timeoutPromise = new Promise<never>((_resolve, reject) =>
+      setTimeout(() => reject(new Error('autologin timeout')), 5000),
+    );
+    Promise.race([api.getOIDCAuthorizeUrl(providerId), timeoutPromise])
+      .then((result) => {
+        window.location.href = (result as { auth_url: string }).auth_url;
+      })
+      .catch(() => {
+        setAutologinFailed(true);
+      });
+  }, [advancedAuthStatus, searchParams]);
+
+  const localLoginEnabled = advancedAuthStatus?.local_login_enabled !== false;
+  const showAutologinBanner = autologinFailed && advancedAuthStatus?.autologin_provider_id != null;
+
   // M-B: Detect #reset_token=... in the URL fragment and switch to the reset step.
   // Fragments are never sent to the server so the token never appears in access-logs
   // or Referer headers — mirrors the H-4 treatment of the OIDC token.
@@ -667,6 +703,19 @@ export function LoginPage() {
           </p>
         </div>
 
+        {showAutologinBanner && (
+          <div className="mt-6 rounded-lg border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-200">
+            {t('login.autologinFailed')}
+          </div>
+        )}
+
+        {!localLoginEnabled && (
+          <div className="mt-6 rounded-lg border border-bambu-dark-tertiary bg-bambu-dark/40 px-4 py-3 text-sm text-bambu-gray">
+            {t('login.localDisabledNotice')}
+          </div>
+        )}
+
+        {localLoginEnabled && (
         <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
           <div className="space-y-4">
             <div>
@@ -739,6 +788,7 @@ export function LoginPage() {
             </button>
           </div>
         </form>
+        )}
 
         {/* OIDC provider buttons */}
         {oidcProviders && oidcProviders.length > 0 && (

+ 20 - 2
frontend/src/pages/SettingsPage.tsx

@@ -554,7 +554,7 @@ export function SettingsPage() {
   });
 
   // Advanced auth status for user creation
-  const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false } } = useQuery({
+  const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false, local_login_enabled: true, autologin_provider_id: null } } = useQuery({
     queryKey: ['advancedAuthStatus'],
     queryFn: () => api.getAdvancedAuthStatus(),
   });
@@ -934,6 +934,7 @@ export function SettingsPage() {
       settings.check_updates !== localSettings.check_updates ||
       (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
+      (settings.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
       settings.notification_language !== localSettings.notification_language ||
       (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
@@ -1027,6 +1028,7 @@ export function SettingsPage() {
         check_updates: localSettings.check_updates,
         check_printer_firmware: localSettings.check_printer_firmware,
         include_beta_updates: localSettings.include_beta_updates,
+        local_login_enabled: localSettings.local_login_enabled,
         notification_language: localSettings.notification_language,
         bed_cooled_threshold: localSettings.bed_cooled_threshold,
         ams_humidity_good: localSettings.ams_humidity_good,
@@ -5672,7 +5674,23 @@ export function SettingsPage() {
           )}
 
           {usersSubTab === 'oidc' && isAdmin && (
-            <div className="max-w-3xl">
+            <div className="max-w-3xl space-y-4">
+              <Card>
+                <CardContent className="space-y-3 p-4">
+                  <label className="flex items-start gap-3 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.local_login_enabled === false}
+                      onChange={(e) => updateSetting('local_login_enabled', !e.target.checked)}
+                      className="mt-1 h-4 w-4 rounded border-bambu-dark-tertiary bg-bambu-dark-secondary text-bambu-green focus:ring-bambu-green/50 cursor-pointer"
+                    />
+                    <div>
+                      <p className="text-sm font-medium text-white">{t('settings.localLogin.disable')}</p>
+                      <p className="text-xs text-bambu-gray mt-0.5">{t('settings.localLogin.disableHint')}</p>
+                    </div>
+                  </label>
+                </CardContent>
+              </Card>
               <OIDCProviderSettings />
             </div>
           )}

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
static/assets/index-Blhe8AhR.js


+ 1 - 1
static/index.html

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

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff