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)
This commit is contained in:
maziggy
2026-05-15 12:05:43 +02:00
parent dca05ce6b1
commit d6364646f8
24 changed files with 2206 additions and 555 deletions
+1
View File
File diff suppressed because one or more lines are too long
+163
View File
@@ -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
View File
@@ -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)
+211 -67
View File
@@ -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.
posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
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.
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=["*"],
)
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()
def search_ldap_users(config: LDAPConfig, query: str, limit: int = 25) -> list[LDAPSearchResult]:
"""Fuzzy search the directory for users matching `query`.
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:
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"],
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),
)
)
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)
)
return LDAPUserInfo(
username=canonical_username,
email=email,
display_name=display_name,
groups=groups,
)
logger.info("LDAP directory search for %r returned %d result(s)", query, len(results))
return results
finally:
service_conn.unbind()
@@ -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
@@ -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")
@@ -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
View File
@@ -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',
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>