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.
This commit is contained in:
maziggy 2026-06-25 14:54:27 +02:00
parent f9bf145477
commit 70857af393
31 changed files with 624 additions and 39 deletions

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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:

View file

@ -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)

View file

@ -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",

View file

@ -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)

View file

@ -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:

View file

@ -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).

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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'),

View file

@ -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 })

View file

@ -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 = {

View file

@ -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 {

View file

@ -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)}

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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: 'ユーザー名とパスワードを入力してください',

View file

@ -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: '사용자명과 비밀번호를 입력하세요',

View file

@ -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',

View file

@ -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',

View file

@ -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: '请输入用户名和密码',

View file

@ -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: '請輸入使用者名稱和密碼',

View file

@ -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 && (

View file

@ -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>
)}

File diff suppressed because one or more lines are too long

View file

@ -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>