fix: restore API response formats + ai-ui-control ws + profile fields

- Restore {plugins: [...], total: N} for /plugins and /plugins/active-manifests
  (flat array broke frontend PluginRegistry → no menu items → empty UI)
- Restore {count: N} for /notifications/unread-count
  (scalar broke frontend notification badge)
- Fix ai_ui_control: on_install → on_activate for WS manager registration
  (WS 403 on every reconnect after container restart)
- Fix double /api/v1 prefix in useAIContext.ts and SuggestionBadge.tsx
- Add first_name, last_name, avatar_url to User model + migration 0032
- Extend UserUpdate schema with profile + password change fields
- Extend user_service.update_user with profile fields + password change
- Extend frontend UserResponse/UserUpdate types
This commit is contained in:
Agent Zero
2026-07-24 17:17:53 +02:00
parent 7b4a2c0791
commit 2fd4bd123d
12 changed files with 110 additions and 17 deletions
+3
View File
@@ -25,6 +25,9 @@ class User(Base, TenantMixin):
)
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
role_id: Mapped[uuid.UUID | None] = mapped_column(
+5 -3
View File
@@ -44,16 +44,18 @@ class AIUIControlPlugin(BasePlugin):
is_core=True,
)
async def on_install(self, db, service_container) -> None:
"""Register the WebSocket manager in the service container."""
async def on_activate(self, db, service_container, event_bus) -> None:
"""Register the WebSocket manager in the service container on every activation."""
await super().on_activate(db, service_container, event_bus)
from app.plugins.builtins.ai_ui_control.websocket_manager import AIUIControlWSManager
ws_manager = AIUIControlWSManager()
service_container.register("ai_ui_control_ws", ws_manager)
logger.info("AI UI Control WebSocket manager registered")
async def on_uninstall(self, db, service_container) -> None:
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up the WebSocket manager."""
await super().on_deactivate(db, service_container, event_bus)
if service_container.has("ai_ui_control_ws"):
service_container.remove("ai_ui_control_ws")
logger.info("AI UI Control WebSocket manager removed")
+1 -1
View File
@@ -76,7 +76,7 @@ async def unread_count_endpoint(
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
count = await get_unread_count(db, tenant_id, user_id)
return count
return {"count": count}
@router.get("/types")
+2 -2
View File
@@ -50,7 +50,7 @@ async def list_plugins(
"""List all plugins with their current status (discovered, installed, active, inactive)."""
service = get_plugin_service()
plugins = await service.list_plugins(db)
return plugins
return {"plugins": plugins, "total": len(plugins)}
@router.get("/manifest")
@@ -76,7 +76,7 @@ async def get_active_manifests(
"""
service = get_plugin_service()
manifests = await service.get_active_manifests(db)
return manifests
return {"plugins": manifests, "total": len(manifests)}
@router.get("/{name}/config")
+33 -9
View File
@@ -187,15 +187,36 @@ async def update_user(
else:
role_id = _UNSET
user = await user_service.update_user(
db,
tenant_id,
uid,
body.name,
body.role,
role_id,
body.is_active,
)
# Handle profile fields
if body.first_name is not None:
changes["first_name"] = body.first_name
if body.last_name is not None:
changes["last_name"] = body.last_name
if body.email is not None:
changes["email"] = body.email
if body.avatar_url is not None:
changes["avatar_url"] = body.avatar_url
if body.new_password is not None:
changes["password_changed"] = True
try:
user = await user_service.update_user(
db,
tenant_id,
uid,
body.name,
body.role,
role_id,
body.is_active,
body.first_name,
body.last_name,
body.avatar_url,
body.email,
body.current_password,
body.new_password,
)
except ValueError as exc:
raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_password"}) from None
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
@@ -209,6 +230,9 @@ async def update_user(
"id": str(user.id),
"email": user.email,
"name": user.name,
"first_name": user.first_name,
"last_name": user.last_name,
"avatar_url": user.avatar_url,
"role": user.role,
"role_id": str(user.role_id) if user.role_id else None,
"is_active": user.is_active,
+9
View File
@@ -16,15 +16,24 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
first_name: str | None = Field(None, max_length=100)
last_name: str | None = Field(None, max_length=100)
email: EmailStr | None = None
avatar_url: str | None = None
role: str | None = None
role_id: str | None = Field(default=None, description="UUID of a custom Role; send null to clear")
is_active: bool | None = None
current_password: str | None = Field(None, description="Required when changing password")
new_password: str | None = Field(None, min_length=8, description="New password")
class UserResponse(BaseModel):
id: str
email: str
name: str
first_name: str | None = None
last_name: str | None = None
avatar_url: str | None = None
role: str
role_id: str | None = None
is_active: bool
+21
View File
@@ -116,6 +116,12 @@ class UserService:
role: str | None = None,
role_id: uuid.UUID | None | Any = _UNSET,
is_active: bool | None = None,
first_name: str | None = None,
last_name: str | None = None,
avatar_url: str | None = None,
email: str | None = None,
current_password: str | None = None,
new_password: str | None = None,
) -> User | None:
"""Update a user.
@@ -147,6 +153,21 @@ class UserService:
user_tenant.role_id = role_id
if is_active is not None:
user.is_active = is_active
if first_name is not None:
user.first_name = first_name
if last_name is not None:
user.last_name = last_name
if avatar_url is not None:
user.avatar_url = avatar_url
if email is not None:
user.email = email
if new_password is not None and current_password is not None:
# Verify current password
from app.core.auth import verify_password
if not verify_password(current_password, user.password_hash):
raise ValueError("Current password is incorrect")
from app.core.auth import hash_password
user.password_hash = hash_password(new_password)
await db.flush()
return user