Просмотр исходного кода

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 2 месяцев назад
Родитель
Сommit
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
 # In Docker, also bind-mount the host path into the container at the same
 # location (see docker-compose.yml for the matching volume snippet).
 # location (see docker-compose.yml for the matching volume snippet).
 # BAMBUDDY_EXTERNAL_ROOTS=
 # 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

Разница между файлами не показана из-за своего большого размера
+ 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:
 def _get_client_ip(request: Request) -> str:
     """Return the real client IP for rate-limiting purposes.
     """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)
     client_ip = _get_client_ip(raw_request)
     await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
     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
     # Check if LDAP is enabled
     ldap_user = None
     ldap_user = None
     ldap_settings = await _get_ldap_settings(db)
     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)
             logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
             ldap_user = None
             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)
     # 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)
         user = await authenticate_user(db, request.username, request.password)
 
 
     # If username auth failed and advanced auth is enabled, try email-based authentication
     # 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)
         advanced_auth = await is_advanced_auth_enabled(db)
         if advanced_auth:
         if advanced_auth:
             user = await authenticate_user_by_email(db, request.username, request.password)
             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:
     if not user:
         await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
         await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
         await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
         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(
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Incorrect username or password",
             detail="Incorrect username or password",
@@ -813,12 +856,39 @@ async def disable_advanced_auth(
 
 
 @router.get("/advanced-auth/status")
 @router.get("/advanced-auth/status")
 async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
 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)
     advanced_auth_enabled = await is_advanced_auth_enabled(db)
     smtp_configured = await get_smtp_settings(db) is not None
     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 {
     return {
         "advanced_auth_enabled": advanced_auth_enabled,
         "advanced_auth_enabled": advanced_auth_enabled,
         "smtp_configured": smtp_configured,
         "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
     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.
     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
     # Check if advanced auth is enabled
     advanced_auth = await is_advanced_auth_enabled(db)
     advanced_auth = await is_advanced_auth_enabled(db)
     if not advanced_auth:
     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 fastapi.responses import RedirectResponse
 from jwt import PyJWKClient
 from jwt import PyJWKClient
 from passlib.context import CryptContext
 from passlib.context import CryptContext
-from sqlalchemy import delete, select
+from sqlalchemy import delete, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload, undefer
 from sqlalchemy.orm import selectinload, undefer
 
 
@@ -1388,11 +1388,17 @@ async def create_oidc_provider(
         icon_content_type=icon_content_type,
         icon_content_type=icon_content_type,
         icon_etag=icon_etag,
         icon_etag=icon_etag,
         default_group_id=body.default_group_id,
         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.
     # 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).
     # Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
     _enforce_auto_link_safety(provider)
     _enforce_auto_link_safety(provider)
     db.add(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.commit()
     await db.refresh(provider)
     await db.refresh(provider)
     return _build_provider_response(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.
     # partial updates that each pass schema validation individually but are unsafe together.
     _enforce_auto_link_safety(provider)
     _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.commit()
     await db.refresh(provider)
     await db.refresh(provider)
     return _build_provider_response(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 datetime import datetime
 from pathlib import Path
 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 fastapi.responses import FileResponse, JSONResponse
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
-from sqlalchemy import delete, select
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
 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",
             "default_nozzle_offset_cali",
             "ldap_enabled",
             "ldap_enabled",
             "ldap_auto_provision",
             "ldap_auto_provision",
+            "local_login_enabled",
         ]:
         ]:
             settings_dict[setting.key] = setting.value.lower() == "true"
             settings_dict[setting.key] = setting.value.lower() == "true"
         elif setting.key in [
         elif setting.key in [
@@ -204,11 +205,39 @@ async def get_settings(
 async def update_settings(
 async def update_settings(
     settings_update: AppSettingsUpdate,
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
 ):
     """Update application settings."""
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
     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
     # Check if any MQTT settings are being updated
     mqtt_keys = {
     mqtt_keys = {
         "mqtt_enabled",
         "mqtt_enabled",

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

@@ -3222,6 +3222,13 @@ async def run_migrations(conn):
     else:
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
         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
     # Migration: Disambiguate the four ``user_print_*`` notification template
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
     await _migrate_rename_user_print_template_names(conn)
     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
     # SHA-256 hex of icon_data, served as the ETag header so clients can
     # revalidate via If-None-Match and receive 304 Not Modified.
     # revalidate via If-None-Match and receive 304 Not Modified.
     icon_etag: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
     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
     @property
     def has_icon(self) -> bool:
     def has_icon(self) -> bool:

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

@@ -413,6 +413,7 @@ class OIDCProviderCreate(BaseModel):
     require_email_verified: bool = True
     require_email_verified: bool = True
     icon_url: str | None = None
     icon_url: str | None = None
     default_group_id: int | None = None
     default_group_id: int | None = None
+    is_autologin: bool = False  # #1589 — at most one provider may carry this
 
 
     @field_validator("issuer_url")
     @field_validator("issuer_url")
     @classmethod
     @classmethod
@@ -469,6 +470,7 @@ class OIDCProviderUpdate(BaseModel):
     require_email_verified: bool | None = None
     require_email_verified: bool | None = None
     icon_url: str | None = None
     icon_url: str | None = None
     default_group_id: int | None = None
     default_group_id: int | None = None
+    is_autologin: bool | None = None  # #1589
 
 
     @field_validator("scopes")
     @field_validator("scopes")
     @classmethod
     @classmethod
@@ -515,6 +517,7 @@ class OIDCProviderResponse(BaseModel):
     require_email_verified: bool = True
     require_email_verified: bool = True
     icon_url: str | None = None
     icon_url: str | None = None
     default_group_id: int | 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`
     # 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`
     # rather than `@computed_field` (project policy) or `icon_data is not None`
     # (would trigger an async lazy-load on the deferred BLOB column).
     # (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]",
         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 authentication (#794)
     ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
     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)")
     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_updates: bool | None = None
     check_printer_firmware: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
     include_beta_updates: bool | None = None
+    local_login_enabled: bool | None = None
     language: str | None = None
     language: str | None = None
     notification_language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | 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.
     # not re-exported from __init__.py.
     from backend.app.models import (  # noqa: F401
     from backend.app.models import (  # noqa: F401
         external_link,
         external_link,
+        print_log,
         print_queue,
         print_queue,
         project_bom,
         project_bom,
         slot_preset,
         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 { render } from '../utils';
 import { CameraTile } from '../../components/CameraTile';
 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', () => {
 describe('CameraTile', () => {
   beforeEach(() => {
   beforeEach(() => {
     vi.useFakeTimers();
     vi.useFakeTimers();
@@ -14,7 +24,7 @@ describe('CameraTile', () => {
     vi.restoreAllMocks();
     vi.restoreAllMocks();
   });
   });
 
 
-  it('renders the live stream URL in live mode', () => {
+  it('renders the live stream URL in live mode', async () => {
     render(
     render(
       <CameraTile
       <CameraTile
         printerId={42}
         printerId={42}
@@ -24,12 +34,13 @@ describe('CameraTile', () => {
         connected
         connected
       />,
       />,
     );
     );
+    await flushMicrotasks();
     const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
     const img = screen.getByAltText('X1C-Lab') as HTMLImageElement;
     expect(img.src).toContain('/api/v1/printers/42/camera/stream');
     expect(img.src).toContain('/api/v1/printers/42/camera/stream');
     expect(img.src).toContain('fps=8');
     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(
     render(
       <CameraTile
       <CameraTile
         printerId={7}
         printerId={7}
@@ -39,10 +50,11 @@ describe('CameraTile', () => {
         connected
         connected
       />,
       />,
     );
     );
+    await flushMicrotasks();
     const initial = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
     const initial = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
     expect(initial).toContain('/api/v1/printers/7/camera/snapshot');
     expect(initial).toContain('/api/v1/printers/7/camera/snapshot');
 
 
-    act(() => {
+    await act(async () => {
       vi.advanceTimersByTime(1500);
       vi.advanceTimersByTime(1500);
     });
     });
     const refreshed = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
     const refreshed = (screen.getByAltText('P1S-Garage') as HTMLImageElement).src;
@@ -50,7 +62,7 @@ describe('CameraTile', () => {
     expect(refreshed).not.toBe(initial);
     expect(refreshed).not.toBe(initial);
   });
   });
 
 
-  it('shows an offline placeholder when not connected', () => {
+  it('shows an offline placeholder when not connected', async () => {
     render(
     render(
       <CameraTile
       <CameraTile
         printerId={1}
         printerId={1}
@@ -60,10 +72,11 @@ describe('CameraTile', () => {
         connected={false}
         connected={false}
       />,
       />,
     );
     );
+    await flushMicrotasks();
     expect(screen.queryByAltText('A1-Offline')).toBeNull();
     expect(screen.queryByAltText('A1-Offline')).toBeNull();
   });
   });
 
 
-  it('shows the paused placeholder in paused mode', () => {
+  it('shows the paused placeholder in paused mode', async () => {
     render(
     render(
       <CameraTile
       <CameraTile
         printerId={9}
         printerId={9}
@@ -73,6 +86,7 @@ describe('CameraTile', () => {
         connected
         connected
       />,
       />,
     );
     );
+    await flushMicrotasks();
     expect(screen.queryByAltText('H2D-Booth')).toBeNull();
     expect(screen.queryByAltText('H2D-Booth')).toBeNull();
   });
   });
 
 
@@ -89,17 +103,20 @@ describe('CameraTile', () => {
         connected
         connected
       />,
       />,
     );
     );
+    await flushMicrotasks();
     fetchMock.mockClear();
     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]) =>
     const stopCalls = fetchMock.mock.calls.filter(([url]) =>
       String(url).includes('/api/v1/printers/11/camera/stop'),
       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 })
     HttpResponse.json({ totp_enabled: false, email_otp_enabled: false, backup_codes_remaining: 0 })
   ),
   ),
   http.get('/api/v1/auth/advanced-auth/status', () =>
   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', () =>
   http.get('/api/v1/auth/ldap/status', () =>
     HttpResponse.json({ ldap_enabled: false, ldap_configured: false })
     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 = {
 const mockAdvancedAuthEnabled = {
   advanced_auth_enabled: true,
   advanced_auth_enabled: true,
   smtp_configured: true,
   smtp_configured: true,
+  local_login_enabled: true,
+  autologin_provider_id: null,
 };
 };
 
 
 const mockSettingsWithNotifications = {
 const mockSettingsWithNotifications = {

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

@@ -1087,6 +1087,10 @@ export interface AppSettings {
   check_updates: boolean;
   check_updates: boolean;
   check_printer_firmware: boolean;
   check_printer_firmware: boolean;
   include_beta_updates: 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;
   language: string;
   notification_language: string;
   notification_language: string;
   // AMS threshold settings
   // AMS threshold settings
@@ -3191,6 +3195,9 @@ export interface OIDCProvider {
   // includes this field in the response (Pydantic default-False is
   // includes this field in the response (Pydantic default-False is
   // populated unconditionally in the route handler).
   // populated unconditionally in the route handler).
   has_icon: boolean;
   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 {
 export interface OIDCProviderCreate {
@@ -3206,6 +3213,7 @@ export interface OIDCProviderCreate {
   require_email_verified?: boolean;
   require_email_verified?: boolean;
   icon_url?: string | null;
   icon_url?: string | null;
   default_group_id?: number | null;
   default_group_id?: number | null;
+  is_autologin?: boolean;  // #1589
 }
 }
 
 
 export interface OIDCLink {
 export interface OIDCLink {
@@ -3228,6 +3236,13 @@ export interface TestSMTPResponse {
 export interface AdvancedAuthStatus {
 export interface AdvancedAuthStatus {
   advanced_auth_enabled: boolean;
   advanced_auth_enabled: boolean;
   smtp_configured: 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 {
 export interface LDAPStatus {

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

@@ -23,6 +23,7 @@ const EMPTY_FORM: OIDCProviderCreate = {
   require_email_verified: true,
   require_email_verified: true,
   icon_url: undefined,
   icon_url: undefined,
   default_group_id: null,
   default_group_id: null,
+  is_autologin: false,
 };
 };
 
 
 // ─── Provider form (create / edit) ───────────────────────────────────────────
 // ─── Provider form (create / edit) ───────────────────────────────────────────
@@ -149,6 +150,13 @@ function ProviderForm({
             <p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
             <p className="text-bambu-gray text-xs">{requireEmailVerifiedDesc}</p>
           </div>
           </div>
         </label>
         </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>
 
 
       <div>
       <div>
@@ -447,6 +455,7 @@ export function OIDCProviderSettings() {
                     require_email_verified: provider.require_email_verified,
                     require_email_verified: provider.require_email_verified,
                     icon_url: provider.icon_url ?? undefined,
                     icon_url: provider.icon_url ?? undefined,
                     default_group_id: provider.default_group_id ?? null,
                     default_group_id: provider.default_group_id ?? null,
+                    is_autologin: provider.is_autologin,
                   }}
                   }}
                   onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
                   onSave={(data) => updateMutation.mutate({ id: provider.id, data })}
                   onCancel={() => setEditingId(null)}
                   onCancel={() => setEditingId(null)}

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

@@ -1865,6 +1865,10 @@ export default {
     checkPrinterFirmware: 'Drucker-Firmware prüfen',
     checkPrinterFirmware: 'Drucker-Firmware prüfen',
     includeBetaUpdates: 'Beta-Versionen einschließen',
     includeBetaUpdates: 'Beta-Versionen einschließen',
     includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',
     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
     // Queue
     enableRetry: 'Wiederholung aktivieren',
     enableRetry: 'Wiederholung aktivieren',
     // Home Assistant
     // Home Assistant
@@ -2535,6 +2539,8 @@ export default {
         defaultGroup: 'Standardgruppe',
         defaultGroup: 'Standardgruppe',
         defaultGroupDesc: 'Gruppe, der automatisch erstellte Benutzer zugewiesen werden. Fallback auf Viewers, wenn nicht gesetzt.',
         defaultGroupDesc: 'Gruppe, der automatisch erstellte Benutzer zugewiesen werden. Fallback auf Viewers, wenn nicht gesetzt.',
         defaultGroupViewersFallback: 'Viewers (Standard)',
         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...',
     signingIn: 'Anmeldung läuft...',
     rememberMe: 'Angemeldet bleiben',
     rememberMe: 'Angemeldet bleiben',
     forgotPassword: 'Passwort vergessen?',
     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',
     loginSuccess: 'Erfolgreich angemeldet',
     loginFailed: 'Anmeldung fehlgeschlagen',
     loginFailed: 'Anmeldung fehlgeschlagen',
     enterCredentials: 'Bitte Benutzername und Passwort eingeben',
     enterCredentials: 'Bitte Benutzername und Passwort eingeben',

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

@@ -1879,6 +1879,10 @@ export default {
     checkPrinterFirmware: 'Check printer firmware',
     checkPrinterFirmware: 'Check printer firmware',
     includeBetaUpdates: 'Include beta versions',
     includeBetaUpdates: 'Include beta versions',
     includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',
     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
     // Queue
     enableRetry: 'Enable retry',
     enableRetry: 'Enable retry',
     // Home Assistant
     // Home Assistant
@@ -2550,6 +2554,8 @@ export default {
         defaultGroup: 'Default Group',
         defaultGroup: 'Default Group',
         defaultGroupDesc: 'Group assigned to auto-created users. Falls back to Viewers if not set.',
         defaultGroupDesc: 'Group assigned to auto-created users. Falls back to Viewers if not set.',
         defaultGroupViewersFallback: 'Viewers (default)',
         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...',
     signingIn: 'Logging in...',
     rememberMe: 'Remember Me',
     rememberMe: 'Remember Me',
     forgotPassword: 'Forgot your password?',
     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',
     loginSuccess: 'Logged in successfully',
     loginFailed: 'Login failed',
     loginFailed: 'Login failed',
     enterCredentials: 'Please enter username and password',
     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',
     checkPrinterFirmware: 'Comprobar el firmware de la impresora',
     includeBetaUpdates: 'Incluir versiones beta',
     includeBetaUpdates: 'Incluir versiones beta',
     includeBetaUpdatesDesc: 'Notificar sobre versiones beta y preliminares al buscar actualizaciones',
     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
     // Queue
     enableRetry: 'Activar reintentos',
     enableRetry: 'Activar reintentos',
     // Home Assistant
     // Home Assistant
@@ -2538,6 +2542,8 @@ export default {
         defaultGroup: 'Grupo predeterminado',
         defaultGroup: 'Grupo predeterminado',
         defaultGroupDesc: 'Grupo asignado a los usuarios creados automáticamente. Si no se establece, se usa Visores como alternativa.',
         defaultGroupDesc: 'Grupo asignado a los usuarios creados automáticamente. Si no se establece, se usa Visores como alternativa.',
         defaultGroupViewersFallback: 'Visores (predeterminado)',
         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...',
     signingIn: 'Iniciando sesión...',
     rememberMe: 'Recordarme',
     rememberMe: 'Recordarme',
     forgotPassword: '¿Olvidó su contraseña?',
     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',
     loginSuccess: 'Sesión iniciada correctamente',
     loginFailed: 'Error al iniciar sesión',
     loginFailed: 'Error al iniciar sesión',
     enterCredentials: 'Introduzca el nombre de usuario y la contraseña',
     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',
     checkPrinterFirmware: 'Vérifier le firmware imprimante',
     includeBetaUpdates: 'Inclure les versions bêta',
     includeBetaUpdates: 'Inclure les versions bêta',
     includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',
     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
     // Queue
     enableRetry: 'Activer la rétentative',
     enableRetry: 'Activer la rétentative',
     // Home Assistant
     // Home Assistant
@@ -2478,6 +2482,8 @@ export default {
         defaultGroup: 'Groupe par défaut',
         defaultGroup: 'Groupe par défaut',
         defaultGroupDesc: 'Groupe attribué aux utilisateurs créés automatiquement. Repli sur Viewers si non défini.',
         defaultGroupDesc: 'Groupe attribué aux utilisateurs créés automatiquement. Repli sur Viewers si non défini.',
         defaultGroupViewersFallback: 'Viewers (par défaut)',
         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...',
     signingIn: 'Connexion...',
     rememberMe: 'Se souvenir de moi',
     rememberMe: 'Se souvenir de moi',
     forgotPassword: 'Mot de passe oublié ?',
     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',
     loginSuccess: 'Connecté avec succès',
     loginFailed: 'Échec de connexion',
     loginFailed: 'Échec de connexion',
     enterCredentials: 'Entrez vos identifiants',
     enterCredentials: 'Entrez vos identifiants',

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

@@ -1821,6 +1821,10 @@ export default {
     checkPrinterFirmware: 'Controlla firmware stampante',
     checkPrinterFirmware: 'Controlla firmware stampante',
     includeBetaUpdates: 'Includi versioni beta',
     includeBetaUpdates: 'Includi versioni beta',
     includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',
     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
     // Queue
     enableRetry: 'Abilita retry',
     enableRetry: 'Abilita retry',
     // Home Assistant
     // Home Assistant
@@ -2477,6 +2481,8 @@ export default {
         defaultGroup: 'Gruppo predefinito',
         defaultGroup: 'Gruppo predefinito',
         defaultGroupDesc: 'Gruppo assegnato agli utenti creati automaticamente. Ritorno a Viewers se non impostato.',
         defaultGroupDesc: 'Gruppo assegnato agli utenti creati automaticamente. Ritorno a Viewers se non impostato.',
         defaultGroupViewersFallback: 'Viewers (predefinito)',
         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...',
     signingIn: 'Accesso in corso...',
     rememberMe: 'Ricordami',
     rememberMe: 'Ricordami',
     forgotPassword: 'Hai dimenticato la password?',
     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',
     loginSuccess: 'Accesso riuscito',
     loginFailed: 'Accesso fallito',
     loginFailed: 'Accesso fallito',
     enterCredentials: 'Inserisci nome utente e password',
     enterCredentials: 'Inserisci nome utente e password',

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

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

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

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

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

@@ -1821,6 +1821,10 @@ export default {
     checkPrinterFirmware: 'Verificar firmware da impressora',
     checkPrinterFirmware: 'Verificar firmware da impressora',
     includeBetaUpdates: 'Incluir versões beta',
     includeBetaUpdates: 'Incluir versões beta',
     includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',
     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
     // Queue
     enableRetry: 'Habilitar tentativa',
     enableRetry: 'Habilitar tentativa',
     // Home Assistant
     // Home Assistant
@@ -2477,6 +2481,8 @@ export default {
         defaultGroup: 'Grupo padrão',
         defaultGroup: 'Grupo padrão',
         defaultGroupDesc: 'Grupo atribuído aos usuários criados automaticamente. Retorna a Viewers se não definido.',
         defaultGroupDesc: 'Grupo atribuído aos usuários criados automaticamente. Retorna a Viewers se não definido.',
         defaultGroupViewersFallback: 'Viewers (padrão)',
         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...',
     signingIn: 'Entrando...',
     rememberMe: 'Lembrar de mim',
     rememberMe: 'Lembrar de mim',
     forgotPassword: 'Esqueceu sua senha?',
     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',
     loginSuccess: 'Login realizado com sucesso',
     loginFailed: 'Falha no login',
     loginFailed: 'Falha no login',
     enterCredentials: 'Por favor, insira nome de usuário e senha',
     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',
     checkPrinterFirmware: 'Yazıcı firmware\'ini kontrol et',
     includeBetaUpdates: 'Beta sürümleri dahil et',
     includeBetaUpdates: 'Beta sürümleri dahil et',
     includeBetaUpdatesDesc: 'Güncellemeleri kontrol ederken beta ve önyayım sürümleri hakkında bildir',
     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
     // Kuyruk
     enableRetry: 'Yeniden denemeyi etkinleştir',
     enableRetry: 'Yeniden denemeyi etkinleştir',
     // Home Assistant
     // Home Assistant
@@ -2538,6 +2542,8 @@ export default {
         defaultGroup: 'Varsayılan Grup',
         defaultGroup: 'Varsayılan Grup',
         defaultGroupDesc: 'Otomatik oluşturulan kullanıcılara atanan grup. Ayarlanmazsa Viewers\'a geri döner.',
         defaultGroupDesc: 'Otomatik oluşturulan kullanıcılara atanan grup. Ayarlanmazsa Viewers\'a geri döner.',
         defaultGroupViewersFallback: 'Viewers (varsayılan)',
         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...',
     signingIn: 'Giriş yapılıyor...',
     rememberMe: 'Beni Hatırla',
     rememberMe: 'Beni Hatırla',
     forgotPassword: 'Parolanızı mı unuttunuz?',
     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ı',
     loginSuccess: 'Başarıyla giriş yapıldı',
     loginFailed: 'Giriş başarısız',
     loginFailed: 'Giriş başarısız',
     enterCredentials: 'Lütfen kullanıcı adı ve parola girin',
     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: '检查打印机固件',
     checkPrinterFirmware: '检查打印机固件',
     includeBetaUpdates: '包含测试版本',
     includeBetaUpdates: '包含测试版本',
     includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
     includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
+    localLogin: {
+      disable: '禁用本地用户名/密码登录',
+      disableHint: '启用后,只能通过SSO提供商登录。LDAP不受影响。在服务器上设置 BAMBUDDY_LOCAL_LOGIN=true 可保留恢复通道。',
+    },
     // Queue
     // Queue
     enableRetry: '启用重试',
     enableRetry: '启用重试',
     // Home Assistant
     // Home Assistant
@@ -2522,6 +2526,8 @@ export default {
         defaultGroup: '默认组',
         defaultGroup: '默认组',
         defaultGroupDesc: '自动创建用户时分配的组。未设置时回退到 Viewers。',
         defaultGroupDesc: '自动创建用户时分配的组。未设置时回退到 Viewers。',
         defaultGroupViewersFallback: 'Viewers(默认)',
         defaultGroupViewersFallback: 'Viewers(默认)',
+        autologin: '自动登录',
+        autologinDesc: '将未认证的访问者直接重定向到该提供商。只有一个提供商可以携带此标志。',
       },
       },
     },
     },
 
 
@@ -2671,6 +2677,8 @@ export default {
     signingIn: '登录中...',
     signingIn: '登录中...',
     rememberMe: '记住我',
     rememberMe: '记住我',
     forgotPassword: '忘记密码?',
     forgotPassword: '忘记密码?',
+    autologinFailed: '自动SSO登录失败。请在下方选择一个提供商以继续。',
+    localDisabledNotice: '本地登录已禁用。请使用下方的SSO提供商之一。',
     loginSuccess: '登录成功',
     loginSuccess: '登录成功',
     loginFailed: '登录失败',
     loginFailed: '登录失败',
     enterCredentials: '请输入用户名和密码',
     enterCredentials: '请输入用户名和密码',

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

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

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

@@ -165,6 +165,42 @@ export function LoginPage() {
     queryFn: () => api.getOIDCProviders(),
     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.
   // 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
   // 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.
   // or Referer headers — mirrors the H-4 treatment of the OIDC token.
@@ -667,6 +703,19 @@ export function LoginPage() {
           </p>
           </p>
         </div>
         </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}>
         <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
           <div className="space-y-4">
           <div className="space-y-4">
             <div>
             <div>
@@ -739,6 +788,7 @@ export function LoginPage() {
             </button>
             </button>
           </div>
           </div>
         </form>
         </form>
+        )}
 
 
         {/* OIDC provider buttons */}
         {/* OIDC provider buttons */}
         {oidcProviders && oidcProviders.length > 0 && (
         {oidcProviders && oidcProviders.length > 0 && (

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

@@ -554,7 +554,7 @@ export function SettingsPage() {
   });
   });
 
 
   // Advanced auth status for user creation
   // 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'],
     queryKey: ['advancedAuthStatus'],
     queryFn: () => api.getAdvancedAuthStatus(),
     queryFn: () => api.getAdvancedAuthStatus(),
   });
   });
@@ -934,6 +934,7 @@ export function SettingsPage() {
       settings.check_updates !== localSettings.check_updates ||
       settings.check_updates !== localSettings.check_updates ||
       (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (settings.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
       (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.notification_language !== localSettings.notification_language ||
       (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
       (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
@@ -1027,6 +1028,7 @@ export function SettingsPage() {
         check_updates: localSettings.check_updates,
         check_updates: localSettings.check_updates,
         check_printer_firmware: localSettings.check_printer_firmware,
         check_printer_firmware: localSettings.check_printer_firmware,
         include_beta_updates: localSettings.include_beta_updates,
         include_beta_updates: localSettings.include_beta_updates,
+        local_login_enabled: localSettings.local_login_enabled,
         notification_language: localSettings.notification_language,
         notification_language: localSettings.notification_language,
         bed_cooled_threshold: localSettings.bed_cooled_threshold,
         bed_cooled_threshold: localSettings.bed_cooled_threshold,
         ams_humidity_good: localSettings.ams_humidity_good,
         ams_humidity_good: localSettings.ams_humidity_good,
@@ -5672,7 +5674,23 @@ export function SettingsPage() {
           )}
           )}
 
 
           {usersSubTab === 'oidc' && isAdmin && (
           {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 />
               <OIDCProviderSettings />
             </div>
             </div>
           )}
           )}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Blhe8AhR.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- 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-DMYFpZ9c.js"></script>
+    <script type="module" crossorigin src="/assets/index-Blhe8AhR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DIWYFok8.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DIWYFok8.css">
   </head>
   </head>
   <body>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов