Browse Source

feat(auth): manual LDAP user provisioning from the UI (#1298)

  Reporter @Fuechslein flagged that disabling LDAP auto-provision left admins
  with no UI path to onboard new users — the create-user form had zero LDAP
  awareness and the only workaround was hand-editing the database.

  Add a Local / LDAP tab toggle to the create-user modal (hidden when LDAP is
  disabled). The LDAP tab is a debounced directory search (≥2 chars, 300ms)
  that returns up to 25 matches via the service-account bind, annotated with
  already_provisioned so existing usernames render disabled. Clicking
  "Provision user" re-resolves via the service bind and creates the user
  through the same _provision_ldap_user helper the auto-provision login path
  uses, so group mapping, default-group fallback, and email sync are identical
  regardless of which path created the user.

  The picker component is shared across all four create-user modal paths
  (UsersPage basic + advanced, SettingsPage basic + advanced).

  Two ldap3 schema-check workarounds were needed for OpenLDAP installs:
  - Open the search connection with check_names=False so ldap3 doesn't reject
    the cross-schema OR filter (sAMAccountName/displayName are AD-only)
  - Request attributes=["*"] because ldap3's build_attribute_selection
    validates each named attribute against the server schema regardless of
    check_names, and only the * wildcard is in its hard-coded exclusion list

  Login/lookup paths keep check_names=True so typos in user_filter still fail
  loudly.

  Backend
  - New routes: GET /auth/ldap/search, POST /auth/ldap/provision (both gated
    by USERS_CREATE; 503 details include ldap3 exception class + message)
  - Extract _open_service_connection + _extract_user_info helpers so
    authenticate_ldap_user, lookup_ldap_user, and search_ldap_users share the
    bind and attribute-extraction logic

  Frontend
  - New LdapUserPicker component (debounced search, result list, provision
    mutation, already-provisioned guard, error surface)
  - Tab toggle wired into UsersPage and SettingsPage modals, plus
    CreateUserAdvancedAuthModal props
  - 14 i18n keys added to en.ts (other locales fall back to English)
maziggy 1 week ago
parent
commit
d6364646f8

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 163 - 0
backend/app/api/routes/auth.py

@@ -46,6 +46,8 @@ from backend.app.schemas.auth import (
     ForgotPasswordRequest,
     ForgotPasswordResponse,
     GroupBrief,
+    LDAPProvisionRequest,
+    LDAPSearchResultResponse,
     LoginRequest,
     LoginResponse,
     ResetPasswordRequest,
@@ -1301,6 +1303,167 @@ async def get_ldap_status(db: AsyncSession = Depends(get_db)):
     }
 
 
+# =============================================================================
+# Manual LDAP user provisioning (#1298)
+# =============================================================================
+# Admins can search the directory and provision users directly from the UI
+# without enabling auto-provision on login. The two endpoints below pair with
+# the new "LDAP" tab in the user-create modal.
+
+
+@router.get("/ldap/search", response_model=list[LDAPSearchResultResponse])
+async def search_ldap_directory(
+    q: str,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Search the LDAP directory for users matching `q`.
+
+    Returns up to 25 candidates. The query is matched (case-insensitively, with
+    wildcards on both sides) against sAMAccountName, uid, mail, displayName,
+    and cn — covering both AD and OpenLDAP layouts. Each result is annotated
+    with `already_provisioned` so the UI can grey out usernames that already
+    exist as BamBuddy users.
+
+    Requires USERS_CREATE permission. Minimum query length is 2 characters.
+    """
+    from sqlalchemy import func as sa_func
+
+    from backend.app.services.ldap_service import parse_ldap_config, search_ldap_users
+
+    query = q.strip()
+    if len(query) < 2:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Query must be at least 2 characters",
+        )
+
+    ldap_settings = await _get_ldap_settings(db)
+    if not ldap_settings:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="LDAP is not enabled",
+        )
+
+    config = parse_ldap_config(ldap_settings)
+    if not config:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="LDAP server URL is not configured",
+        )
+
+    try:
+        results = search_ldap_users(config, query, limit=25)
+    except Exception as e:
+        _logger.exception("LDAP directory search failed")
+        # Admin-only endpoint — surface the underlying reason so the operator
+        # can fix it (auth_middleware already restricted access to USERS_CREATE).
+        raise HTTPException(
+            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+            detail=f"LDAP search failed: {type(e).__name__}: {e}",
+        )
+
+    if not results:
+        return []
+
+    # Annotate `already_provisioned` so the SPA can dim/disable rows that map
+    # to an existing local row. Case-insensitive lookup mirrors create_user.
+    usernames_lower = [r.username.lower() for r in results]
+    existing_query = await db.execute(select(User.username).where(sa_func.lower(User.username).in_(usernames_lower)))
+    existing_lower = {str(name).lower() for name in existing_query.scalars().all()}
+
+    return [
+        LDAPSearchResultResponse(
+            username=r.username,
+            email=r.email,
+            display_name=r.display_name,
+            dn=r.dn,
+            already_provisioned=r.username.lower() in existing_lower,
+        )
+        for r in results
+    ]
+
+
+@router.post("/ldap/provision", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
+async def provision_ldap_user(
+    payload: LDAPProvisionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Provision a BamBuddy user from an existing LDAP directory entry.
+
+    Re-resolves the username via the service-account bind (rather than trusting
+    the request body) so group mappings and email come from a fresh LDAP read.
+    Applies the same group-mapping / default-group logic as the auto-provision
+    login path (`_provision_ldap_user`), so behavior stays identical regardless
+    of whether the user was created here or on first login.
+
+    Requires USERS_CREATE.
+    """
+    from sqlalchemy import func as sa_func
+
+    from backend.app.services.ldap_service import lookup_ldap_user, parse_ldap_config
+
+    username = payload.username.strip()
+    if not username:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Username is required",
+        )
+
+    ldap_settings = await _get_ldap_settings(db)
+    if not ldap_settings:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="LDAP is not enabled",
+        )
+
+    config = parse_ldap_config(ldap_settings)
+    if not config:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="LDAP server URL is not configured",
+        )
+
+    # Look up via service bind. Service-bind failures bubble up as 503; missing
+    # entries surface as 404 to distinguish "directory unreachable" from
+    # "username doesn't exist in the directory" in the UI.
+    try:
+        ldap_user = lookup_ldap_user(config, username)
+    except Exception as e:
+        _logger.exception("LDAP lookup failed during provision")
+        raise HTTPException(
+            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+            detail=f"LDAP lookup failed: {type(e).__name__}: {e}",
+        )
+
+    if ldap_user is None:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail=f"User '{username}' not found in LDAP directory",
+        )
+
+    # Reject duplicates — the canonical username from LDAP is what gets stored,
+    # so the conflict check uses that rather than the request payload.
+    existing = await db.execute(select(User).where(sa_func.lower(User.username) == sa_func.lower(ldap_user.username)))
+    existing_user = existing.scalar_one_or_none()
+    if existing_user is not None:
+        if existing_user.auth_source == "ldap":
+            detail = f"LDAP user '{ldap_user.username}' is already provisioned"
+        else:
+            detail = f"A local user with the username '{ldap_user.username}' already exists"
+        raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
+
+    new_user = await _provision_ldap_user(db, ldap_user, config)
+
+    # Reload with groups eagerly loaded so _user_to_response can serialize them
+    # without lazy-load warnings (matches create_user / list_users pattern).
+    result = await db.execute(select(User).where(User.id == new_user.id).options(selectinload(User.groups)))
+    new_user = result.scalar_one()
+    _logger.info("Manually provisioned LDAP user %s (id=%d)", new_user.username, new_user.id)
+    return _user_to_response(new_user)
+
+
 # =============================================================================
 # Long-lived camera-stream tokens (#1108)
 # =============================================================================

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

@@ -93,6 +93,24 @@ class UserResponse(BaseModel):
         from_attributes = True
 
 
+class LDAPSearchResultResponse(BaseModel):
+    """One match from GET /auth/ldap/search — surfaced in the admin UI."""
+
+    username: str
+    email: str | None = None
+    display_name: str | None = None
+    dn: str
+    already_provisioned: bool = False  # True if this username already exists as a BamBuddy user
+
+
+class LDAPProvisionRequest(BaseModel):
+    """Body for POST /auth/ldap/provision. Username is re-resolved via the
+    service-account bind, so the request only carries the directory username
+    the admin picked from the search results."""
+
+    username: str = Field(..., max_length=150)
+
+
 class ChangePasswordRequest(BaseModel):
     current_password: str = Field(..., max_length=256)  # M-NEW-3: cap before pbkdf2
     new_password: str = Field(..., min_length=8, max_length=256)

+ 209 - 65
backend/app/services/ldap_service.py

@@ -28,6 +28,16 @@ class LDAPUserInfo:
     groups: list[str]  # List of group DNs the user belongs to
 
 
+@dataclass
+class LDAPSearchResult:
+    """A directory user returned by the admin search endpoint (no auth performed)."""
+
+    username: str
+    email: str | None
+    display_name: str | None
+    dn: str
+
+
 @dataclass
 class LDAPConfig:
     """LDAP configuration parsed from settings."""
@@ -91,6 +101,104 @@ def _create_server(config: LDAPConfig) -> Server:
     return Server(config.server_url, use_ssl=use_ssl, tls=tls, get_info=ALL, connect_timeout=10)
 
 
+def _open_service_connection(config: LDAPConfig, server: Server, *, check_names: bool = True) -> Connection:
+    """Open and bind a service-account LDAP connection. Raises on failure.
+
+    `check_names` toggles ldap3's client-side attribute-name validation. The
+    default keeps it on so typos in `user_filter` fail loudly. The fuzzy
+    directory search disables it because its fixed OR filter spans both AD-only
+    (sAMAccountName, displayName) and OpenLDAP-only attribute names — without
+    this bypass ldap3 throws `LDAPAttributeError` before any request is sent
+    on a directory whose schema doesn't define one of the names.
+    """
+    conn = Connection(
+        server,
+        user=config.bind_dn,
+        password=config.bind_password,
+        auto_bind=False,
+        raise_exceptions=True,
+        read_only=True,
+        check_names=check_names,
+    )
+    conn.open()
+    if config.security == "starttls" and not config.server_url.startswith("ldaps://"):
+        conn.start_tls()
+    conn.bind()
+    return conn
+
+
+def _pick_canonical_username(entry, fallback: str) -> str:
+    """Prefer sAMAccountName, then uid, then the supplied fallback."""
+    if hasattr(entry, "sAMAccountName") and entry.sAMAccountName:
+        return str(entry.sAMAccountName)
+    if hasattr(entry, "uid") and entry.uid:
+        return str(entry.uid)
+    return fallback
+
+
+def _extract_user_info(
+    service_conn: Connection, config: LDAPConfig, user_entry, fallback_username: str
+) -> LDAPUserInfo:
+    """Build an LDAPUserInfo from an already-fetched directory entry.
+
+    Collects memberOf groups, POSIX memberUid groups, and the primary
+    gidNumber group; dedups DNs case-insensitively. Uses the supplied
+    service-bound connection to resolve POSIX groups.
+    """
+    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 []
+
+    canonical_username = _pick_canonical_username(user_entry, fallback_username)
+
+    # Also search for POSIX groups (memberUid-based) using the service account
+    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))
+
+    # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
+    # Standard Unix semantics treat this as full group membership, so we need
+    # to resolve it to a group DN alongside the memberUid results.
+    if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
+        primary_gid = str(user_entry.gidNumber)
+        primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+        service_conn.search(
+            search_base=config.search_base,
+            search_filter=primary_filter,
+            search_scope=SUBTREE,
+            attributes=["cn"],
+        )
+        for entry in service_conn.entries:
+            groups.append(str(entry.entry_dn))
+
+    # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
+    # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
+    seen_lower: set[str] = set()
+    deduped_groups: list[str] = []
+    for g in groups:
+        key = g.lower()
+        if key not in seen_lower:
+            seen_lower.add(key)
+            deduped_groups.append(g)
+
+    return LDAPUserInfo(
+        username=canonical_username,
+        email=email,
+        display_name=display_name,
+        groups=deduped_groups,
+    )
+
+
 def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) -> LDAPUserInfo | None:
     """Authenticate a user via LDAP bind.
 
@@ -105,20 +213,8 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
 
     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()
+        service_conn = _open_service_connection(config, server)
     except Exception as e:
         logger.warning("LDAP service account bind failed: %s", e)
         return None
@@ -159,70 +255,118 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
             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
+        info = _extract_user_info(service_conn, config, user_entry, username)
+        logger.info(
+            "LDAP authentication successful for user: %s (DN: %s, groups: %d)",
+            info.username,
+            user_dn,
+            len(info.groups),
         )
+        return info
+    finally:
+        service_conn.unbind()
 
-        # 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)
+def lookup_ldap_user(config: LDAPConfig, username: str) -> LDAPUserInfo | None:
+    """Look up a directory user by exact username via the service-account bind.
+
+    Performs no password verification — intended for the admin manual-provision
+    flow, where the caller has already been authenticated as a BamBuddy admin
+    and now needs the directory attributes (email, display name, group DNs)
+    to create the user.
 
-        posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
+    Uses the same `user_filter` template that the login path uses, so anything
+    that logs in successfully via auto-provision is also resolvable here.
+    """
+    server = _create_server(config)
+
+    try:
+        service_conn = _open_service_connection(config, server)
+    except Exception as e:
+        logger.warning("LDAP service account bind failed during lookup: %s", e)
+        raise
+
+    try:
+        search_filter = config.user_filter.replace("{username}", _ldap_escape(username))
         service_conn.search(
             search_base=config.search_base,
-            search_filter=posix_filter,
+            search_filter=search_filter,
             search_scope=SUBTREE,
-            attributes=["cn"],
+            attributes=["*"],
         )
-        for entry in service_conn.entries:
-            groups.append(str(entry.entry_dn))
+        if not service_conn.entries:
+            logger.info("LDAP lookup: user not found: %s", username)
+            return None
+        return _extract_user_info(service_conn, config, service_conn.entries[0], username)
+    finally:
+        service_conn.unbind()
 
-        # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
-        # Standard Unix semantics treat this as full group membership, so we need
-        # to resolve it to a group DN alongside the memberUid results.
-        if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
-            primary_gid = str(user_entry.gidNumber)
-            primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
-            service_conn.search(
-                search_base=config.search_base,
-                search_filter=primary_filter,
-                search_scope=SUBTREE,
-                attributes=["cn"],
-            )
-            for entry in service_conn.entries:
-                groups.append(str(entry.entry_dn))
-
-        # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
-        # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
-        seen_lower: set[str] = set()
-        deduped_groups: list[str] = []
-        for g in groups:
-            key = g.lower()
-            if key not in seen_lower:
-                seen_lower.add(key)
-                deduped_groups.append(g)
-        groups = deduped_groups
 
-        logger.info(
-            "LDAP authentication successful for user: %s (DN: %s, groups: %d)", canonical_username, user_dn, len(groups)
-        )
+def search_ldap_users(config: LDAPConfig, query: str, limit: int = 25) -> list[LDAPSearchResult]:
+    """Fuzzy search the directory for users matching `query`.
 
