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

Add LDAP/Active Directory authentication (#794)

  Users can authenticate against an LDAP/AD server with configurable
  server URL, bind DN, search base, and user filter. Supports StartTLS
  and LDAPS — plaintext is not allowed. Both Active Directory (memberOf)
  and POSIX groups (memberUid) are mapped to BamBuddy groups on each
  login. Auto-provisioning creates local accounts on first LDAP login.
  Local admin accounts remain as fallback when LDAP is unreachable.
  Password management is disabled for LDAP users.
maziggy 1 месяц назад
Родитель
Сommit
b6599dd419

+ 1 - 0
CHANGELOG.md

@@ -9,6 +9,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Shortest Job First Queue Scheduling** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — New SJF toggle badge on the queue page header. When enabled, the scheduler starts shorter print jobs before longer ones instead of FIFO order. A starvation guard ensures long jobs that get skipped once are protected from being skipped again — they move to the front of the queue on the next cycle. The queue display automatically reorders to show the scheduler's actual execution order. Print duration is cached on queue items at creation time from the 3MF metadata.
 - **Auto-Print G-code Injection** ([#422](https://github.com/maziggy/bambuddy/issues/422)) — Configure custom start and end G-code snippets per printer model in Settings (Workflow tab) for bed-clearing systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. When adding a print to the queue, enable "Inject G-code" to have the scheduler inject the configured snippets into the 3MF before uploading to the printer. The original file is never modified — injection creates a temporary copy for upload only.
 - **External Folder Subfolder Preservation** ([#890](https://github.com/maziggy/bambuddy/issues/890)) — Scanning an external folder now mirrors the real directory structure into the file manager folder tree instead of flattening all files into the root. Subdirectories are created as child LibraryFolders with correct parent/child hierarchy, and files are assigned to their matching subfolder. Hidden directories are skipped when "Show hidden files" is disabled. Subfolders that are deleted from disk are automatically cleaned up on the next scan. Created subfolders inherit the parent's read-only and show-hidden settings.
+- **LDAP Authentication** ([#794](https://github.com/maziggy/bambuddy/issues/794)) — Users can now authenticate against an LDAP/Active Directory server. Configure the LDAP server URL, bind DN, search base, and user filter in Settings > Authentication > LDAP. Supports StartTLS, LDAPS (SSL), and plaintext connections. LDAP groups can be mapped to BamBuddy groups (Administrators, Operators, Viewers) for automatic role assignment. Auto-provisioning creates BamBuddy accounts on first LDAP login when enabled. Local admin accounts remain as fallback when the LDAP server is unreachable. Password management features (change password, forgot password, admin reset) are automatically disabled for LDAP users.
 
 ### Improved
 - **Database Engine Info on System Page** — The System Information page now shows the active database engine (SQLite or PostgreSQL) and its version in the Database section, making it easy to verify which backend is in use.

+ 193 - 5
backend/app/api/routes/auth.py

@@ -62,6 +62,7 @@ def _user_to_response(user: User) -> UserResponse:
         role=user.role,
         is_active=user.is_active,
         is_admin=user.is_admin,
+        auth_source=getattr(user, "auth_source", "local"),
         groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
         permissions=sorted(user.get_permissions()),
         created_at=user.created_at.isoformat(),
@@ -289,11 +290,49 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             detail="Authentication is not enabled",
         )
 
-    # Try username-based authentication first
-    user = await authenticate_user(db, request.username, request.password)
+    # Check if LDAP is enabled
+    ldap_user = None
+    ldap_settings = await _get_ldap_settings(db)
+    if ldap_settings:
+        try:
+            from backend.app.services.ldap_service import (
+                authenticate_ldap_user,
+                parse_ldap_config,
+            )
+
+            ldap_config = parse_ldap_config(ldap_settings)
+            if ldap_config:
+                ldap_user = authenticate_ldap_user(ldap_config, request.username, request.password)
+                if ldap_user:
+                    # LDAP auth succeeded — find or create local user
+                    user = await get_user_by_username(db, ldap_user.username)
+                    if user and user.auth_source != "ldap":
+                        # Username exists as local user — don't override
+                        user = None
+                        ldap_user = None
+                    elif not user:
+                        if not ldap_config.auto_provision:
+                            # User doesn't exist and auto-provision is off
+                            ldap_user = None
+                        else:
+                            # Auto-provision LDAP user
+                            user = await _provision_ldap_user(db, ldap_user, ldap_config)
+
+                    if user and ldap_user:
+                        # Update email and group mappings on each login
+                        await _sync_ldap_user(db, user, ldap_user, ldap_config)
+        except Exception as e:
+            import logging
+
+            logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
+            ldap_user = None
+
+    # Try username-based authentication (skip if already authenticated via LDAP)
+    if not ldap_user:
+        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:
+    if not user and not ldap_user:
         advanced_auth = await is_advanced_auth_enabled(db)
         if advanced_auth:
             user = await authenticate_user_by_email(db, request.username, request.password)
@@ -587,8 +626,8 @@ async def forgot_password(request: ForgotPasswordRequest, db: AsyncSession = Dep
     user = await get_user_by_email(db, request.email)
 
     # Always return success message to prevent email enumeration
-    # but only send email if user exists
-    if user and user.is_active:
+    # but only send email if user exists and is not an LDAP user
+    if user and user.is_active and user.auth_source != "ldap":
         try:
             # Generate new password
             new_password = generate_secure_password()
@@ -659,6 +698,12 @@ async def reset_user_password(
             detail="User not found",
         )
 
+    if user.auth_source == "ldap":
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Cannot reset password for LDAP users — passwords are managed by the LDAP server",
+        )
+
     if not user.email:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
@@ -688,3 +733,146 @@ async def reset_user_password(
             status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
             detail=f"Failed to reset password: {str(e)}",
         )
+
+
+# LDAP Authentication Helpers
+
+
+async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
+    """Get LDAP settings from the database. Returns None if LDAP is not enabled."""
+    ldap_keys = [
+        "ldap_enabled",
+        "ldap_server_url",
+        "ldap_bind_dn",
+        "ldap_bind_password",
+        "ldap_search_base",
+        "ldap_user_filter",
+        "ldap_security",
+        "ldap_group_mapping",
+        "ldap_auto_provision",
+        "ldap_ca_cert_path",
+    ]
+    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+    settings = {s.key: s.value for s in result.scalars().all()}
+    if settings.get("ldap_enabled", "false").lower() != "true":
+        return None
+    return settings
+
+
+async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User:
+    """Create a new local user from LDAP authentication."""
+    import logging
+
+    from backend.app.services.ldap_service import resolve_group_mapping
+
+    logger = logging.getLogger(__name__)
+
+    new_user = User(
+        username=ldap_user.username,
+        email=ldap_user.email,
+        password_hash=None,
+        role="user",
+        auth_source="ldap",
+        is_active=True,
+    )
+
+    # Map LDAP groups to BamBuddy groups
+    mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
+    if mapped_group_names:
+        groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
+        new_user.groups = list(groups_result.scalars().all())
+
+    db.add(new_user)
+    await db.commit()
+    await db.refresh(new_user)
+    logger.info("Auto-provisioned LDAP user: %s (groups: %s)", new_user.username, mapped_group_names)
+    return new_user
+
+
+async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config) -> None:
+    """Sync LDAP user attributes (email, groups) on each login."""
+    import logging
+
+    from backend.app.services.ldap_service import resolve_group_mapping
+
+    logger = logging.getLogger(__name__)
+
+    changed = False
+
+    # Update email if changed
+    if ldap_user.email and ldap_user.email != user.email:
+        user.email = ldap_user.email
+        changed = True
+
+    # Sync group mappings — always update to match LDAP state (including revocation)
+    mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
+    if mapped_group_names:
+        groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
+        new_groups = list(groups_result.scalars().all())
+    else:
+        new_groups = []
+    current_group_ids = {g.id for g in user.groups}
+    new_group_ids = {g.id for g in new_groups}
+    if current_group_ids != new_group_ids:
+        user.groups = new_groups
+        changed = True
+
+    if changed:
+        await db.commit()
+        logger.info("Synced LDAP user attributes: %s", user.username)
+
+
+@router.post("/ldap/test")
+async def test_ldap(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Test LDAP connection using saved settings (admin only when auth enabled)."""
+    import logging
+
+    from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection
+
+    logger = logging.getLogger(__name__)
+
+    ldap_settings = await _get_ldap_settings(db)
+    if not ldap_settings:
+        # LDAP might not be enabled yet but settings might still exist — read all keys
+        ldap_keys = [
+            "ldap_enabled",
+            "ldap_server_url",
+            "ldap_bind_dn",
+            "ldap_bind_password",
+            "ldap_search_base",
+            "ldap_user_filter",
+            "ldap_security",
+            "ldap_group_mapping",
+            "ldap_auto_provision",
+        ]
+        result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+        ldap_settings = {s.key: s.value for s in result.scalars().all()}
+        # Force enabled for test
+        ldap_settings["ldap_enabled"] = "true"
+
+    config = parse_ldap_config(ldap_settings)
+    if not config:
+        return {"success": False, "message": "LDAP server URL is not configured"}
+
+    success, message = test_ldap_connection(config)
+    if success:
+        logger.info("LDAP connection test successful")
+    else:
+        logger.warning("LDAP connection test failed: %s", message)
+    return {"success": success, "message": message}
+
+
+@router.get("/ldap/status")
+async def get_ldap_status(db: AsyncSession = Depends(get_db)):
+    """Get LDAP authentication status."""
+    # Only fetch the minimum keys needed — never load secrets
+    ldap_keys = ["ldap_enabled", "ldap_server_url"]
+    result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
+    settings = {s.key: s.value for s in result.scalars().all()}
+    return {
+        "ldap_enabled": settings.get("ldap_enabled", "false").lower() == "true",
+        "ldap_configured": bool(settings.get("ldap_server_url")),
+    }

+ 5 - 0
backend/app/api/routes/settings.py

@@ -106,6 +106,8 @@ async def get_settings(
                 "default_vibration_cali",
                 "default_layer_inspect",
                 "default_timelapse",
+                "ldap_enabled",
+                "ldap_auto_provision",
             ]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in [
@@ -139,6 +141,9 @@ async def get_settings(
     ha_settings = await get_homeassistant_settings(db)
     settings_dict.update(ha_settings)
 
+    # Never return LDAP bind password in API responses
+    settings_dict["ldap_bind_password"] = ""
+
     return AppSettings(**settings_dict)
 
 

+ 18 - 0
backend/app/api/routes/users.py

@@ -38,6 +38,7 @@ def _user_to_response(user: User) -> UserResponse:
         role=user.role,
         is_active=user.is_active,
         is_admin=user.is_admin,
+        auth_source=getattr(user, "auth_source", "local"),
         groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
         permissions=sorted(user.get_permissions()),
         created_at=user.created_at.isoformat(),
@@ -253,6 +254,11 @@ async def update_user(
         user.email = user_data.email
 
     if user_data.password is not None:
+        if getattr(user, "auth_source", "local") == "ldap":
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot set password for LDAP users",
+            )
         user.password_hash = get_password_hash(user_data.password)
 
     if user_data.role is not None:
@@ -402,7 +408,19 @@ async def change_own_password(
             detail="Authentication required to change password",
         )
 
+    # Block password change for LDAP users
+    if getattr(current_user, "auth_source", "local") == "ldap":
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Cannot change password for LDAP users — passwords are managed by the LDAP server",
+        )
+
     # Verify current password
+    if not current_user.password_hash:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Account has no local password set",
+        )
     if not verify_password(password_data.current_password, current_user.password_hash):
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,

+ 6 - 2
backend/app/core/auth.py

@@ -220,7 +220,9 @@ async def authenticate_user(db: AsyncSession, username: str, password: str) -> U
     user = await get_user_by_username(db, username)
     if not user:
         return None
-    if not verify_password(password, user.password_hash):
+    if getattr(user, "auth_source", "local") == "ldap":
+        return None  # LDAP users authenticate via LDAP, not local password
+    if not user.password_hash or not verify_password(password, user.password_hash):
         return None
     if not user.is_active:
         return None
@@ -235,7 +237,9 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str
     user = await get_user_by_email(db, email)
     if not user:
         return None
-    if not verify_password(password, user.password_hash):
+    if getattr(user, "auth_source", "local") == "ldap":
+        return None
+    if not user.password_hash or not verify_password(password, user.password_hash):
         return None
     if not user.is_active:
         return None

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

@@ -1367,6 +1367,13 @@ async def run_migrations(conn):
     # Previously both None and [] meant "all printers"; now [] means "no printers"
     await _safe_execute(conn, "UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'")
 
+    # Migration: Add auth_source column to users for LDAP support (#794)
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local' NOT NULL")
+
+    # Migration: Make password_hash nullable for LDAP users (#794)
+    if not is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL")
+
     # Seed default settings keys that must exist on fresh install
     default_settings = [
         ("advanced_auth_enabled", "false"),

+ 2 - 1
backend/app/models/user.py

@@ -26,10 +26,11 @@ class User(Base):
     id: Mapped[int] = mapped_column(primary_key=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
-    password_hash: Mapped[str] = mapped_column(String(255))
+    password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
     role: Mapped[str] = mapped_column(
         String(20), default="user"
     )  # "admin" or "user" (legacy, kept for backward compat)
+    auth_source: Mapped[str] = mapped_column(String(20), default="local")  # "local" or "ldap"
     is_active: Mapped[bool] = mapped_column(default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

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

@@ -46,6 +46,7 @@ class UserResponse(BaseModel):
     role: str  # Deprecated, kept for backward compatibility
     is_active: bool
     is_admin: bool  # Computed from role and group membership
+    auth_source: str = "local"  # "local" or "ldap"
     groups: list[GroupBrief] = []
     permissions: list[str] = []  # All permissions from groups
     created_at: str

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

@@ -225,6 +225,26 @@ class AppSettings(BaseModel):
         description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
     )
 
+    # 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)")
+    ldap_bind_dn: str = Field(default="", description="Bind DN for LDAP searches (e.g., cn=admin,dc=example,dc=com)")
+    ldap_bind_password: str = Field(default="", description="Bind password for LDAP searches")
+    ldap_search_base: str = Field(default="", description="Search base DN (e.g., ou=users,dc=example,dc=com)")
+    ldap_user_filter: str = Field(
+        default="(sAMAccountName={username})",
+        description="LDAP user search filter. {username} is replaced with the login username",
+    )
+    ldap_security: str = Field(default="starttls", description="LDAP security: 'starttls' or 'ldaps'")
+    ldap_group_mapping: str = Field(
+        default="",
+        description="JSON: LDAP group to BamBuddy group mapping {ldap_group_dn: bambuddy_group_name}",
+    )
+    ldap_auto_provision: bool = Field(
+        default=False,
+        description="Auto-create BamBuddy user on first successful LDAP login",
+    )
+
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
         default="",
@@ -310,6 +330,15 @@ class AppSettingsUpdate(BaseModel):
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
     gcode_snippets: str | None = None
+    ldap_enabled: bool | None = None
+    ldap_server_url: str | None = None
+    ldap_bind_dn: str | None = None
+    ldap_bind_password: str | None = None
+    ldap_search_base: str | None = None
+    ldap_user_filter: str | None = None
+    ldap_security: str | None = None
+    ldap_group_mapping: str | None = None
+    ldap_auto_provision: bool | None = None
     default_sidebar_order: str | None = None
 
     @field_validator("gcode_snippets")
@@ -325,6 +354,19 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError("gcode_snippets must be a JSON object keyed by printer model")
         return v
 
+    @field_validator("ldap_group_mapping")
+    @classmethod
+    def validate_ldap_group_mapping(cls, v: str | None) -> str | None:
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError("ldap_group_mapping must be valid JSON or empty")
+        if not isinstance(parsed, dict):
+            raise ValueError("ldap_group_mapping must be a JSON object mapping LDAP group DNs to BamBuddy group names")
+        return v
+
     @field_validator("default_sidebar_order")
     @classmethod
     def validate_default_sidebar_order(cls, v: str | None) -> str | None:

+ 265 - 0
backend/app/services/ldap_service.py

@@ -0,0 +1,265 @@
+"""LDAP authentication service for BamBuddy (#794).
+
+Supports:
+- LDAP bind authentication (simple bind with user's credentials)
+- StartTLS, LDAPS, and plaintext connections
+- User search with configurable filter
+- Group membership resolution for role mapping
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass
+
+from ldap3 import ALL, SUBTREE, Connection, Server, Tls
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class LDAPUserInfo:
+    """User information retrieved from LDAP after successful authentication."""
+
+    username: str
+    email: str | None
+    display_name: str | None
+    groups: list[str]  # List of group DNs the user belongs to
+
+
+@dataclass
+class LDAPConfig:
+    """LDAP configuration parsed from settings."""
+
+    server_url: str
+    bind_dn: str
+    bind_password: str
+    search_base: str
+    user_filter: str  # e.g. "(sAMAccountName={username})"
+    security: str  # "none", "starttls", "ldaps"
+    group_mapping: dict[str, str]  # LDAP group DN -> BamBuddy group name
+    auto_provision: bool
+    ca_cert_path: str  # Path to CA certificate file (empty = skip verification)
+
+
+def parse_ldap_config(settings: dict[str, str]) -> LDAPConfig | None:
+    """Parse LDAP config from settings key-value pairs. Returns None if LDAP not enabled."""
+    if settings.get("ldap_enabled", "false").lower() != "true":
+        return None
+
+    server_url = settings.get("ldap_server_url", "").strip()
+    if not server_url:
+        return None
+
+    group_mapping_raw = settings.get("ldap_group_mapping", "")
+    try:
+        group_mapping = json.loads(group_mapping_raw) if group_mapping_raw else {}
+    except json.JSONDecodeError:
+        group_mapping = {}
+
+    return LDAPConfig(
+        server_url=server_url,
+        bind_dn=settings.get("ldap_bind_dn", "").strip(),
+        bind_password=settings.get("ldap_bind_password", ""),
+        search_base=settings.get("ldap_search_base", "").strip(),
+        user_filter=settings.get("ldap_user_filter", "(sAMAccountName={username})").strip(),
+        security=settings.get("ldap_security", "starttls").strip(),
+        group_mapping=group_mapping if isinstance(group_mapping, dict) else {},
+        auto_provision=settings.get("ldap_auto_provision", "false").lower() == "true",
+        ca_cert_path=settings.get("ldap_ca_cert_path", "").strip(),
+    )
+
+
+def _create_server(config: LDAPConfig) -> Server:
+    """Create an ldap3 Server instance from config.
+
+    Always uses TLS — either LDAPS (TLS from start) or StartTLS (upgrade after connect).
+    Plaintext LDAP is not supported.
+    """
+    import ssl
+
+    use_ssl = config.security == "ldaps" or config.server_url.startswith("ldaps://")
+
+    if config.ca_cert_path:
+        tls = Tls(validate=ssl.CERT_REQUIRED, ca_certs_file=config.ca_cert_path)
+    else:
+        tls = Tls(validate=ssl.CERT_NONE)
+
+    return Server(config.server_url, use_ssl=use_ssl, tls=tls, get_info=ALL, connect_timeout=10)
+
+
+def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) -> LDAPUserInfo | None:
+    """Authenticate a user via LDAP bind.
+
+    1. Bind with service account to search for the user DN
+    2. Attempt bind with the user's DN and provided password
+    3. On success, retrieve user attributes and group memberships
+
+    Returns LDAPUserInfo on success, None on failure.
+    """
+    if not password:
+        return None
+
+    server = _create_server(config)
+
+    # Step 1: Service account bind + user search
+    try:
+        service_conn = Connection(
+            server,
+            user=config.bind_dn,
+            password=config.bind_password,
+            auto_bind=False,
+            raise_exceptions=True,
+            read_only=True,
+        )
+        service_conn.open()
+        if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
+            service_conn.start_tls()
+        service_conn.bind()
+    except Exception as e:
+        logger.warning("LDAP service account bind failed: %s", e)
+        return None
+
+    try:
+        # Search for the user
+        search_filter = config.user_filter.replace("{username}", _ldap_escape(username))
+        service_conn.search(
+            search_base=config.search_base,
+            search_filter=search_filter,
+            search_scope=SUBTREE,
+            attributes=["*"],
+        )
+
+        if not service_conn.entries:
+            logger.info("LDAP user not found: %s", username)
+            return None
+
+        user_entry = service_conn.entries[0]
+        user_dn = str(user_entry.entry_dn)
+
+        # Step 2: Bind as the user to verify password
+        try:
+            user_conn = Connection(
+                server,
+                user=user_dn,
+                password=password,
+                auto_bind=False,
+                raise_exceptions=True,
+                read_only=True,
+            )
+            user_conn.open()
+            if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
+                user_conn.start_tls()
+            user_conn.bind()
+            user_conn.unbind()
+        except Exception as e:
+            logger.info("LDAP bind failed for user %s: %s", username, e)
+            return None
+
+        # Step 3: Extract user info
+        email = str(user_entry.mail) if hasattr(user_entry, "mail") and user_entry.mail else None
+        display_name = (
+            str(user_entry.displayName) if hasattr(user_entry, "displayName") and user_entry.displayName else None
+        )
+
+        # Collect groups from memberOf attribute (Active Directory / groupOfNames)
+        groups = (
+            [str(g) for g in user_entry.memberOf] if hasattr(user_entry, "memberOf") and user_entry.memberOf else []
+        )
+
+        # Also search for Posix groups (memberUid-based) using the service account
+        canonical_username = username
+        if hasattr(user_entry, "sAMAccountName") and user_entry.sAMAccountName:
+            canonical_username = str(user_entry.sAMAccountName)
+        elif hasattr(user_entry, "uid") and user_entry.uid:
+            canonical_username = str(user_entry.uid)
+
+        posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
+        service_conn.search(
+            search_base=config.search_base,
+            search_filter=posix_filter,
+            search_scope=SUBTREE,
+            attributes=["cn"],
+        )
+        for entry in service_conn.entries:
+            groups.append(str(entry.entry_dn))
+
+        logger.info(
+            "LDAP authentication successful for user: %s (DN: %s, groups: %d)", canonical_username, user_dn, len(groups)
+        )
+
+        return LDAPUserInfo(
+            username=canonical_username,
+            email=email,
+            display_name=display_name,
+            groups=groups,
+        )
+    finally:
+        service_conn.unbind()
+
+
+def resolve_group_mapping(ldap_groups: list[str], group_mapping: dict[str, str]) -> list[str]:
+    """Map LDAP group DNs to BamBuddy group names.
+
+    Returns list of BamBuddy group names that the user should be added to.
+    Comparison is case-insensitive on the LDAP group DN.
+    """
+    if not group_mapping:
+        return []
+
+    # Build case-insensitive lookup
+    mapping_lower = {k.lower(): v for k, v in group_mapping.items()}
+    result = []
+    for ldap_group in ldap_groups:
+        bambuddy_group = mapping_lower.get(ldap_group.lower())
+        if bambuddy_group:
+            result.append(bambuddy_group)
+    return result
+
+
+def test_ldap_connection(config: LDAPConfig) -> tuple[bool, str]:
+    """Test LDAP connection and service account bind.
+
+    Returns (success, message).
+    """
+    try:
+        server = _create_server(config)
+        conn = Connection(
+            server,
+            user=config.bind_dn,
+            password=config.bind_password,
+            auto_bind=False,
+            raise_exceptions=True,
+            read_only=True,
+        )
+        conn.open()
+        if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
+            conn.start_tls()
+        conn.bind()
+
+        # Try a search to verify search base
+        conn.search(
+            search_base=config.search_base,
+            search_filter="(objectClass=*)",
+            search_scope=SUBTREE,
+            size_limit=1,
+        )
+        conn.unbind()
+        return True, "LDAP connection successful"
+    except Exception as e:
+        return False, f"LDAP connection failed: {e}"
+
+
+def _ldap_escape(value: str) -> str:
+    """Escape special characters in LDAP search filter values (RFC 4515)."""
+    replacements = {
+        "\\": "\\5c",
+        "*": "\\2a",
+        "(": "\\28",
+        ")": "\\29",
+        "\x00": "\\00",
+    }
+    for char, escaped in replacements.items():
+        value = value.replace(char, escaped)
+    return value

+ 230 - 0
backend/tests/unit/services/test_ldap_service.py

@@ -0,0 +1,230 @@
+"""Tests for LDAP authentication service (#794).
+
+Tests the pure logic functions in ldap_service.py:
+- Config parsing from settings dict
+- LDAP filter escaping (RFC 4515)
+- Group mapping resolution
+- LDAPConfig/LDAPUserInfo dataclass construction
+
+Network-dependent functions (authenticate_ldap_user, test_ldap_connection)
+are not tested here — they require a live LDAP server.
+"""
+
+import pytest
+
+from backend.app.services.ldap_service import (
+    LDAPConfig,
+    LDAPUserInfo,
+    _ldap_escape,
+    parse_ldap_config,
+    resolve_group_mapping,
+)
+
+
+class TestParseConfig:
+    """Verify parse_ldap_config builds LDAPConfig from settings dict."""
+
+    def test_returns_none_when_disabled(self):
+        settings = {"ldap_enabled": "false", "ldap_server_url": "ldaps://example.com"}
+        assert parse_ldap_config(settings) is None
+
+    def test_returns_none_when_missing_enabled(self):
+        settings = {"ldap_server_url": "ldaps://example.com"}
+        assert parse_ldap_config(settings) is None
+
+    def test_returns_none_when_no_server_url(self):
+        settings = {"ldap_enabled": "true", "ldap_server_url": ""}
+        assert parse_ldap_config(settings) is None
+
+    def test_returns_none_when_server_url_whitespace(self):
+        settings = {"ldap_enabled": "true", "ldap_server_url": "   "}
+        assert parse_ldap_config(settings) is None
+
+    def test_parses_minimal_config(self):
+        settings = {
+            "ldap_enabled": "true",
+            "ldap_server_url": "ldaps://ldap.example.com:636",
+        }
+        config = parse_ldap_config(settings)
+        assert config is not None
+        assert config.server_url == "ldaps://ldap.example.com:636"
+        assert config.bind_dn == ""
+        assert config.search_base == ""
+        assert config.user_filter == "(sAMAccountName={username})"
+        assert config.security == "starttls"
+        assert config.group_mapping == {}
+        assert config.auto_provision is False
+        assert config.ca_cert_path == ""
+
+    def test_parses_full_config(self):
+        settings = {
+            "ldap_enabled": "true",
+            "ldap_server_url": "ldaps://ldap.example.com:636",
+            "ldap_bind_dn": "cn=admin,dc=example,dc=com",
+            "ldap_bind_password": "secret",
+            "ldap_search_base": "ou=users,dc=example,dc=com",
+            "ldap_user_filter": "(uid={username})",
+            "ldap_security": "ldaps",
+            "ldap_group_mapping": '{"cn=admins,dc=example,dc=com": "Administrators"}',
+            "ldap_auto_provision": "true",
+            "ldap_ca_cert_path": "/path/to/ca.pem",
+        }
+        config = parse_ldap_config(settings)
+        assert config is not None
+        assert config.bind_dn == "cn=admin,dc=example,dc=com"
+        assert config.bind_password == "secret"
+        assert config.search_base == "ou=users,dc=example,dc=com"
+        assert config.user_filter == "(uid={username})"
+        assert config.security == "ldaps"
+        assert config.group_mapping == {"cn=admins,dc=example,dc=com": "Administrators"}
+        assert config.auto_provision is True
+        assert config.ca_cert_path == "/path/to/ca.pem"
+
+    def test_handles_invalid_group_mapping_json(self):
+        settings = {
+            "ldap_enabled": "true",
+            "ldap_server_url": "ldaps://ldap.example.com",
+            "ldap_group_mapping": "not valid json",
+        }
+        config = parse_ldap_config(settings)
+        assert config is not None
+        assert config.group_mapping == {}
+
+    def test_handles_non_dict_group_mapping(self):
+        settings = {
+            "ldap_enabled": "true",
+            "ldap_server_url": "ldaps://ldap.example.com",
+            "ldap_group_mapping": '["not", "a", "dict"]',
+        }
+        config = parse_ldap_config(settings)
+        assert config is not None
+        assert config.group_mapping == {}
+
+    def test_enabled_case_insensitive(self):
+        settings = {"ldap_enabled": "True", "ldap_server_url": "ldaps://ldap.example.com"}
+        assert parse_ldap_config(settings) is not None
+
+        settings = {"ldap_enabled": "TRUE", "ldap_server_url": "ldaps://ldap.example.com"}
+        assert parse_ldap_config(settings) is not None
+
+    def test_strips_whitespace(self):
+        settings = {
+            "ldap_enabled": "true",
+            "ldap_server_url": "  ldaps://ldap.example.com  ",
+            "ldap_bind_dn": "  cn=admin,dc=example,dc=com  ",
+            "ldap_search_base": "  dc=example,dc=com  ",
+        }
+        config = parse_ldap_config(settings)
+        assert config.server_url == "ldaps://ldap.example.com"
+        assert config.bind_dn == "cn=admin,dc=example,dc=com"
+        assert config.search_base == "dc=example,dc=com"
+
+
+class TestLDAPEscape:
+    """Verify RFC 4515 escaping for LDAP search filter values."""
+
+    def test_plain_string(self):
+        assert _ldap_escape("testuser") == "testuser"
+
+    def test_escapes_backslash(self):
+        assert _ldap_escape("test\\user") == "test\\5cuser"
+
+    def test_escapes_asterisk(self):
+        assert _ldap_escape("test*user") == "test\\2auser"
+
+    def test_escapes_open_paren(self):
+        assert _ldap_escape("test(user") == "test\\28user"
+
+    def test_escapes_close_paren(self):
+        assert _ldap_escape("test)user") == "test\\29user"
+
+    def test_escapes_null(self):
+        assert _ldap_escape("test\x00user") == "test\\00user"
+
+    def test_escapes_multiple_chars(self):
+        assert _ldap_escape("a*b(c)d\\e") == "a\\2ab\\28c\\29d\\5ce"
+
+    def test_empty_string(self):
+        assert _ldap_escape("") == ""
+
+
+class TestResolveGroupMapping:
+    """Verify LDAP group DN to BamBuddy group name resolution."""
+
+    def test_empty_mapping(self):
+        assert resolve_group_mapping(["cn=admins,dc=example"], {}) == []
+
+    def test_empty_groups(self):
+        mapping = {"cn=admins,dc=example": "Administrators"}
+        assert resolve_group_mapping([], mapping) == []
+
+    def test_single_match(self):
+        mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
+        groups = ["cn=admins,dc=example,dc=com"]
+        assert resolve_group_mapping(groups, mapping) == ["Administrators"]
+
+    def test_multiple_matches(self):
+        mapping = {
+            "cn=admins,dc=example,dc=com": "Administrators",
+            "cn=ops,dc=example,dc=com": "Operators",
+        }
+        groups = ["cn=admins,dc=example,dc=com", "cn=ops,dc=example,dc=com"]
+        result = resolve_group_mapping(groups, mapping)
+        assert set(result) == {"Administrators", "Operators"}
+
+    def test_no_match(self):
+        mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
+        groups = ["cn=users,dc=example,dc=com"]
+        assert resolve_group_mapping(groups, mapping) == []
+
+    def test_case_insensitive_dn(self):
+        mapping = {"CN=Admins,DC=Example,DC=Com": "Administrators"}
+        groups = ["cn=admins,dc=example,dc=com"]
+        assert resolve_group_mapping(groups, mapping) == ["Administrators"]
+
+    def test_partial_match_not_matched(self):
+        mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
+        groups = ["cn=admins,dc=other,dc=com"]
+        assert resolve_group_mapping(groups, mapping) == []
+
+    def test_extra_groups_ignored(self):
+        mapping = {"cn=admins,dc=example,dc=com": "Administrators"}
+        groups = ["cn=admins,dc=example,dc=com", "cn=users,dc=example,dc=com", "cn=devs,dc=example,dc=com"]
+        assert resolve_group_mapping(groups, mapping) == ["Administrators"]
+
+
+class TestDataclasses:
+    """Verify dataclass construction."""
+
+    def test_ldap_user_info(self):
+        info = LDAPUserInfo(
+            username="testuser",
+            email="test@example.com",
+            display_name="Test User",
+            groups=["cn=admins,dc=example,dc=com"],
+        )
+        assert info.username == "testuser"
+        assert info.email == "test@example.com"
+        assert info.display_name == "Test User"
+        assert info.groups == ["cn=admins,dc=example,dc=com"]
+
+    def test_ldap_user_info_none_fields(self):
+        info = LDAPUserInfo(username="testuser", email=None, display_name=None, groups=[])
+        assert info.email is None
+        assert info.display_name is None
+        assert info.groups == []
+
+    def test_ldap_config(self):
+        config = LDAPConfig(
+            server_url="ldaps://ldap.example.com:636",
+            bind_dn="cn=admin,dc=example,dc=com",
+            bind_password="secret",
+            search_base="dc=example,dc=com",
+            user_filter="(uid={username})",
+            security="ldaps",
+            group_mapping={"cn=admins": "Administrators"},
+            auto_provision=True,
+            ca_cert_path="",
+        )
+        assert config.server_url == "ldaps://ldap.example.com:636"
+        assert config.auto_provision is True

+ 4 - 1
frontend/src/__tests__/components/AssignSpoolModal.test.tsx

@@ -34,6 +34,7 @@ const manualSpool = {
   weight_used: 0,
   tag_uid: null,
   tray_uuid: null,
+  slicer_filament_name: 'PLA',
 };
 
 const blSpool = {
@@ -47,11 +48,12 @@ const blSpool = {
   weight_used: 50,
   tag_uid: '05CC1E0F00000100',
   tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+  slicer_filament_name: 'PLA',
 };
 
 const anotherManualSpool = {
   id: 3,
-  material: 'PETG',
+  material: 'PLA',
   subtype: 'HF',
   brand: 'Overture',
   color_name: 'Black',
@@ -60,6 +62,7 @@ const anotherManualSpool = {
   weight_used: 200,
   tag_uid: null,
   tray_uuid: null,
+  slicer_filament_name: 'PLA',
 };
 
 describe('AssignSpoolModal', () => {

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

@@ -903,6 +903,16 @@ export interface AppSettings {
   queue_shortest_first: boolean;
   // Default sidebar order (admin-set for all users)
   default_sidebar_order: string;
+  // LDAP authentication
+  ldap_enabled: boolean;
+  ldap_server_url: string;
+  ldap_bind_dn: string;
+  ldap_bind_password: string;
+  ldap_search_base: string;
+  ldap_user_filter: string;
+  ldap_security: string;
+  ldap_group_mapping: string;
+  ldap_auto_provision: boolean;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -2275,6 +2285,7 @@ export interface UserResponse {
   role: string;  // Deprecated, kept for backward compatibility
   is_active: boolean;
   is_admin: boolean;  // Computed from role and group membership
+  auth_source: string;  // "local" or "ldap"
   groups: GroupBrief[];
   permissions: Permission[];  // All permissions from groups
   created_at: string;
@@ -2344,6 +2355,16 @@ export interface AdvancedAuthStatus {
   smtp_configured: boolean;
 }
 
+export interface LDAPStatus {
+  ldap_enabled: boolean;
+  ldap_configured: boolean;
+}
+
+export interface LDAPTestResponse {
+  success: boolean;
+  message: string;
+}
+
 export interface SetupResponse {
   auth_enabled: boolean;
   admin_created?: boolean;
@@ -2399,6 +2420,12 @@ export const api = {
       method: 'POST',
     }),
   getAdvancedAuthStatus: () => request<AdvancedAuthStatus>('/auth/advanced-auth/status'),
+  // LDAP Authentication
+  getLDAPStatus: () => request<LDAPStatus>('/auth/ldap/status'),
+  testLDAP: () =>
+    request<LDAPTestResponse>('/auth/ldap/test', {
+      method: 'POST',
+    }),
   forgotPassword: (data: ForgotPasswordRequest) =>
     request<ForgotPasswordResponse>('/auth/forgot-password', {
       method: 'POST',

+ 418 - 0
frontend/src/components/LDAPSettings.tsx

@@ -0,0 +1,418 @@
+import { useState, useEffect } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Shield, Lock, Unlock, AlertTriangle, CheckCircle, Loader2, Send } from 'lucide-react';
+import { api } from '../api/client';
+import type { AppSettings } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+
+const SECURITY_PORT_MAP: Record<string, string> = {
+  starttls: '389',
+  ldaps: '636',
+};
+
+interface LDAPFormState {
+  ldap_server_url: string;
+  ldap_bind_dn: string;
+  ldap_bind_password: string;
+  ldap_search_base: string;
+  ldap_user_filter: string;
+  ldap_security: string;
+  ldap_group_mapping: string;
+  ldap_auto_provision: boolean;
+}
+
+export function LDAPSettings() {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const { authEnabled } = useAuth();
+
+  const [form, setForm] = useState<LDAPFormState>({
+    ldap_server_url: '',
+    ldap_bind_dn: '',
+    ldap_bind_password: '',
+    ldap_search_base: '',
+    ldap_user_filter: '(sAMAccountName={username})',
+    ldap_security: 'starttls',
+    ldap_group_mapping: '',
+    ldap_auto_provision: false,
+  });
+
+  // Fetch settings
+  const { data: settings, isLoading } = useQuery({
+    queryKey: ['settings'],
+    queryFn: () => api.getSettings(),
+  });
+
+  // Fetch LDAP status
+  const { data: ldapStatus } = useQuery({
+    queryKey: ['ldapStatus'],
+    queryFn: () => api.getLDAPStatus(),
+  });
+
+  // Fetch groups for mapping display
+  const { data: groups = [] } = useQuery({
+    queryKey: ['groups'],
+    queryFn: () => api.getGroups(),
+  });
+
+  // Load settings into form
+  useEffect(() => {
+    if (settings) {
+      setForm({
+        ldap_server_url: settings.ldap_server_url || '',
+        ldap_bind_dn: settings.ldap_bind_dn || '',
+        ldap_bind_password: '', // Never show password
+        ldap_search_base: settings.ldap_search_base || '',
+        ldap_user_filter: settings.ldap_user_filter || '(sAMAccountName={username})',
+        ldap_security: settings.ldap_security || 'starttls',
+        ldap_group_mapping: settings.ldap_group_mapping || '',
+        ldap_auto_provision: settings.ldap_auto_provision ?? false,
+      });
+    }
+  }, [settings]);
+
+  // Save settings
+  const saveMutation = useMutation({
+    mutationFn: (data: Partial<AppSettings>) => api.updateSettings(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+      queryClient.invalidateQueries({ queryKey: ['ldapStatus'] });
+      showToast(t('settings.ldap.settingsSaved') || 'LDAP settings saved', 'success');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  // Toggle LDAP
+  const toggleMutation = useMutation({
+    mutationFn: (enabled: boolean) => api.updateSettings({ ldap_enabled: enabled }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+      queryClient.invalidateQueries({ queryKey: ['ldapStatus'] });
+      showToast(
+        ldapStatus?.ldap_enabled
+          ? (t('settings.ldap.disabled') || 'LDAP authentication disabled')
+          : (t('settings.ldap.enabled') || 'LDAP authentication enabled'),
+        'success'
+      );
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  // Test connection
+  const testMutation = useMutation({
+    mutationFn: () => api.testLDAP(),
+    onSuccess: (data: { success: boolean; message: string }) => {
+      showToast(data.message, data.success ? 'success' : 'error');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handleSave = () => {
+    if (!form.ldap_server_url) {
+      showToast(t('settings.ldap.errors.serverRequired') || 'LDAP server URL is required', 'error');
+      return;
+    }
+    if (!form.ldap_search_base) {
+      showToast(t('settings.ldap.errors.searchBaseRequired') || 'Search base DN is required', 'error');
+      return;
+    }
+
+    // Build the update payload — only include password if user entered one
+    const update: Record<string, unknown> = {
+      ldap_server_url: form.ldap_server_url,
+      ldap_bind_dn: form.ldap_bind_dn,
+      ldap_search_base: form.ldap_search_base,
+      ldap_user_filter: form.ldap_user_filter,
+      ldap_security: form.ldap_security,
+      ldap_group_mapping: form.ldap_group_mapping,
+      ldap_auto_provision: form.ldap_auto_provision,
+    };
+    if (form.ldap_bind_password) {
+      update.ldap_bind_password = form.ldap_bind_password;
+    }
+    saveMutation.mutate(update as Partial<AppSettings>);
+  };
+
+  const handleToggle = () => {
+    if (!authEnabled) {
+      showToast(t('settings.ldap.errors.enableAuthFirst') || 'Enable authentication first', 'error');
+      return;
+    }
+    if (!ldapStatus?.ldap_enabled && !ldapStatus?.ldap_configured) {
+      showToast(t('settings.ldap.errors.configureLdapFirst') || 'Save LDAP settings first', 'error');
+      return;
+    }
+    toggleMutation.mutate(!ldapStatus?.ldap_enabled);
+  };
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center p-12">
+        <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+      </div>
+    );
+  }
+
+  const ldapEnabled = ldapStatus?.ldap_enabled ?? false;
+  const inputClasses = "w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors";
+
+  return (
+    <div className="space-y-6">
+      {/* LDAP Toggle */}
+      <Card>
+        <CardHeader>
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <Shield className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-lg font-semibold text-white">
+                {t('settings.ldap.title') || 'LDAP Authentication'}
+              </h2>
+            </div>
+            <Button
+              onClick={handleToggle}
+              disabled={toggleMutation.isPending}
+              variant={ldapEnabled ? 'danger' : 'primary'}
+            >
+              {ldapEnabled ? (
+                <>
+                  <Unlock className="w-4 h-4" />
+                  {t('common.disable') || 'Disable'}
+                </>
+              ) : (
+                <>
+                  <Lock className="w-4 h-4" />
+                  {t('common.enable') || 'Enable'}
+                </>
+              )}
+            </Button>
+          </div>
+        </CardHeader>
+        <CardContent>
+          {ldapEnabled ? (
+            <div className="bg-green-500/10 border border-green-500/30 rounded-lg p-4">
+              <div className="flex items-start gap-3">
+                <CheckCircle className="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
+                <div className="space-y-2">
+                  <p className="text-white font-medium">
+                    {t('settings.ldap.enabledDesc') || 'LDAP authentication is enabled'}
+                  </p>
+                  <ul className="text-sm text-green-300 space-y-1 list-disc list-inside">
+                    <li>{t('settings.ldap.feature1') || 'Users can login with LDAP credentials'}</li>
+                    <li>{t('settings.ldap.feature2') || 'Local admin account remains as fallback'}</li>
+                    <li>{t('settings.ldap.feature3') || 'LDAP groups are mapped to BamBuddy groups on login'}</li>
+                  </ul>
+                </div>
+              </div>
+            </div>
+          ) : (
+            <div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4">
+              <div className="flex items-start gap-3">
+                <AlertTriangle className="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
+                <div>
+                  <p className="text-white font-medium">
+                    {t('settings.ldap.disabledDesc') || 'LDAP authentication is disabled'}
+                  </p>
+                  <p className="text-sm text-yellow-300 mt-1">
+                    {t('settings.ldap.disabledHint') || 'Configure and save LDAP settings below, then enable.'}
+                  </p>
+                </div>
+              </div>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+
+      {/* LDAP Server Configuration */}
+      <Card>
+        <CardHeader>
+          <h2 className="text-lg font-semibold text-white">
+            {t('settings.ldap.serverConfig') || 'LDAP Server Configuration'}
+          </h2>
+        </CardHeader>
+        <CardContent>
+          <div className="space-y-4">
+            {/* Server URL */}
+            <div>
+              <label className="block text-sm font-medium text-bambu-gray mb-2">
+                {t('settings.ldap.serverUrl') || 'Server URL'}
+              </label>
+              <input
+                type="text"
+                className={inputClasses}
+                placeholder="ldaps://ldap.example.com:636"
+                value={form.ldap_server_url}
+                onChange={e => setForm({ ...form, ldap_server_url: e.target.value })}
+              />
+              <p className="text-xs text-bambu-gray mt-1">
+                {t('settings.ldap.serverUrlHint') || 'Use ldaps:// for SSL or ldap:// with StartTLS'}
+              </p>
+            </div>
+
+            {/* Security */}
+            <div>
+              <label className="block text-sm font-medium text-bambu-gray mb-2">
+                {t('settings.ldap.security') || 'Security'}
+              </label>
+              <div className="flex gap-2">
+                {(['starttls', 'ldaps'] as const).map(sec => (
+                  <button
+                    key={sec}
+                    onClick={() => setForm({ ...form, ldap_security: sec })}
+                    className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
+                      form.ldap_security === sec
+                        ? 'bg-bambu-green text-black'
+                        : 'bg-bambu-dark-secondary text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
+                    }`}
+                  >
+                    {sec === 'starttls' ? 'StartTLS' : 'LDAPS (SSL)'}
+                  </button>
+                ))}
+              </div>
+              <p className="text-xs text-bambu-gray mt-1">
+                {t('settings.ldap.securityHint') || `Default port: ${SECURITY_PORT_MAP[form.ldap_security]}`}
+              </p>
+            </div>
+
+            {/* Bind DN */}
+            <div>
+              <label className="block text-sm font-medium text-bambu-gray mb-2">
+                {t('settings.ldap.bindDn') || 'Bind DN (Service Account)'}
+              </label>
+              <input
+                type="text"
+                className={inputClasses}
+                placeholder="cn=service-account,ou=service,dc=example,dc=com"
+                value={form.ldap_bind_dn}
+                onChange={e => setForm({ ...form, ldap_bind_dn: e.target.value })}
+              />
+            </div>
+
+            {/* Bind Password */}
+            <div>
+              <label className="block text-sm font-medium text-bambu-gray mb-2">
+                {t('settings.ldap.bindPassword') || 'Bind Password'}
+              </label>
+              <input
+                type="password"
+                className={inputClasses}
+                placeholder={settings?.ldap_bind_dn ? '••••••••' : ''}
+                value={form.ldap_bind_password}
+                onChange={e => setForm({ ...form, ldap_bind_password: e.target.value })}
+              />
+            </div>
+
+            {/* Search Base */}
+            <div>
+              <label className="block text-sm font-medium text-bambu-gray mb-2">
+                {t('settings.ldap.searchBase') || 'Search Base DN'}
+              </label>
+              <input
+                type="text"
+                className={inputClasses}
+                placeholder="ou=users,dc=example,dc=com"
+                value={form.ldap_search_base}
+                onChange={e => setForm({ ...form, ldap_search_base: e.target.value })}
+              />
+            </div>
+
+            {/* User Filter */}
+            <div>
+              <label className="block text-sm font-medium text-bambu-gray mb-2">
+                {t('settings.ldap.userFilter') || 'User Search Filter'}
+              </label>
+              <input
+                type="text"
+                className={inputClasses}
+                placeholder="(sAMAccountName={username})"
+                value={form.ldap_user_filter}
+                onChange={e => setForm({ ...form, ldap_user_filter: e.target.value })}
+              />
+              <p className="text-xs text-bambu-gray mt-1">
+                {t('settings.ldap.userFilterHint') || '{username} is replaced with the login username. Use (uid={username}) for OpenLDAP.'}
+              </p>
+            </div>
+
+            {/* Auto Provision */}
+            <div className="flex items-center justify-between py-2">
+              <div>
+                <label className="block text-sm font-medium text-white">
+                  {t('settings.ldap.autoProvision') || 'Auto-provision users'}
+                </label>
+                <p className="text-xs text-bambu-gray mt-0.5">
+                  {t('settings.ldap.autoProvisionHint') || 'Automatically create a BamBuddy account on first LDAP login'}
+                </p>
+              </div>
+              <button
+                onClick={() => setForm({ ...form, ldap_auto_provision: !form.ldap_auto_provision })}
+                className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
+                  form.ldap_auto_provision ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                }`}
+              >
+                <span
+                  className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
+                    form.ldap_auto_provision ? 'translate-x-6' : 'translate-x-1'
+                  }`}
+                />
+              </button>
+            </div>
+
+            {/* Group Mapping */}
+            <div>
+              <label className="block text-sm font-medium text-bambu-gray mb-2">
+                {t('settings.ldap.groupMapping') || 'Group Mapping (JSON)'}
+              </label>
+              <textarea
+                className={`${inputClasses} font-mono text-sm`}
+                rows={4}
+                placeholder={'{\n  "CN=PrintFarm_Admins,OU=Groups,DC=example,DC=com": "Administrators",\n  "CN=PrintFarm_Users,OU=Groups,DC=example,DC=com": "Operators"\n}'}
+                value={form.ldap_group_mapping}
+                onChange={e => setForm({ ...form, ldap_group_mapping: e.target.value })}
+              />
+              <p className="text-xs text-bambu-gray mt-1">
+                {t('settings.ldap.groupMappingHint') || 'Map LDAP group DNs to BamBuddy groups. Available groups: '}{groups.map(g => g.name).join(', ')}
+              </p>
+            </div>
+
+            {/* Action Buttons */}
+            <div className="flex gap-3 pt-2">
+              <Button
+                onClick={handleSave}
+                disabled={saveMutation.isPending}
+              >
+                {saveMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <CheckCircle className="w-4 h-4" />
+                )}
+                {t('common.save') || 'Save'}
+              </Button>
+              <Button
+                variant="secondary"
+                onClick={() => testMutation.mutate()}
+                disabled={testMutation.isPending}
+              >
+                {testMutation.isPending ? (
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                ) : (
+                  <Send className="w-4 h-4" />
+                )}
+                {t('settings.ldap.testConnection') || 'Test Connection'}
+              </Button>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

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

@@ -1303,6 +1303,40 @@ export default {
       users: 'Authentifizierung',
       backup: 'Sicherung',
       emailAuth: 'E-Mail-Authentifizierung',
+      ldap: 'LDAP',
+    },
+    ldap: {
+      title: 'LDAP-Authentifizierung',
+      enabledDesc: 'LDAP-Authentifizierung ist aktiviert',
+      disabledDesc: 'LDAP-Authentifizierung ist deaktiviert',
+      disabledHint: 'LDAP-Einstellungen unten konfigurieren und speichern, dann aktivieren.',
+      enabled: 'LDAP-Authentifizierung aktiviert',
+      disabled: 'LDAP-Authentifizierung deaktiviert',
+      feature1: 'Benutzer können sich mit LDAP-Anmeldedaten anmelden',
+      feature2: 'Lokales Admin-Konto bleibt als Fallback erhalten',
+      feature3: 'LDAP-Gruppen werden bei der Anmeldung BamBuddy-Gruppen zugeordnet',
+      serverConfig: 'LDAP-Server-Konfiguration',
+      serverUrl: 'Server-URL',
+      serverUrlHint: 'Verwenden Sie ldap:// für Standard oder ldaps:// für SSL-Verbindungen',
+      security: 'Sicherheit',
+      securityHint: 'StartTLS aktualisiert eine einfache Verbindung auf TLS. LDAPS verwendet TLS von Anfang an.',
+      bindDn: 'Bind-DN (Dienstkonto)',
+      bindPassword: 'Bind-Passwort',
+      searchBase: 'Such-Basis-DN',
+      userFilter: 'Benutzer-Suchfilter',
+      userFilterHint: '{username} wird durch den Anmeldenamen ersetzt. Verwenden Sie (uid={username}) für OpenLDAP.',
+      autoProvision: 'Benutzer automatisch anlegen',
+      autoProvisionHint: 'Automatisch ein BamBuddy-Konto bei der ersten LDAP-Anmeldung erstellen',
+      groupMapping: 'Gruppenzuordnung (JSON)',
+      groupMappingHint: 'LDAP-Gruppen-DNs BamBuddy-Gruppen zuordnen. Verfügbare Gruppen: ',
+      testConnection: 'Verbindung testen',
+      settingsSaved: 'LDAP-Einstellungen gespeichert',
+      errors: {
+        serverRequired: 'LDAP-Server-URL ist erforderlich',
+        searchBaseRequired: 'Such-Basis-DN ist erforderlich',
+        enableAuthFirst: 'Authentifizierung zuerst aktivieren',
+        configureLdapFirst: 'LDAP-Einstellungen zuerst speichern',
+      },
     },
     // Email settings
     email: {

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

@@ -1304,6 +1304,41 @@ export default {
       users: 'Authentication',
       backup: 'Backup',
       emailAuth: 'Email Authentication',
+      ldap: 'LDAP',
+    },
+    // LDAP settings
+    ldap: {
+      title: 'LDAP Authentication',
+      enabledDesc: 'LDAP authentication is enabled',
+      disabledDesc: 'LDAP authentication is disabled',
+      disabledHint: 'Configure and save LDAP settings below, then enable.',
+      enabled: 'LDAP authentication enabled',
+      disabled: 'LDAP authentication disabled',
+      feature1: 'Users can login with LDAP credentials',
+      feature2: 'Local admin account remains as fallback',
+      feature3: 'LDAP groups are mapped to BamBuddy groups on login',
+      serverConfig: 'LDAP Server Configuration',
+      serverUrl: 'Server URL',
+      serverUrlHint: 'Use ldaps:// for SSL or ldap:// with StartTLS',
+      security: 'Security',
+      securityHint: 'StartTLS upgrades a plain connection to TLS. LDAPS uses TLS from the start.',
+      bindDn: 'Bind DN (Service Account)',
+      bindPassword: 'Bind Password',
+      searchBase: 'Search Base DN',
+      userFilter: 'User Search Filter',
+      userFilterHint: '{username} is replaced with the login username. Use (uid={username}) for OpenLDAP.',
+      autoProvision: 'Auto-provision users',
+      autoProvisionHint: 'Automatically create a BamBuddy account on first LDAP login',
+      groupMapping: 'Group Mapping (JSON)',
+      groupMappingHint: 'Map LDAP group DNs to BamBuddy groups. Available groups: ',
+      testConnection: 'Test Connection',
+      settingsSaved: 'LDAP settings saved',
+      errors: {
+        serverRequired: 'LDAP server URL is required',
+        searchBaseRequired: 'Search base DN is required',
+        enableAuthFirst: 'Enable authentication first',
+        configureLdapFirst: 'Save LDAP settings first',
+      },
     },
     // Email settings
     email: {

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

@@ -1303,6 +1303,40 @@ export default {
       users: 'Authentification',
       backup: 'Sauvegarde',
       emailAuth: 'Authentification Email',
+      ldap: 'LDAP',
+    },
+    ldap: {
+      title: 'Authentification LDAP',
+      enabledDesc: "L'authentification LDAP est activée",
+      disabledDesc: "L'authentification LDAP est désactivée",
+      disabledHint: 'Configurez et enregistrez les paramètres LDAP ci-dessous, puis activez.',
+      enabled: 'Authentification LDAP activée',
+      disabled: 'Authentification LDAP désactivée',
+      feature1: 'Les utilisateurs peuvent se connecter avec leurs identifiants LDAP',
+      feature2: "Le compte administrateur local reste disponible en secours",
+      feature3: 'Les groupes LDAP sont associés aux groupes BamBuddy à la connexion',
+      serverConfig: 'Configuration du serveur LDAP',
+      serverUrl: 'URL du serveur',
+      serverUrlHint: 'Utilisez ldap:// pour standard ou ldaps:// pour les connexions SSL',
+      security: 'Sécurité',
+      securityHint: 'StartTLS met à niveau une connexion simple vers TLS. LDAPS utilise TLS dès le début.',
+      bindDn: 'DN de liaison (compte de service)',
+      bindPassword: 'Mot de passe de liaison',
+      searchBase: 'DN de base de recherche',
+      userFilter: 'Filtre de recherche utilisateur',
+      userFilterHint: "{username} est remplacé par le nom d'utilisateur. Utilisez (uid={username}) pour OpenLDAP.",
+      autoProvision: 'Provisionnement automatique',
+      autoProvisionHint: 'Créer automatiquement un compte BamBuddy lors de la première connexion LDAP',
+      groupMapping: 'Mappage de groupes (JSON)',
+      groupMappingHint: 'Associer les DN de groupes LDAP aux groupes BamBuddy. Groupes disponibles : ',
+      testConnection: 'Tester la connexion',
+      settingsSaved: 'Paramètres LDAP enregistrés',
+      errors: {
+        serverRequired: "L'URL du serveur LDAP est requise",
+        searchBaseRequired: 'Le DN de base de recherche est requis',
+        enableAuthFirst: "Activez d'abord l'authentification",
+        configureLdapFirst: "Enregistrez d'abord les paramètres LDAP",
+      },
     },
     // Email settings
     email: {

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

@@ -1303,6 +1303,40 @@ export default {
       users: 'Utenti',
       backup: 'Backup',
       emailAuth: 'Autenticazione Email',
+      ldap: 'LDAP',
+    },
+    ldap: {
+      title: 'Autenticazione LDAP',
+      enabledDesc: "L'autenticazione LDAP è attiva",
+      disabledDesc: "L'autenticazione LDAP è disattivata",
+      disabledHint: 'Configura e salva le impostazioni LDAP qui sotto, poi attiva.',
+      enabled: 'Autenticazione LDAP attivata',
+      disabled: 'Autenticazione LDAP disattivata',
+      feature1: 'Gli utenti possono accedere con le credenziali LDAP',
+      feature2: "L'account amministratore locale rimane come fallback",
+      feature3: 'I gruppi LDAP vengono mappati ai gruppi BamBuddy al login',
+      serverConfig: 'Configurazione server LDAP',
+      serverUrl: 'URL del server',
+      serverUrlHint: 'Usa ldap:// per standard o ldaps:// per connessioni SSL',
+      security: 'Sicurezza',
+      securityHint: 'StartTLS aggiorna una connessione semplice a TLS. LDAPS usa TLS fin dall\'inizio.',
+      bindDn: 'Bind DN (account di servizio)',
+      bindPassword: 'Password Bind',
+      searchBase: 'Base DN di ricerca',
+      userFilter: 'Filtro di ricerca utente',
+      userFilterHint: '{username} viene sostituito con il nome utente. Usa (uid={username}) per OpenLDAP.',
+      autoProvision: 'Provisioning automatico utenti',
+      autoProvisionHint: 'Crea automaticamente un account BamBuddy al primo accesso LDAP',
+      groupMapping: 'Mappatura gruppi (JSON)',
+      groupMappingHint: 'Mappa i DN dei gruppi LDAP ai gruppi BamBuddy. Gruppi disponibili: ',
+      testConnection: 'Test connessione',
+      settingsSaved: 'Impostazioni LDAP salvate',
+      errors: {
+        serverRequired: "L'URL del server LDAP è obbligatorio",
+        searchBaseRequired: 'Il Base DN di ricerca è obbligatorio',
+        enableAuthFirst: "Attiva prima l'autenticazione",
+        configureLdapFirst: 'Salva prima le impostazioni LDAP',
+      },
     },
     // Email settings
     email: {

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

@@ -1302,6 +1302,40 @@ export default {
       users: '認証',
       backup: 'バックアップ',
       emailAuth: 'メール認証',
+      ldap: 'LDAP',
+    },
+    ldap: {
+      title: 'LDAP認証',
+      enabledDesc: 'LDAP認証が有効です',
+      disabledDesc: 'LDAP認証が無効です',
+      disabledHint: '以下のLDAP設定を構成して保存し、有効にしてください。',
+      enabled: 'LDAP認証を有効にしました',
+      disabled: 'LDAP認証を無効にしました',
+      feature1: 'LDAP資格情報でログインできます',
+      feature2: 'ローカル管理者アカウントはフォールバックとして残ります',
+      feature3: 'ログイン時にLDAPグループがBamBuddyグループにマッピングされます',
+      serverConfig: 'LDAPサーバー設定',
+      serverUrl: 'サーバーURL',
+      serverUrlHint: '標準はldap://、SSL接続はldaps://を使用',
+      security: 'セキュリティ',
+      securityHint: 'StartTLSはプレーン接続をTLSにアップグレードします。LDAPSは最初からTLSを使用します。',
+      bindDn: 'バインドDN(サービスアカウント)',
+      bindPassword: 'バインドパスワード',
+      searchBase: '検索ベースDN',
+      userFilter: 'ユーザー検索フィルター',
+      userFilterHint: '{username}はログインユーザー名に置き換えられます。OpenLDAPの場合は(uid={username})を使用。',
+      autoProvision: 'ユーザー自動作成',
+      autoProvisionHint: '初回LDAPログイン時にBamBuddyアカウントを自動作成',
+      groupMapping: 'グループマッピング(JSON)',
+      groupMappingHint: 'LDAPグループDNをBamBuddyグループにマッピング。利用可能なグループ: ',
+      testConnection: '接続テスト',
+      settingsSaved: 'LDAP設定を保存しました',
+      errors: {
+        serverRequired: 'LDAPサーバーURLは必須です',
+        searchBaseRequired: '検索ベースDNは必須です',
+        enableAuthFirst: '先に認証を有効にしてください',
+        configureLdapFirst: '先にLDAP設定を保存してください',
+      },
     },
     // Email settings
     email: {

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

@@ -1303,6 +1303,40 @@ export default {
       users: 'Autenticação',
       backup: 'Backup',
       emailAuth: 'Autenticação por Email',
+      ldap: 'LDAP',
+    },
+    ldap: {
+      title: 'Autenticação LDAP',
+      enabledDesc: 'A autenticação LDAP está ativada',
+      disabledDesc: 'A autenticação LDAP está desativada',
+      disabledHint: 'Configure e salve as configurações LDAP abaixo, depois ative.',
+      enabled: 'Autenticação LDAP ativada',
+      disabled: 'Autenticação LDAP desativada',
+      feature1: 'Usuários podem fazer login com credenciais LDAP',
+      feature2: 'A conta de administrador local permanece como fallback',
+      feature3: 'Grupos LDAP são mapeados para grupos BamBuddy no login',
+      serverConfig: 'Configuração do Servidor LDAP',
+      serverUrl: 'URL do servidor',
+      serverUrlHint: 'Use ldap:// para padrão ou ldaps:// para conexões SSL',
+      security: 'Segurança',
+      securityHint: 'StartTLS atualiza uma conexão simples para TLS. LDAPS usa TLS desde o início.',
+      bindDn: 'Bind DN (conta de serviço)',
+      bindPassword: 'Senha Bind',
+      searchBase: 'Base DN de pesquisa',
+      userFilter: 'Filtro de pesquisa de usuário',
+      userFilterHint: '{username} é substituído pelo nome de usuário. Use (uid={username}) para OpenLDAP.',
+      autoProvision: 'Provisionamento automático de usuários',
+      autoProvisionHint: 'Criar automaticamente uma conta BamBuddy no primeiro login LDAP',
+      groupMapping: 'Mapeamento de grupos (JSON)',
+      groupMappingHint: 'Mapear DNs de grupos LDAP para grupos BamBuddy. Grupos disponíveis: ',
+      testConnection: 'Testar conexão',
+      settingsSaved: 'Configurações LDAP salvas',
+      errors: {
+        serverRequired: 'URL do servidor LDAP é obrigatória',
+        searchBaseRequired: 'Base DN de pesquisa é obrigatória',
+        enableAuthFirst: 'Ative a autenticação primeiro',
+        configureLdapFirst: 'Salve as configurações LDAP primeiro',
+      },
     },
     // Email settings
     email: {

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

@@ -1303,6 +1303,40 @@ export default {
       users: '身份验证',
       backup: '备份',
       emailAuth: '邮箱认证',
+      ldap: 'LDAP',
+    },
+    ldap: {
+      title: 'LDAP 认证',
+      enabledDesc: 'LDAP 认证已启用',
+      disabledDesc: 'LDAP 认证已禁用',
+      disabledHint: '在下方配置并保存 LDAP 设置,然后启用。',
+      enabled: 'LDAP 认证已启用',
+      disabled: 'LDAP 认证已禁用',
+      feature1: '用户可以使用 LDAP 凭据登录',
+      feature2: '本地管理员帐户作为后备保留',
+      feature3: '登录时 LDAP 组映射到 BamBuddy 组',
+      serverConfig: 'LDAP 服务器配置',
+      serverUrl: '服务器 URL',
+      serverUrlHint: '使用 ldap:// 进行标准连接或 ldaps:// 进行 SSL 连接',
+      security: '安全',
+      securityHint: 'StartTLS 将普通连接升级为 TLS。LDAPS 从一开始就使用 TLS。',
+      bindDn: '绑定 DN(服务帐户)',
+      bindPassword: '绑定密码',
+      searchBase: '搜索基础 DN',
+      userFilter: '用户搜索过滤器',
+      userFilterHint: '{username} 替换为登录用户名。OpenLDAP 使用 (uid={username})。',
+      autoProvision: '自动创建用户',
+      autoProvisionHint: '首次 LDAP 登录时自动创建 BamBuddy 帐户',
+      groupMapping: '组映射(JSON)',
+      groupMappingHint: '将 LDAP 组 DN 映射到 BamBuddy 组。可用组:',
+      testConnection: '测试连接',
+      settingsSaved: 'LDAP 设置已保存',
+      errors: {
+        serverRequired: 'LDAP 服务器 URL 为必填项',
+        searchBaseRequired: '搜索基础 DN 为必填项',
+        enableAuthFirst: '请先启用认证',
+        configureLdapFirst: '请先保存 LDAP 设置',
+      },
     },
     // Email settings
     email: {

+ 34 - 1
frontend/src/pages/SettingsPage.tsx

@@ -24,6 +24,7 @@ import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { VirtualPrinterList } from '../components/VirtualPrinterList';
 import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { EmailSettings } from '../components/EmailSettings';
+import { LDAPSettings } from '../components/LDAPSettings';
 import { APIBrowser } from '../components/APIBrowser';
 import { Toggle } from '../components/Toggle';
 import { virtualPrinterApi } from '../api/client';
@@ -36,7 +37,7 @@ import { Palette } from 'lucide-react';
 
 const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
-type UsersSubTab = 'users' | 'email';
+type UsersSubTab = 'users' | 'email' | 'ldap';
 
 const STORAGE_CATEGORY_COLORS: Record<string, string> = {
   database: 'bg-blue-600',
@@ -407,6 +408,11 @@ export function SettingsPage() {
     queryFn: () => api.getAdvancedAuthStatus(),
   });
 
+  const { data: ldapStatus } = useQuery({
+    queryKey: ['ldapStatus'],
+    queryFn: () => api.getLDAPStatus(),
+  });
+
   // User management queries and mutations
   const { data: usersData = [], isLoading: usersLoading } = useQuery({
     queryKey: ['users'],
@@ -4136,6 +4142,20 @@ export function SettingsPage() {
                 <span className="w-2 h-2 rounded-full bg-green-400" />
               )}
             </button>
+            <button
+              onClick={() => setUsersSubTab('ldap')}
+              className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                usersSubTab === 'ldap'
+                  ? 'text-bambu-green border-bambu-green'
+                  : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+              }`}
+            >
+              <Shield className="w-4 h-4" />
+              {t('settings.tabs.ldap') || 'LDAP'}
+              {ldapStatus?.ldap_enabled && (
+                <span className="w-2 h-2 rounded-full bg-green-400" />
+              )}
+            </button>
           </div>
 
           {/* Users Sub-tab */}
@@ -4209,10 +4229,12 @@ export function SettingsPage() {
                           <Users className="w-5 h-5 text-bambu-green" />
                           {t('settings.currentUser')}
                         </h3>
+                        {user.auth_source !== 'ldap' && (
                         <Button size="sm" variant="ghost" onClick={() => setShowChangePasswordModal(true)}>
                           <Key className="w-4 h-4" />
                           {t('settings.changePassword')}
                         </Button>
+                        )}
                       </div>
                     </CardHeader>
                     <CardContent>
@@ -4284,6 +4306,11 @@ export function SettingsPage() {
                             <div className="flex-1 min-w-0">
                               <p className="text-white font-medium truncate">{userItem.username}</p>
                               <div className="flex flex-wrap gap-1 mt-1">
+                                {userItem.auth_source === 'ldap' && (
+                                  <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-cyan-500/20 text-cyan-300">
+                                    LDAP
+                                  </span>
+                                )}
                                 {userItem.is_admin && (
                                   <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300">
                                     {t('settings.admin')}
@@ -4449,6 +4476,12 @@ export function SettingsPage() {
               <EmailSettings />
             </div>
           )}
+
+          {usersSubTab === 'ldap' && (
+            <div className="max-w-2xl">
+              <LDAPSettings />
+            </div>
+          )}
         </div>
       )}
 

+ 1 - 0
requirements.txt

@@ -47,6 +47,7 @@ psutil>=6.0.0
 # Authentication
 PyJWT>=2.12.0
 passlib[bcrypt]>=1.7.4
+ldap3>=2.9.0
 
 # Plate Detection (optional - enables build plate empty detection)
 opencv-python-headless>=4.8.0

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


+ 1 - 1
static/index.html

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

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