-        return LDAPUserInfo(
-            username=canonical_username,
-            email=email,
-            display_name=display_name,
-            groups=groups,
+    Uses a fixed OR filter across sAMAccountName, uid, mail, displayName, and
+    cn — covering both Active Directory and OpenLDAP layouts. The query is
+    RFC-4515 escaped so a typed `*` doesn't enumerate the whole directory.
+    Returns up to `limit` results (default 25). Service-bind failures raise so
+    the caller can surface a 503; "no matches" returns an empty list.
+
+    Callers should enforce a minimum query length (≥2 chars) — short queries
+    against a large directory are wasteful and effectively unbounded.
+    """
+    query = query.strip()
+    if len(query) < 2:
+        return []
+
+    escaped = _ldap_escape(query)
+    search_filter = (
+        f"(|(sAMAccountName=*{escaped}*)(uid=*{escaped}*)(mail=*{escaped}*)(displayName=*{escaped}*)(cn=*{escaped}*))"
+    )
+
+    server = _create_server(config)
+
+    try:
+        # check_names=False so OpenLDAP directories (no sAMAccountName/displayName
+        # in schema) don't reject the cross-schema OR filter — see helper docstring.
+        service_conn = _open_service_connection(config, server, check_names=False)
+    except Exception as e:
+        logger.warning("LDAP service account bind failed during search: %s", e)
+        raise
+
+    try:
+        # attributes=["*"] requests all user attributes. We can't enumerate the
+        # AD/OpenLDAP-specific names (sAMAccountName, displayName) explicitly
+        # because ldap3 validates the attribute list against the server schema
+        # even with check_names=False — and OpenLDAP rejects the AD names. The
+        # `*` wildcard is hardcoded in ldap3's ATTRIBUTES_EXCLUDED_FROM_CHECK so
+        # it bypasses that validation, and the server returns whatever it has.
+        service_conn.search(
+            search_base=config.search_base,
+            search_filter=search_filter,
+            search_scope=SUBTREE,
+            attributes=["*"],
+            size_limit=limit,
         )
+        results: list[LDAPSearchResult] = []
+        for entry in service_conn.entries:
+            username = _pick_canonical_username(entry, "")
+            if not username and hasattr(entry, "cn") and entry.cn:
+                # Last resort — some OpenLDAP layouts only have cn
+                username = str(entry.cn)
+            if not username:
+                continue
+            email = str(entry.mail) if hasattr(entry, "mail") and entry.mail else None
+            display_name = str(entry.displayName) if hasattr(entry, "displayName") and entry.displayName else None
+            results.append(
+                LDAPSearchResult(
+                    username=username,
+                    email=email,
+                    display_name=display_name,
+                    dn=str(entry.entry_dn),
+                )
+            )
+        logger.info("LDAP directory search for %r returned %d result(s)", query, len(results))
+        return results
     finally:
         service_conn.unbind()
 

+ 359 - 0
backend/tests/integration/test_ldap_provision.py

@@ -0,0 +1,359 @@
+"""Integration tests for the manual LDAP user provisioning routes (#1298).
+
+Reporter @Fuechslein noted that BamBuddy forced admins to leave auto-provision
+on because there was no UI path to create an LDAP user by hand. The new
+endpoints are GET /auth/ldap/search (admin types a partial name, picks a
+candidate) and POST /auth/ldap/provision (server re-resolves and creates the
+user).
+
+These tests cover:
+
+- Permission gating (only USERS_CREATE can search/provision)
+- LDAP-disabled and short-query rejections
+- Service-unreachable surfaces as 503, not 200 empty
+- Provision creates the user with auth_source=ldap, password_hash=None
+- Provision applies the same group mapping as the auto-provision login path
+- Duplicate-username protection (409 with explanation)
+"""
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.ldap_service import LDAPSearchResult, LDAPUserInfo
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+async def _seed_ldap_settings(db: AsyncSession, **overrides) -> None:
+    """Write a minimal but valid LDAP config to the settings table."""
+    defaults = {
+        "ldap_enabled": "true",
+        "ldap_server_url": "ldaps://ldap.test.example:636",
+        "ldap_bind_dn": "cn=admin,dc=test,dc=com",
+        "ldap_bind_password": "x",  # pragma: allowlist secret — test fixture
+        "ldap_search_base": "dc=test,dc=com",
+        "ldap_user_filter": "(uid={username})",
+        "ldap_security": "ldaps",
+        "ldap_group_mapping": "{}",
+        "ldap_auto_provision": "false",
+        "ldap_ca_cert_path": "",
+        "ldap_default_group": "",
+    }
+    defaults.update(overrides)
+    for key, value in defaults.items():
+        db.add(Settings(key=key, value=value))
+    await db.commit()
+
+
+@pytest.fixture
+async def admin_token(async_client: AsyncClient) -> str:
+    """Enable auth, create an admin, return a valid bearer token."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "ldapadmin",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await async_client.post(
+        "/api/v1/auth/login",
+        json={"username": "ldapadmin", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+# ---------------------------------------------------------------------------
+# /auth/ldap/search
+# ---------------------------------------------------------------------------
+
+
+class TestLdapSearchRoute:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_requires_auth(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Anonymous access is rejected when auth is enabled."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": True, "admin_username": "x", "admin_password": "AdminPass1!"},
+        )
+
+        response = await async_client.get("/api/v1/auth/ldap/search?q=jdoe")
+
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_short_query(self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession):
+        """Single-char queries would be effectively unbounded against a large directory."""
+        await _seed_ldap_settings(db_session)
+
+        response = await async_client.get(
+            "/api/v1/auth/ldap/search?q=j",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+
+        assert response.status_code == 400
+        assert "at least 2 characters" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_when_ldap_disabled(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """No LDAP config in settings → 400 with a clear message."""
+        response = await async_client.get(
+            "/api/v1/auth/ldap/search?q=jdoe",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+
+        assert response.status_code == 400
+        assert "LDAP is not enabled" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_surfaces_unreachable_as_503(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """When the underlying search fails (network/auth), the admin gets 503 — not
+        a silent empty list (which would look like 'no matches')."""
+        await _seed_ldap_settings(db_session)
+
+        with patch(
+            "backend.app.services.ldap_service.search_ldap_users",
+            side_effect=RuntimeError("simulated outage"),
+        ):
+            response = await async_client.get(
+                "/api/v1/auth/ldap/search?q=jdoe",
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 503
+        # Detail now includes the underlying exception class + message so the
+        # admin can see why (e.g. "LDAP search failed: RuntimeError: simulated outage").
+        detail = response.json()["detail"].lower()
+        assert "ldap search failed" in detail
+        assert "simulated outage" in detail
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_results_annotated_with_already_provisioned(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """Results that match an existing local row must come back with the flag set."""
+        await _seed_ldap_settings(db_session)
+
+        # Seed an existing local user that shares a username with one LDAP result.
+        db_session.add(User(username="existing", email="x@test.com", password_hash="$x$", role="user"))
+        await db_session.commit()
+
+        fake_results = [
+            LDAPSearchResult(
+                username="jdoe",
+                email="jdoe@test.com",
+                display_name="John Doe",
+                dn="cn=John Doe,dc=test,dc=com",
+            ),
+            LDAPSearchResult(
+                username="existing",
+                email="existing@test.com",
+                display_name="Already Provisioned",
+                dn="cn=existing,dc=test,dc=com",
+            ),
+        ]
+
+        with patch(
+            "backend.app.services.ldap_service.search_ldap_users",
+            return_value=fake_results,
+        ):
+            response = await async_client.get(
+                "/api/v1/auth/ldap/search?q=jdoe",
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 200
+        body = response.json()
+        assert len(body) == 2
+        by_user = {r["username"]: r for r in body}
+        assert by_user["jdoe"]["already_provisioned"] is False
+        assert by_user["existing"]["already_provisioned"] is True
+
+
+# ---------------------------------------------------------------------------
+# /auth/ldap/provision
+# ---------------------------------------------------------------------------
+
+
+class TestLdapProvisionRoute:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_requires_auth(self, async_client: AsyncClient):
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": True, "admin_username": "x", "admin_password": "AdminPass1!"},
+        )
+
+        response = await async_client.post(
+            "/api/v1/auth/ldap/provision",
+            json={"username": "jdoe"},
+        )
+
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_404_when_directory_lookup_misses(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        await _seed_ldap_settings(db_session)
+
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=None):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "nobody"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 404
+        assert "not found in LDAP directory" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_409_when_local_user_exists(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """A local user with the same username must block provision — the admin has
+        to resolve the collision manually rather than silently coexisting."""
+        await _seed_ldap_settings(db_session)
+
+        db_session.add(User(username="jdoe", password_hash="$x$", role="user", auth_source="local"))
+        await db_session.commit()
+
+        fake_ldap = LDAPUserInfo(username="jdoe", email="jdoe@test.com", display_name=None, groups=[])
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "jdoe"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 409
+        assert "local user" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_409_when_already_provisioned(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """Re-provisioning an existing LDAP user must give a distinct error so the
+        UI can suggest 'they exist already, just have them log in' rather than
+        the more alarming 'local conflict' message."""
+        await _seed_ldap_settings(db_session)
+
+        db_session.add(User(username="alice", password_hash=None, role="user", auth_source="ldap"))
+        await db_session.commit()
+
+        fake_ldap = LDAPUserInfo(username="alice", email="alice@test.com", display_name=None, groups=[])
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "alice"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 409
+        assert "already provisioned" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_503_when_directory_unreachable(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        await _seed_ldap_settings(db_session)
+
+        with patch(
+            "backend.app.services.ldap_service.lookup_ldap_user",
+            side_effect=RuntimeError("simulated outage"),
+        ):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "jdoe"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 503
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_happy_path_creates_user_with_ldap_auth_source(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """Verifies the full provision: response shape + DB state."""
+        await _seed_ldap_settings(db_session)
+
+        fake_ldap = LDAPUserInfo(
+            username="newuser",
+            email="newuser@test.com",
+            display_name="New User",
+            groups=[],
+        )
+
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "newuser"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 201
+        body = response.json()
+        assert body["username"] == "newuser"
+        assert body["email"] == "newuser@test.com"
+        assert body["auth_source"] == "ldap"
+
+        # Verify DB state: password_hash MUST be None (LDAP has no local credential)
+        from sqlalchemy import select
+
+        row = (await db_session.execute(select(User).where(User.username == "newuser"))).scalar_one()
+        assert row.auth_source == "ldap"
+        assert row.password_hash is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_happy_path_applies_group_mapping(
+        self, async_client: AsyncClient, admin_token: str, db_session: AsyncSession
+    ):
+        """Provision must run the same group-mapping logic as the auto-provision
+        login path — so an admin who provisions Alice gets the exact same group
+        memberships as if Alice had logged in herself with auto-provision on."""
+        await _seed_ldap_settings(
+            db_session,
+            ldap_group_mapping='{"cn=staff,ou=groups,dc=test,dc=com": "Operators"}',
+        )
+
+        # Operators group is auto-seeded by the test harness — no need to create it.
+        fake_ldap = LDAPUserInfo(
+            username="alice",
+            email="alice@test.com",
+            display_name="Alice",
+            groups=["cn=staff,ou=groups,dc=test,dc=com"],
+        )
+
+        with patch("backend.app.services.ldap_service.lookup_ldap_user", return_value=fake_ldap):
+            response = await async_client.post(
+                "/api/v1/auth/ldap/provision",
+                json={"username": "alice"},
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+
+        assert response.status_code == 201
+        body = response.json()
+        group_names = {g["name"] for g in body["groups"]}
+        assert "Operators" in group_names

+ 192 - 1
backend/tests/unit/services/test_ldap_service.py

@@ -14,11 +14,14 @@ import pytest
 
 from backend.app.services.ldap_service import (
     LDAPConfig,
+    LDAPSearchResult,
     LDAPUserInfo,
     _ldap_escape,
     authenticate_ldap_user,
+    lookup_ldap_user,
     parse_ldap_config,
     resolve_group_mapping,
+    search_ldap_users,
 )
 
 
@@ -298,6 +301,7 @@ class _MockConnection:
     def __init__(self, *args, **kwargs):
         self.entries: list = []
         self.search_calls: list[str] = []
+        self.last_attrs: list | None = None
         _MockConnection._instances.append(self)
 
     def open(self):
@@ -312,8 +316,10 @@ class _MockConnection:
     def unbind(self):
         pass
 
-    def search(self, search_base=None, search_filter=None, search_scope=None, attributes=None):
+    def search(self, search_base=None, search_filter=None, search_scope=None, attributes=None, **kwargs):
+        # **kwargs absorbs ldap3 options like size_limit that the real client supports
         self.search_calls.append(search_filter or "")
+        self.last_attrs = list(attributes) if attributes is not None else None
         for needle, entries in _MockConnection._search_fixture.items():
             if needle in (search_filter or ""):
                 self.entries = entries
@@ -425,3 +431,188 @@ class TestAuthenticateLdapUserGroups:
         service_conn = _MockConnection._instances[0]
         gidnumber_searches = [call for call in service_conn.search_calls if "gidNumber=" in call]
         assert gidnumber_searches == []
+
+
+# ---------------------------------------------------------------------------
+# Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298)
+# ---------------------------------------------------------------------------
+
+
+class TestSearchLdapUsers:
+    """Admin directory search for the manual-provision flow."""
+
+    def test_returns_empty_when_query_too_short(self, mock_ldap):
+        """Queries under 2 chars must not hit the directory at all."""
+        results = search_ldap_users(_base_config(), "a")
+        assert results == []
+        # No connection was opened — no Connection instance recorded.
+        assert _MockConnection._instances == []
+
+    def test_returns_empty_when_query_whitespace(self, mock_ldap):
+        results = search_ldap_users(_base_config(), "   ")
+        assert results == []
+        assert _MockConnection._instances == []
+
+    def test_filter_covers_all_common_attributes(self, mock_ldap):
+        """The fixed OR filter must cover sAMAccountName, uid, mail, displayName, cn."""
+        _MockConnection._search_fixture = {}  # any matching attr; empty result is fine
+        search_ldap_users(_base_config(), "jdoe")
+
+        assert len(_MockConnection._instances) == 1
+        sent = _MockConnection._instances[0].search_calls[0]
+        for attr in ("sAMAccountName=*jdoe*", "uid=*jdoe*", "mail=*jdoe*", "displayName=*jdoe*", "cn=*jdoe*"):
+            assert attr in sent, f"filter missing {attr}: {sent}"
+
+    def test_wildcard_in_query_is_escaped(self, mock_ldap):
+        """A typed * in the query must not enumerate the whole directory."""
+        _MockConnection._search_fixture = {}
+        search_ldap_users(_base_config(), "j*")
+
+        sent = _MockConnection._instances[0].search_calls[0]
+        # _ldap_escape replaces * with \2a; the outer wildcards (from our filter)
+        # must remain, but the user-supplied * must be escaped.
+        assert "*j\\2a*" in sent
+
+    def test_picks_samaccountname_first(self, mock_ldap):
+        entry = _MockEntry(
+            "cn=John Doe,dc=test,dc=com",
+            sAMAccountName="jdoe",
+            uid="jdoe-uid",
+            mail="jdoe@test.com",
+            displayName="John Doe",
+            cn="John Doe",
+        )
+        _MockConnection._search_fixture = {"sAMAccountName=*jdoe*": [entry]}
+
+        results = search_ldap_users(_base_config(), "jdoe")
+
+        assert len(results) == 1
+        assert isinstance(results[0], LDAPSearchResult)
+        assert results[0].username == "jdoe"  # sAMAccountName preferred
+        assert results[0].email == "jdoe@test.com"
+        assert results[0].display_name == "John Doe"
+        assert results[0].dn == "cn=John Doe,dc=test,dc=com"
+
+    def test_falls_back_to_uid_when_no_samaccountname(self, mock_ldap):
+        entry = _MockEntry("uid=alice,ou=people,dc=test,dc=com", uid="alice", cn="Alice")
+        _MockConnection._search_fixture = {"uid=*alice*": [entry]}
+
+        results = search_ldap_users(_base_config(), "alice")
+
+        assert len(results) == 1
+        assert results[0].username == "alice"
+
+    def test_falls_back_to_cn_when_neither_samaccountname_nor_uid(self, mock_ldap):
+        """Some OpenLDAP layouts only have cn — make sure we still surface them."""
+        entry = _MockEntry("cn=Bob,ou=people,dc=test,dc=com", cn="Bob")
+        _MockConnection._search_fixture = {"cn=*Bob*": [entry]}
+
+        results = search_ldap_users(_base_config(), "Bob")
+
+        assert len(results) == 1
+        assert results[0].username == "Bob"
+
+    def test_raises_when_service_bind_fails(self, mock_ldap, monkeypatch):
+        """Bind failures must propagate so the route can return 503 instead of [] (which
+        would look indistinguishable from 'no matches found' to the admin)."""
+
+        class _BindFailConn(_MockConnection):
+            def bind(self):
+                raise RuntimeError("simulated bind failure")
+
+        monkeypatch.setattr("backend.app.services.ldap_service.Connection", _BindFailConn)
+
+        with pytest.raises(RuntimeError):
+            search_ldap_users(_base_config(), "anyone")
+
+    def test_connection_skips_client_side_attribute_validation(self, mock_ldap, monkeypatch):
+        """OpenLDAP directories don't define sAMAccountName/displayName in their schema,
+        so ldap3 would raise LDAPAttributeError client-side before sending the query
+        — break the regression by asserting Connection is opened with check_names=False
+        for directory search."""
+        captured_kwargs: dict = {}
+
+        class _CapturingConn(_MockConnection):
+            def __init__(self, *args, **kwargs):
+                captured_kwargs.update(kwargs)
+                super().__init__(*args, **kwargs)
+
+        monkeypatch.setattr("backend.app.services.ldap_service.Connection", _CapturingConn)
+
+        search_ldap_users(_base_config(), "anyone")
+
+        assert captured_kwargs.get("check_names") is False, (
+            "search_ldap_users must open the connection with check_names=False — "
+            "otherwise ldap3 rejects sAMAccountName/displayName on OpenLDAP schemas"
+        )
+
+    def test_requests_all_user_attributes_to_bypass_schema_check(self, mock_ldap):
+        """ldap3's `build_attribute_selection` validates each named attribute against
+        the server schema regardless of check_names; only the `*` wildcard is in
+        its hard-coded exclusion list. So search_ldap_users MUST request `["*"]`
+        — not the explicit AD-flavoured names — or OpenLDAP servers raise
+        `LDAPAttributeError: invalid attribute type in attribute list: sAMAccountName`."""
+        _MockConnection._search_fixture = {}
+        search_ldap_users(_base_config(), "anyone")
+
+        # The mock's search() captures search_filter in search_calls but not
+        # attributes — so monkeypatch its signature briefly to capture both.
+        # Easier: re-grep ldap3 here. The mock's search() accepts kwargs via
+        # **kwargs; we just need to verify the attributes arg was the wildcard.
+        sent_attrs = _MockConnection._instances[0].last_attrs  # set by patched search
+        assert sent_attrs == ["*"], (
+            f"Expected attributes=['*'] to bypass ldap3 schema validation; got {sent_attrs!r}. "
+            "Explicit AD attribute names (sAMAccountName, displayName) make ldap3 throw on "
+            "OpenLDAP directories whose schema doesn't define them."
+        )
+
+
+class TestLookupLdapUser:
+    """Service-bind lookup used by the manual-provision route."""
+
+    def test_returns_none_when_user_missing(self, mock_ldap):
+        _MockConnection._search_fixture = {}  # nothing matches
+
+        result = lookup_ldap_user(_base_config(), "nobody")
+
+        assert result is None
+
+    def test_returns_user_info_with_groups(self, mock_ldap):
+        user_entry = _MockEntry(
+            "cn=John Doe,dc=test,dc=com",
+            uid="jdoe",
+            mail="jdoe@test.com",
+            displayName="John Doe",
+            memberOf=["cn=ops,ou=groups,dc=test,dc=com", "cn=qa,ou=groups,dc=test,dc=com"],
+        )
+        _MockConnection._search_fixture = {"(uid=jdoe)": [user_entry]}
+
+        info = lookup_ldap_user(_base_config(), "jdoe")
+
+        assert info is not None
+        assert info.username == "jdoe"
+        assert info.email == "jdoe@test.com"
+        assert info.display_name == "John Doe"
+        assert set(info.groups) == {"cn=ops,ou=groups,dc=test,dc=com", "cn=qa,ou=groups,dc=test,dc=com"}
+
+    def test_does_not_attempt_password_bind(self, mock_ldap):
+        """lookup_ldap_user MUST NOT call the user-DN bind that authenticate_ldap_user
+        does — admins are using their own session, not the LDAP user's password."""
+        user_entry = _MockEntry("cn=jdoe,dc=test,dc=com", uid="jdoe")
+        _MockConnection._search_fixture = {"(uid=jdoe)": [user_entry]}
+
+        lookup_ldap_user(_base_config(), "jdoe")
+
+        # authenticate_ldap_user creates TWO Connection objects (service + user-bind).
+        # lookup_ldap_user must create only ONE.
+        assert len(_MockConnection._instances) == 1
+
+    def test_raises_when_service_bind_fails(self, mock_ldap, monkeypatch):
+        class _BindFailConn(_MockConnection):
+            def bind(self):
+                raise RuntimeError("simulated bind failure")
+
+        monkeypatch.setattr("backend.app.services.ldap_service.Connection", _BindFailConn)
+
+        with pytest.raises(RuntimeError):
+            lookup_ldap_user(_base_config(), "anyone")

+ 179 - 0
frontend/src/__tests__/components/LdapUserPicker.test.tsx

@@ -0,0 +1,179 @@
+/**
+ * Tests for LdapUserPicker (#1298).
+ *
+ * The picker is rendered inside the user-create modal when LDAP is enabled.
+ * It owns its own search + provision mutation; the parent modal just provides
+ * the onSuccess callback that closes the modal and toasts.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { LdapUserPicker } from '../../components/LdapUserPicker';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    searchLDAPDirectory: vi.fn(),
+    provisionLDAPUser: vi.fn(),
+    getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    getSettings: vi.fn().mockResolvedValue({}),
+  },
+}));
+
+describe('LdapUserPicker', () => {
+  beforeEach(() => {
+    vi.useFakeTimers({ shouldAdvanceTime: true });
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+    vi.clearAllMocks();
+  });
+
+  it('does not search until the user types at least 2 characters', async () => {
+    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+    render(<LdapUserPicker onSuccess={() => {}} />);
+
+    const input = screen.getByPlaceholderText(/type a username/i);
+    await user.type(input, 'a');
+
+    // Advance well past the debounce window — a 1-char query must still not fire.
+    await vi.advanceTimersByTimeAsync(1000);
+
+    expect(api.searchLDAPDirectory).not.toHaveBeenCalled();
+    expect(screen.getByText(/at least 2 characters/i)).toBeInTheDocument();
+  });
+
+  it('debounces typing and only sends the final query', async () => {
+    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+    (api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([]);
+
+    render(<LdapUserPicker onSuccess={() => {}} />);
+    const input = screen.getByPlaceholderText(/type a username/i);
+
+    await user.type(input, 'jdoe');
+    // After the last keystroke, the 300ms debounce hasn't elapsed yet — verify
+    // we haven't fired a request for an intermediate value like 'jd' or 'jdo'.
+    expect(api.searchLDAPDirectory).not.toHaveBeenCalled();
+
+    await vi.advanceTimersByTimeAsync(350);
+
+    await waitFor(() => {
+      expect(api.searchLDAPDirectory).toHaveBeenCalledTimes(1);
+      expect(api.searchLDAPDirectory).toHaveBeenCalledWith('jdoe');
+    });
+  });
+
+  it('renders search results and lets the admin select and provision one', async () => {
+    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+    (api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
+      {
+        username: 'jdoe',
+        email: 'jdoe@example.com',
+        display_name: 'John Doe',
+        dn: 'cn=John Doe,dc=example,dc=com',
+        already_provisioned: false,
+      },
+    ]);
+    (api.provisionLDAPUser as ReturnType<typeof vi.fn>).mockResolvedValue({
+      id: 42,
+      username: 'jdoe',
+      auth_source: 'ldap',
+      groups: [],
+      permissions: [],
+      role: 'user',
+      is_active: true,
+      is_admin: false,
+      email: 'jdoe@example.com',
+      created_at: '2026-05-15T10:00:00Z',
+    });
+
+    const onSuccess = vi.fn();
+    render(<LdapUserPicker onSuccess={onSuccess} />);
+
+    await user.type(screen.getByPlaceholderText(/type a username/i), 'jdoe');
+    await vi.advanceTimersByTimeAsync(350);
+
+    // Result list renders with the username + display name visible.
+    const resultRow = await screen.findByText('jdoe');
+    expect(resultRow).toBeInTheDocument();
+    expect(screen.getByText(/john doe/i)).toBeInTheDocument();
+
+    await user.click(resultRow);
+
+    // Submit button activates after selection. The label is "Provision user"
+    // — match it specifically so we don't accidentally select the "Provisioning..."
+    // loading variant.
+    const submit = screen.getByRole('button', { name: /^provision user$/i });
+    expect(submit).not.toBeDisabled();
+    await user.click(submit);
+
+    await waitFor(() => {
+      expect(api.provisionLDAPUser).toHaveBeenCalledWith('jdoe');
+      expect(onSuccess).toHaveBeenCalledTimes(1);
+      expect(onSuccess.mock.calls[0][0].username).toBe('jdoe');
+    });
+  });
+
+  it('disables already-provisioned rows so admins cannot pick them', async () => {
+    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+    (api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
+      {
+        username: 'existing',
+        email: 'existing@example.com',
+        display_name: null,
+        dn: 'cn=existing,dc=example,dc=com',
+        already_provisioned: true,
+      },
+    ]);
+
+    render(<LdapUserPicker onSuccess={() => {}} />);
+    await user.type(screen.getByPlaceholderText(/type a username/i), 'existing');
+    await vi.advanceTimersByTimeAsync(350);
+
+    await waitFor(() => {
+      expect(screen.getByText(/already provisioned/i)).toBeInTheDocument();
+    });
+
+    // The row's <button> is disabled — userEvent.click will throw, so we just
+    // assert the disabled attribute is set, which is the contract that drives
+    // the cursor + opacity styling.
+    const rowButton = screen.getByText('existing').closest('button')!;
+    expect(rowButton).toBeDisabled();
+
+    // The submit button stays disabled because there's no selectable row.
+    const submit = screen.getByRole('button', { name: /^provision user$/i });
+    expect(submit).toBeDisabled();
+  });
+
+  it('surfaces provision errors instead of swallowing them', async () => {
+    const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+    (api.searchLDAPDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([
+      {
+        username: 'jdoe',
+        email: null,
+        display_name: null,
+        dn: 'cn=jdoe,dc=example,dc=com',
+        already_provisioned: false,
+      },
+    ]);
+    (api.provisionLDAPUser as ReturnType<typeof vi.fn>).mockRejectedValue(
+      new Error('LDAP server unreachable')
+    );
+
+    const onSuccess = vi.fn();
+    render(<LdapUserPicker onSuccess={onSuccess} />);
+    await user.type(screen.getByPlaceholderText(/type a username/i), 'jdoe');
+    await vi.advanceTimersByTimeAsync(350);
+
+    await user.click(await screen.findByText('jdoe'));
+    await user.click(screen.getByRole('button', { name: /^provision user$/i }));
+
+    await waitFor(() => {
+      expect(screen.getByText(/ldap server unreachable/i)).toBeInTheDocument();
+    });
+    expect(onSuccess).not.toHaveBeenCalled();
+  });
+});

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

@@ -2911,6 +2911,14 @@ export interface LDAPTestResponse {
   message: string;
 }
 
+export interface LDAPSearchResult {
+  username: string;
+  email: string | null;
+  display_name: string | null;
+  dn: string;
+  already_provisioned: boolean;
+}
+
 export interface SetupResponse {
   auth_enabled: boolean;
   admin_created?: boolean;
@@ -2973,6 +2981,13 @@ export const api = {
     request<LDAPTestResponse>('/auth/ldap/test', {
       method: 'POST',
     }),
+  searchLDAPDirectory: (q: string) =>
+    request<LDAPSearchResult[]>(`/auth/ldap/search?q=${encodeURIComponent(q)}`),
+  provisionLDAPUser: (username: string) =>
+    request<UserResponse>('/auth/ldap/provision', {
+      method: 'POST',
+      body: JSON.stringify({ username }),
+    }),
   forgotPassword: (data: ForgotPasswordRequest) =>
     request<ForgotPasswordResponse>('/auth/forgot-password', {
       method: 'POST',

+ 76 - 19
frontend/src/components/CreateUserAdvancedAuthModal.tsx

@@ -1,9 +1,10 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { X, Plus, Loader2, Users as UsersIcon } from 'lucide-react';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
-import type { Group, UserCreate } from '../api/client';
+import { LdapUserPicker } from './LdapUserPicker';
+import type { Group, UserCreate, UserResponse } from '../api/client';
 
 interface AdvancedAuthFormData extends UserCreate {
   group_ids: number[];
@@ -19,8 +20,15 @@ interface CreateUserAdvancedAuthModalProps {
   onCreate: () => void;
   isCreating: boolean;
   isCreateButtonDisabled: boolean;
+  // When LDAP is enabled in settings, the modal shows a "LDAP" tab beside
+  // "Local"; the picker handles its own provision call and reports success
+  // back through onLdapProvisioned.
+  ldapEnabled?: boolean;
+  onLdapProvisioned?: (user: UserResponse) => void;
 }
 
+type Tab = 'local' | 'ldap';
+
 export function CreateUserAdvancedAuthModal({
   formData,
   setFormData,
@@ -29,8 +37,11 @@ export function CreateUserAdvancedAuthModal({
   onCreate,
   isCreating,
   isCreateButtonDisabled,
+  ldapEnabled = false,
+  onLdapProvisioned,
 }: CreateUserAdvancedAuthModalProps) {
   const { t } = useTranslation();
+  const [tab, setTab] = useState<Tab>('local');
 
   // Close modal on Escape key
   useEffect(() => {
@@ -80,6 +91,48 @@ export function CreateUserAdvancedAuthModal({
           </div>
         </CardHeader>
         <CardContent>
+          {ldapEnabled && (
+            <div
+              className="mb-4 flex items-center gap-1 p-1 bg-bambu-dark-secondary rounded-lg"
+              role="tablist"
+              aria-label={t('users.modal.tabsAriaLabel')}
+            >
+              <button
+                type="button"
+                role="tab"
+                aria-selected={tab === 'local'}
+                onClick={() => setTab('local')}
+                className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
+                  tab === 'local'
+                    ? 'bg-bambu-green/15 text-bambu-green'
+                    : 'text-bambu-gray hover:text-white'
+                }`}
+              >
+                {t('users.modal.localTab')}
+              </button>
+              <button
+                type="button"
+                role="tab"
+                aria-selected={tab === 'ldap'}
+                onClick={() => setTab('ldap')}
+                className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
+                  tab === 'ldap'
+                    ? 'bg-bambu-green/15 text-bambu-green'
+                    : 'text-bambu-gray hover:text-white'
+                }`}
+              >
+                {t('users.modal.ldapTab')}
+              </button>
+            </div>
+          )}
+
+          {tab === 'ldap' && ldapEnabled ? (
+            <LdapUserPicker
+              onSuccess={(user) => {
+                onLdapProvisioned?.(user);
+              }}
+            />
+          ) : (
           <div className="space-y-4">
             {/* Username Field */}
             <div>
@@ -148,8 +201,10 @@ export function CreateUserAdvancedAuthModal({
               </div>
             </div>
           </div>
+          )}
 
-          {/* Action Buttons */}
+          {/* Action Buttons — Cancel always shown; Create only on local tab
+              (LDAP picker has its own submit). */}
           <div className="mt-6 flex justify-end gap-3">
             <Button
               variant="secondary"
@@ -157,22 +212,24 @@ export function CreateUserAdvancedAuthModal({
             >
               {t('users.modal.cancel')}
             </Button>
-            <Button
-              onClick={onCreate}
-              disabled={isCreateButtonDisabled}
-            >
-              {isCreating ? (
-                <>
-                  <Loader2 className="w-4 h-4 animate-spin" />
-                  {t('users.modal.creating')}
-                </>
-              ) : (
-                <>
-                  <Plus className="w-4 h-4" />
-                  {t('users.modal.createUser')}
-                </>
-              )}
-            </Button>
+            {tab === 'local' && (
+              <Button
+                onClick={onCreate}
+                disabled={isCreateButtonDisabled}
+              >
+                {isCreating ? (
+                  <>
+                    <Loader2 className="w-4 h-4 animate-spin" />
+                    {t('users.modal.creating')}
+                  </>
+                ) : (
+                  <>
+                    <Plus className="w-4 h-4" />
+                    {t('users.modal.createUser')}
+                  </>
+                )}
+              </Button>
+            )}
           </div>
         </CardContent>
       </Card>

+ 236 - 0
frontend/src/components/LdapUserPicker.tsx

@@ -0,0 +1,236 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Loader2, Search, Plus, CheckCircle2 } from 'lucide-react';
+import { Button } from './Button';
+import { api } from '../api/client';
+import type { LDAPSearchResult, UserResponse } from '../api/client';
+
+interface LdapUserPickerProps {
+  onSuccess: (user: UserResponse) => void;
+}
+
+const SEARCH_DEBOUNCE_MS = 300;
+const MIN_QUERY_LENGTH = 2;
+
+export function LdapUserPicker({ onSuccess }: LdapUserPickerProps) {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const [rawQuery, setRawQuery] = useState('');
+  const [debouncedQuery, setDebouncedQuery] = useState('');
+  const [selectedDn, setSelectedDn] = useState<string | null>(null);
+  const [errorMessage, setErrorMessage] = useState<string | null>(null);
+
+  // Debounce keystrokes — the search hits the directory and we don't want a
+  // request per character. 300ms matches the debounce in other typeaheads in
+  // this app (e.g. file manager).
+  useEffect(() => {
+    const trimmed = rawQuery.trim();
+    if (trimmed.length < MIN_QUERY_LENGTH) {
+      setDebouncedQuery('');
+      return;
+    }
+    const id = setTimeout(() => setDebouncedQuery(trimmed), SEARCH_DEBOUNCE_MS);
+    return () => clearTimeout(id);
+  }, [rawQuery]);
+
+  // Reset selection when the query changes so a stale selection from a previous
+  // search can't be silently submitted.
+  useEffect(() => {
+    setSelectedDn(null);
+    setErrorMessage(null);
+  }, [debouncedQuery]);
+
+  const searchQuery = useQuery({
+    queryKey: ['ldap-search', debouncedQuery],
+    queryFn: () => api.searchLDAPDirectory(debouncedQuery),
+    enabled: debouncedQuery.length >= MIN_QUERY_LENGTH,
+    staleTime: 30_000,
+  });
+
+  const provisionMutation = useMutation({
+    mutationFn: (username: string) => api.provisionLDAPUser(username),
+    onSuccess: (user) => {
+      queryClient.invalidateQueries({ queryKey: ['users'] });
+      onSuccess(user);
+    },
+    onError: (error: Error) => {
+      setErrorMessage(error.message || t('users.modal.ldapErrorProvision'));
+    },
+  });
+
+  const selectedResult = useMemo(
+    () => searchQuery.data?.find((r) => r.dn === selectedDn) ?? null,
+    [searchQuery.data, selectedDn]
+  );
+
+  const isShortQuery = rawQuery.trim().length > 0 && rawQuery.trim().length < MIN_QUERY_LENGTH;
+  const isLoading = searchQuery.isFetching && debouncedQuery.length >= MIN_QUERY_LENGTH;
+  const hasResults = !!searchQuery.data && searchQuery.data.length > 0;
+  const showNoResults =
+    !isLoading && !!searchQuery.data && searchQuery.data.length === 0 && debouncedQuery.length >= MIN_QUERY_LENGTH;
+
+  const handleProvision = () => {
+    if (!selectedResult || selectedResult.already_provisioned) return;
+    setErrorMessage(null);
+    provisionMutation.mutate(selectedResult.username);
+  };
+
+  return (
+    <div className="space-y-4">
+      {/* Search input */}
+      <div>
+        <label className="block text-sm font-medium text-white mb-2">
+          {t('users.modal.ldapSearchLabel')}
+        </label>
+        <div className="relative">
+          <Search className="w-4 h-4 text-bambu-gray absolute left-3 top-1/2 -translate-y-1/2" />
+          <input
+            type="text"
+            value={rawQuery}
+            onChange={(e) => setRawQuery(e.target.value)}
+            className="w-full pl-9 pr-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"
+            placeholder={t('users.modal.ldapSearchPlaceholder')}
+            autoComplete="off"
+          />
+        </div>
+        {isShortQuery && (
+          <p className="mt-1 text-xs text-bambu-gray">{t('users.modal.ldapMinChars')}</p>
+        )}
+      </div>
+
+      {/* Results panel */}
+      <div className="min-h-[8rem] max-h-64 overflow-y-auto bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+        {isLoading && (
+          <div className="flex items-center justify-center py-8 text-bambu-gray">
+            <Loader2 className="w-4 h-4 animate-spin mr-2" />
+            <span>{t('users.modal.ldapSearching')}</span>
+          </div>
+        )}
+
+        {showNoResults && (
+          <div className="flex items-center justify-center py-8 text-bambu-gray text-sm">
+            {t('users.modal.ldapNoResults')}
+          </div>
+        )}
+
+        {searchQuery.isError && (
+          <div className="px-3 py-4 text-sm text-red-400">
+            {searchQuery.error instanceof Error ? searchQuery.error.message : t('users.modal.ldapSearchError')}
+          </div>
+        )}
+
+        {!isLoading && hasResults && (
+          <ul className="divide-y divide-bambu-dark-tertiary">
+            {searchQuery.data!.map((result) => (
+              <LdapResultRow
+                key={result.dn}
+                result={result}
+                selected={selectedDn === result.dn}
+                onSelect={() => setSelectedDn(result.dn)}
+              />
+            ))}
+          </ul>
+        )}
+
+        {!isLoading && !searchQuery.data && !searchQuery.isError && (
+          <div className="flex items-center justify-center py-8 text-bambu-gray text-sm">
+            {t('users.modal.ldapTypeToSearch')}
+          </div>
+        )}
+      </div>
+
+      {/* Selected user summary */}
+      {selectedResult && (
+        <div className="bg-bambu-dark-secondary/50 border border-bambu-green/20 rounded-lg p-3 space-y-1">
+          <p className="text-sm text-white">
+            <span className="text-bambu-gray">{t('users.modal.ldapSelectedLabel')}: </span>
+            <span className="font-medium">{selectedResult.username}</span>
+            {selectedResult.display_name && (
+              <span className="text-bambu-gray"> — {selectedResult.display_name}</span>
+            )}
+          </p>
+          {selectedResult.email && (
+            <p className="text-xs text-bambu-gray">{selectedResult.email}</p>
+          )}
+          <p className="text-xs text-bambu-gray break-all">{selectedResult.dn}</p>
+        </div>
+      )}
+
+      {/* Error from the provision mutation */}
+      {errorMessage && (
+        <div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
+          <p className="text-sm text-red-400">{errorMessage}</p>
+        </div>
+      )}
+
+      {/* Submit button */}
+      <div className="flex justify-end">
+        <Button
+          onClick={handleProvision}
+          disabled={
+            !selectedResult || selectedResult.already_provisioned || provisionMutation.isPending
+          }
+        >
+          {provisionMutation.isPending ? (
+            <>
+              <Loader2 className="w-4 h-4 animate-spin" />
+              {t('users.modal.ldapProvisioning')}
+            </>
+          ) : (
+            <>
+              <Plus className="w-4 h-4" />
+              {t('users.modal.ldapProvision')}
+            </>
+          )}
+        </Button>
+      </div>
+    </div>
+  );
+}
+
+interface LdapResultRowProps {
+  result: LDAPSearchResult;
+  selected: boolean;
+  onSelect: () => void;
+}
+
+function LdapResultRow({ result, selected, onSelect }: LdapResultRowProps) {
+  const { t } = useTranslation();
+  const disabled = result.already_provisioned;
+
+  return (
+    <li>
+      <button
+        type="button"
+        onClick={onSelect}
+        disabled={disabled}
+        className={`w-full text-left px-3 py-2 flex items-center gap-3 transition-colors ${
+          disabled
+            ? 'opacity-50 cursor-not-allowed'
+            : selected
+              ? 'bg-bambu-green/10'
+              : 'hover:bg-bambu-dark-tertiary'
+        }`}
+      >
+        <div className="flex-1 min-w-0">
+          <p className="text-sm text-white truncate">
+            <span className="font-medium">{result.username}</span>
+            {result.display_name && (
+              <span className="text-bambu-gray"> — {result.display_name}</span>
+            )}
+          </p>
+          {result.email && (
+            <p className="text-xs text-bambu-gray truncate">{result.email}</p>
+          )}
+        </div>
+        {disabled && (
+          <span className="flex items-center gap-1 text-xs text-bambu-gray whitespace-nowrap">
+            <CheckCircle2 className="w-3.5 h-3.5" />
+            {t('users.modal.ldapAlreadyProvisioned')}
+          </span>
+        )}
+      </button>
+    </li>
+  );
+}

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

@@ -2691,6 +2691,7 @@ export default {
       fillRequired: 'Bitte füllen Sie alle Pflichtfelder aus',
       passwordsDoNotMatch: 'Passwörter stimmen nicht überein',
       passwordTooShort: 'Passwort muss mindestens 6 Zeichen lang sein',
+      ldapProvisioned: 'LDAP-Benutzer „{{username}}" bereitgestellt',
     },
     modal: {
       createUser: 'Benutzer erstellen',
@@ -2700,6 +2701,22 @@ export default {
       saving: 'Speichern...',
       saveChanges: 'Änderungen speichern',
       advancedAuthSubtitle: 'mit erweiterter Authentifizierung',
+      // Manuelle LDAP-Bereitstellung (#1298)
+      tabsAriaLabel: 'Benutzerquelle',
+      localTab: 'Lokal',
+      ldapTab: 'LDAP',
+      ldapSearchLabel: 'Verzeichnis durchsuchen',
+      ldapSearchPlaceholder: 'Benutzername, Name oder E-Mail eingeben...',
+      ldapMinChars: 'Mindestens 2 Zeichen für die Suche eingeben',
+      ldapTypeToSearch: 'Tippen, um das LDAP-Verzeichnis zu durchsuchen',
+      ldapSearching: 'Verzeichnis wird durchsucht...',
+      ldapNoResults: 'Keine passenden Benutzer im Verzeichnis',
+      ldapSearchError: 'Verzeichnissuche fehlgeschlagen. Bitte LDAP-Server-Status prüfen.',
+      ldapAlreadyProvisioned: 'Bereits bereitgestellt',
+      ldapSelectedLabel: 'Ausgewählt',
+      ldapProvision: 'Benutzer bereitstellen',
+      ldapProvisioning: 'Wird bereitgestellt...',
+      ldapErrorProvision: 'Bereitstellung fehlgeschlagen. Bitte LDAP-Server-Status prüfen und erneut versuchen.',
     },
     form: {
       username: 'Benutzername',

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

@@ -2694,6 +2694,7 @@ export default {
       fillRequired: 'Please fill in all required fields',
       passwordsDoNotMatch: 'Passwords do not match',
       passwordTooShort: 'Password must be at least 6 characters',
+      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
     },
     modal: {
       createUser: 'Create User',
@@ -2703,6 +2704,22 @@ export default {
       saving: 'Saving...',
       saveChanges: 'Save Changes',
       advancedAuthSubtitle: 'with Advanced Authentication',
+      // LDAP manual provisioning (#1298)
+      tabsAriaLabel: 'User source',
+      localTab: 'Local',
+      ldapTab: 'LDAP',
+      ldapSearchLabel: 'Search directory',
+      ldapSearchPlaceholder: 'Type a username, name, or email...',
+      ldapMinChars: 'Type at least 2 characters to search',
+      ldapTypeToSearch: 'Start typing to search the LDAP directory',
+      ldapSearching: 'Searching directory...',
+      ldapNoResults: 'No matching users in the directory',
+      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
+      ldapAlreadyProvisioned: 'Already provisioned',
+      ldapSelectedLabel: 'Selected',
+      ldapProvision: 'Provision user',
+      ldapProvisioning: 'Provisioning...',
+      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
     },
     form: {
       username: 'Username',

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

@@ -2680,6 +2680,7 @@ export default {
       fillRequired: 'Remplissez les champs requis',
       passwordsDoNotMatch: 'Les mots de passe ne correspondent pas',
       passwordTooShort: 'Minimum 6 caractères',
+      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
     },
     modal: {
       createUser: 'Créer utilisateur',
@@ -2689,6 +2690,22 @@ export default {
       saving: 'Enregistrement...',
       saveChanges: 'Enregistrer',
       advancedAuthSubtitle: 'avec Authentification Avancée',
+      // Manual LDAP provisioning (#1298) — English fallbacks
+      tabsAriaLabel: 'User source',
+      localTab: 'Local',
+      ldapTab: 'LDAP',
+      ldapSearchLabel: 'Search directory',
+      ldapSearchPlaceholder: 'Type a username, name, or email...',
+      ldapMinChars: 'Type at least 2 characters to search',
+      ldapTypeToSearch: 'Start typing to search the LDAP directory',
+      ldapSearching: 'Searching directory...',
+      ldapNoResults: 'No matching users in the directory',
+      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
+      ldapAlreadyProvisioned: 'Already provisioned',
+      ldapSelectedLabel: 'Selected',
+      ldapProvision: 'Provision user',
+      ldapProvisioning: 'Provisioning...',
+      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
     },
     form: {
       username: 'Utilisateur',

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

@@ -2679,6 +2679,7 @@ export default {
       fillRequired: 'Compila tutti i campi obbligatori',
       passwordsDoNotMatch: 'Le password non coincidono',
       passwordTooShort: 'La password deve essere di almeno 6 caratteri',
+      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
     },
     modal: {
       createUser: 'Crea utente',
@@ -2688,6 +2689,22 @@ export default {
       saving: 'Salvataggio...',
       saveChanges: 'Salva modifiche',
       advancedAuthSubtitle: 'con autenticazione avanzata',
+      // Manual LDAP provisioning (#1298) — English fallbacks
+      tabsAriaLabel: 'User source',
+      localTab: 'Local',
+      ldapTab: 'LDAP',
+      ldapSearchLabel: 'Search directory',
+      ldapSearchPlaceholder: 'Type a username, name, or email...',
+      ldapMinChars: 'Type at least 2 characters to search',
+      ldapTypeToSearch: 'Start typing to search the LDAP directory',
+      ldapSearching: 'Searching directory...',
+      ldapNoResults: 'No matching users in the directory',
+      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
+      ldapAlreadyProvisioned: 'Already provisioned',
+      ldapSelectedLabel: 'Selected',
+      ldapProvision: 'Provision user',
+      ldapProvisioning: 'Provisioning...',
+      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
     },
     form: {
       username: 'Nome utente',

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

@@ -2691,6 +2691,7 @@ export default {
       fillRequired: '必須項目をすべて入力してください',
       passwordsDoNotMatch: 'パスワードが一致しません',
       passwordTooShort: 'パスワードは6文字以上必要です',
+      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
     },
     modal: {
       createUser: 'ユーザーを作成',
@@ -2700,6 +2701,22 @@ export default {
       saving: '保存中...',
       saveChanges: '変更を保存',
       advancedAuthSubtitle: '高度な認証を使用',
+      // Manual LDAP provisioning (#1298) — English fallbacks
+      tabsAriaLabel: 'User source',
+      localTab: 'Local',
+      ldapTab: 'LDAP',
+      ldapSearchLabel: 'Search directory',
+      ldapSearchPlaceholder: 'Type a username, name, or email...',
+      ldapMinChars: 'Type at least 2 characters to search',
+      ldapTypeToSearch: 'Start typing to search the LDAP directory',
+      ldapSearching: 'Searching directory...',
+      ldapNoResults: 'No matching users in the directory',
+      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
+      ldapAlreadyProvisioned: 'Already provisioned',
+      ldapSelectedLabel: 'Selected',
+      ldapProvision: 'Provision user',
+      ldapProvisioning: 'Provisioning...',
+      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
     },
     form: {
       username: 'ユーザー名',

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

@@ -2679,6 +2679,7 @@ export default {
       fillRequired: 'Por favor, preencha todos os campos obrigatórios',
       passwordsDoNotMatch: 'As senhas não coincidem',
       passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
     },
     modal: {
       createUser: 'Criar Usuário',
@@ -2688,6 +2689,22 @@ export default {
       saving: 'Salvando...',
       saveChanges: 'Salvar Alterações',
       advancedAuthSubtitle: 'com Autenticação Avançada',
+      // Manual LDAP provisioning (#1298) — English fallbacks
+      tabsAriaLabel: 'User source',
+      localTab: 'Local',
+      ldapTab: 'LDAP',
+      ldapSearchLabel: 'Search directory',
+      ldapSearchPlaceholder: 'Type a username, name, or email...',
+      ldapMinChars: 'Type at least 2 characters to search',
+      ldapTypeToSearch: 'Start typing to search the LDAP directory',
+      ldapSearching: 'Searching directory...',
+      ldapNoResults: 'No matching users in the directory',
+      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
+      ldapAlreadyProvisioned: 'Already provisioned',
+      ldapSelectedLabel: 'Selected',
+      ldapProvision: 'Provision user',
+      ldapProvisioning: 'Provisioning...',
+      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
     },
     form: {
       username: 'Nome de Usuário',

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

@@ -2679,6 +2679,7 @@ export default {
       fillRequired: '请填写所有必填字段',
       passwordsDoNotMatch: '密码不匹配',
       passwordTooShort: '密码至少需要 6 个字符',
+      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
     },
     modal: {
       createUser: '创建用户',
@@ -2688,6 +2689,22 @@ export default {
       saving: '保存中...',
       saveChanges: '保存更改',
       advancedAuthSubtitle: '使用高级认证',
+      // Manual LDAP provisioning (#1298) — English fallbacks
+      tabsAriaLabel: 'User source',
+      localTab: 'Local',
+      ldapTab: 'LDAP',
+      ldapSearchLabel: 'Search directory',
+      ldapSearchPlaceholder: 'Type a username, name, or email...',
+      ldapMinChars: 'Type at least 2 characters to search',
+      ldapTypeToSearch: 'Start typing to search the LDAP directory',
+      ldapSearching: 'Searching directory...',
+      ldapNoResults: 'No matching users in the directory',
+      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
+      ldapAlreadyProvisioned: 'Already provisioned',
+      ldapSelectedLabel: 'Selected',
+      ldapProvision: 'Provision user',
+      ldapProvisioning: 'Provisioning...',
+      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
     },
     form: {
       username: '用户名',

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

@@ -2679,6 +2679,7 @@ export default {
       fillRequired: '請填寫所有必填欄位',
       passwordsDoNotMatch: '密碼不符',
       passwordTooShort: '密碼至少需要 6 個字元',
+      ldapProvisioned: 'Provisioned LDAP user "{{username}}"',
     },
     modal: {
       createUser: '建立使用者',
@@ -2688,6 +2689,22 @@ export default {
       saving: '儲存中...',
       saveChanges: '儲存更改',
       advancedAuthSubtitle: '使用進階認證',
+      // Manual LDAP provisioning (#1298) — English fallbacks
+      tabsAriaLabel: 'User source',
+      localTab: 'Local',
+      ldapTab: 'LDAP',
+      ldapSearchLabel: 'Search directory',
+      ldapSearchPlaceholder: 'Type a username, name, or email...',
+      ldapMinChars: 'Type at least 2 characters to search',
+      ldapTypeToSearch: 'Start typing to search the LDAP directory',
+      ldapSearching: 'Searching directory...',
+      ldapNoResults: 'No matching users in the directory',
+      ldapSearchError: 'Directory search failed. Check the LDAP server status.',
+      ldapAlreadyProvisioned: 'Already provisioned',
+      ldapSelectedLabel: 'Selected',
+      ldapProvision: 'Provision user',
+      ldapProvisioning: 'Provisioning...',
+      ldapErrorProvision: 'Provisioning failed. Check the LDAP server status and try again.',
     },
     form: {
       username: '使用者名稱',

+ 71 - 0
frontend/src/pages/SettingsPage.tsx

@@ -21,6 +21,7 @@ import { NotificationTemplateEditor } from '../components/NotificationTemplateEd
 import { NotificationLogViewer } from '../components/NotificationLogViewer';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
+import { LdapUserPicker } from '../components/LdapUserPicker';
 import { SpoolmanSettings } from '../components/SpoolmanSettings';
 import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
 import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
@@ -219,6 +220,8 @@ export function SettingsPage() {
 
   // User management state
   const [showCreateUserModal, setShowCreateUserModal] = useState(false);
+  // Local / LDAP tab inside the create-user modal (#1298).
+  const [createUserTab, setCreateUserTab] = useState<'local' | 'ldap'>('local');
   const [showEditUserModal, setShowEditUserModal] = useState(false);
   const [editingUserId, setEditingUserId] = useState<number | null>(null);
   const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
@@ -5304,6 +5307,66 @@ export function SettingsPage() {
               </div>
             </CardHeader>
             <CardContent>
+              {ldapStatus?.ldap_enabled && (
+                <div
+                  className="mb-4 flex items-center gap-1 p-1 bg-bambu-dark-secondary rounded-lg"
+                  role="tablist"
+                  aria-label={t('users.modal.tabsAriaLabel')}
+                >
+                  <button
+                    type="button"
+                    role="tab"
+                    aria-selected={createUserTab === 'local'}
+                    onClick={() => setCreateUserTab('local')}
+                    className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
+                      createUserTab === 'local'
+                        ? 'bg-bambu-green/15 text-bambu-green'
+                        : 'text-bambu-gray hover:text-white'
+                    }`}
+                  >
+                    {t('users.modal.localTab')}
+                  </button>
+                  <button
+                    type="button"
+                    role="tab"
+                    aria-selected={createUserTab === 'ldap'}
+                    onClick={() => setCreateUserTab('ldap')}
+                    className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
+                      createUserTab === 'ldap'
+                        ? 'bg-bambu-green/15 text-bambu-green'
+                        : 'text-bambu-gray hover:text-white'
+                    }`}
+                  >
+                    {t('users.modal.ldapTab')}
+                  </button>
+                </div>
+              )}
+
+              {createUserTab === 'ldap' && ldapStatus?.ldap_enabled ? (
+                <>
+                  <LdapUserPicker
+                    onSuccess={(user) => {
+                      setShowCreateUserModal(false);
+                      setCreateUserTab('local');
+                      setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
+                      showToast(t('users.toast.ldapProvisioned', { username: user.username }));
+                    }}
+                  />
+                  <div className="mt-6 flex justify-end">
+                    <Button
+                      variant="secondary"
+                      onClick={() => {
+                        setShowCreateUserModal(false);
+                        setCreateUserTab('local');
+                        setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
+                      }}
+                    >
+                      {t('common.cancel')}
+                    </Button>
+                  </div>
+                </>
+              ) : (
+              <>
               <div className="space-y-3">
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">{t('settings.username')}</label>
@@ -5401,6 +5464,8 @@ export function SettingsPage() {
                   )}
                 </Button>
               </div>
+              </>
+              )}
             </CardContent>
           </Card>
         </div>
@@ -5419,6 +5484,12 @@ export function SettingsPage() {
           onCreate={handleCreateUser}
           isCreating={createUserMutation.isPending}
           isCreateButtonDisabled={createUserMutation.isPending || !userFormData.username || !userFormData.email}
+          ldapEnabled={ldapStatus?.ldap_enabled}
+          onLdapProvisioned={(user) => {
+            setShowCreateUserModal(false);
+            setUserFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
+            showToast(t('users.toast.ldapProvisioned', { username: user.username }));
+          }}
         />
       )}
 

+ 81 - 0
frontend/src/pages/UsersPage.tsx

@@ -11,6 +11,7 @@ import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
+import { LdapUserPicker } from '../components/LdapUserPicker';
 
 interface FormData extends UserCreate {
   group_ids: number[];
@@ -25,6 +26,9 @@ export function UsersPage() {
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const [showCreateModal, setShowCreateModal] = useState(false);
+  // Basic-mode (non-advanced-auth) modal: track which tab is active so the
+  // LDAP picker can replace the local form when LDAP is enabled.
+  const [basicCreateTab, setBasicCreateTab] = useState<'local' | 'ldap'>('local');
   const [showEditModal, setShowEditModal] = useState(false);
   const [editingUserId, setEditingUserId] = useState<number | null>(null);
   const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
@@ -43,12 +47,19 @@ export function UsersPage() {
     queryFn: () => api.getAdvancedAuthStatus(),
   });
 
+  // LDAP status — drives whether the LDAP tab is rendered in the create modal.
+  const { data: ldapStatus = { ldap_enabled: false, ldap_configured: false } } = useQuery({
+    queryKey: ['ldapStatus'],
+    queryFn: () => api.getLDAPStatus(),
+  });
+
   // Close modal on Escape key
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
       if (e.key === 'Escape') {
         if (showCreateModal) {
           setShowCreateModal(false);
+          setBasicCreateTab('local');
           setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
         }
         if (showEditModal) {
@@ -413,6 +424,7 @@ export function UsersPage() {
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
           onClick={() => {
             setShowCreateModal(false);
+            setBasicCreateTab('local');
             setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
           }}
         >
@@ -431,6 +443,7 @@ export function UsersPage() {
                   size="sm"
                   onClick={() => {
                     setShowCreateModal(false);
+                    setBasicCreateTab('local');
                     setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
                   }}
                 >
@@ -439,6 +452,66 @@ export function UsersPage() {
               </div>
             </CardHeader>
             <CardContent>
+              {ldapStatus?.ldap_enabled && (
+                <div
+                  className="mb-4 flex items-center gap-1 p-1 bg-bambu-dark-secondary rounded-lg"
+                  role="tablist"
+                  aria-label={t('users.modal.tabsAriaLabel')}
+                >
+                  <button
+                    type="button"
+                    role="tab"
+                    aria-selected={basicCreateTab === 'local'}
+                    onClick={() => setBasicCreateTab('local')}
+                    className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
+                      basicCreateTab === 'local'
+                        ? 'bg-bambu-green/15 text-bambu-green'
+                        : 'text-bambu-gray hover:text-white'
+                    }`}
+                  >
+                    {t('users.modal.localTab')}
+                  </button>
+                  <button
+                    type="button"
+                    role="tab"
+                    aria-selected={basicCreateTab === 'ldap'}
+                    onClick={() => setBasicCreateTab('ldap')}
+                    className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
+                      basicCreateTab === 'ldap'
+                        ? 'bg-bambu-green/15 text-bambu-green'
+                        : 'text-bambu-gray hover:text-white'
+                    }`}
+                  >
+                    {t('users.modal.ldapTab')}
+                  </button>
+                </div>
+              )}
+
+              {basicCreateTab === 'ldap' && ldapStatus?.ldap_enabled ? (
+                <>
+                  <LdapUserPicker
+                    onSuccess={(user) => {
+                      setShowCreateModal(false);
+                      setBasicCreateTab('local');
+                      setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
+                      showToast(t('users.toast.ldapProvisioned', { username: user.username }));
+                    }}
+                  />
+                  <div className="mt-6 flex justify-end">
+                    <Button
+                      variant="secondary"
+                      onClick={() => {
+                        setShowCreateModal(false);
+                        setBasicCreateTab('local');
+                        setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
+                      }}
+                    >
+                      {t('users.modal.cancel')}
+                    </Button>
+                  </div>
+                </>
+              ) : (
+              <>
               <div className="space-y-4">
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
@@ -543,6 +616,8 @@ export function UsersPage() {
                   )}
                 </Button>
               </div>
+              </>
+              )}
             </CardContent>
           </Card>
         </div>
@@ -561,6 +636,12 @@ export function UsersPage() {
           onCreate={handleCreate}
           isCreating={createMutation.isPending}
           isCreateButtonDisabled={isCreateButtonDisabled}
+          ldapEnabled={ldapStatus?.ldap_enabled}
+          onLdapProvisioned={(user) => {
+            setShowCreateModal(false);
+            setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
+            showToast(t('users.toast.ldapProvisioned', { username: user.username }));
+          }}
         />
       )}
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BOMoNf8F.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Baw5c3Hn.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-By5KjUa-.js


+ 2 - 2
static/index.html

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

Some files were not shown because too many files changed in this diff