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
@@ -0,0 +1,25 @@
"""Add first_name, last_name, avatar_url to users table.
Revision ID: 0032
Revises: 0031
"""
from alembic import op
import sqlalchemy as sa
revision = "0032"
down_revision = "0031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("first_name", sa.String(100), nullable=True))
op.add_column("users", sa.Column("last_name", sa.String(100), nullable=True))
op.add_column("users", sa.Column("avatar_url", sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column("users", "avatar_url")
op.drop_column("users", "last_name")
op.drop_column("users", "first_name")
+3
View File
@@ -25,6 +25,9 @@ class User(Base, TenantMixin):
) )
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False) 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) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer") role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
role_id: Mapped[uuid.UUID | None] = mapped_column( role_id: Mapped[uuid.UUID | None] = mapped_column(
+5 -3
View File
@@ -44,16 +44,18 @@ class AIUIControlPlugin(BasePlugin):
is_core=True, is_core=True,
) )
async def on_install(self, db, service_container) -> None: async def on_activate(self, db, service_container, event_bus) -> None:
"""Register the WebSocket manager in the service container.""" """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 from app.plugins.builtins.ai_ui_control.websocket_manager import AIUIControlWSManager
ws_manager = AIUIControlWSManager() ws_manager = AIUIControlWSManager()
service_container.register("ai_ui_control_ws", ws_manager) service_container.register("ai_ui_control_ws", ws_manager)
logger.info("AI UI Control WebSocket manager registered") 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.""" """Clean up the WebSocket manager."""
await super().on_deactivate(db, service_container, event_bus)
if service_container.has("ai_ui_control_ws"): if service_container.has("ai_ui_control_ws"):
service_container.remove("ai_ui_control_ws") service_container.remove("ai_ui_control_ws")
logger.info("AI UI Control WebSocket manager removed") 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"]) tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"]) user_id = uuid.UUID(current_user["user_id"])
count = await get_unread_count(db, tenant_id, user_id) count = await get_unread_count(db, tenant_id, user_id)
return count return {"count": count}
@router.get("/types") @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).""" """List all plugins with their current status (discovered, installed, active, inactive)."""
service = get_plugin_service() service = get_plugin_service()
plugins = await service.list_plugins(db) plugins = await service.list_plugins(db)
return plugins return {"plugins": plugins, "total": len(plugins)}
@router.get("/manifest") @router.get("/manifest")
@@ -76,7 +76,7 @@ async def get_active_manifests(
""" """
service = get_plugin_service() service = get_plugin_service()
manifests = await service.get_active_manifests(db) manifests = await service.get_active_manifests(db)
return manifests return {"plugins": manifests, "total": len(manifests)}
@router.get("/{name}/config") @router.get("/{name}/config")
+33 -9
View File
@@ -187,15 +187,36 @@ async def update_user(
else: else:
role_id = _UNSET role_id = _UNSET
user = await user_service.update_user( # Handle profile fields
db, if body.first_name is not None:
tenant_id, changes["first_name"] = body.first_name
uid, if body.last_name is not None:
body.name, changes["last_name"] = body.last_name
body.role, if body.email is not None:
role_id, changes["email"] = body.email
body.is_active, 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: if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
@@ -209,6 +230,9 @@ async def update_user(
"id": str(user.id), "id": str(user.id),
"email": user.email, "email": user.email,
"name": user.name, "name": user.name,
"first_name": user.first_name,
"last_name": user.last_name,
"avatar_url": user.avatar_url,
"role": user.role, "role": user.role,
"role_id": str(user.role_id) if user.role_id else None, "role_id": str(user.role_id) if user.role_id else None,
"is_active": user.is_active, "is_active": user.is_active,
+9
View File
@@ -16,15 +16,24 @@ class UserCreate(BaseModel):
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200) 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: str | None = None
role_id: str | None = Field(default=None, description="UUID of a custom Role; send null to clear") role_id: str | None = Field(default=None, description="UUID of a custom Role; send null to clear")
is_active: bool | None = None 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): class UserResponse(BaseModel):
id: str id: str
email: str email: str
name: str name: str
first_name: str | None = None
last_name: str | None = None
avatar_url: str | None = None
role: str role: str
role_id: str | None = None role_id: str | None = None
is_active: bool is_active: bool
+21
View File
@@ -116,6 +116,12 @@ class UserService:
role: str | None = None, role: str | None = None,
role_id: uuid.UUID | None | Any = _UNSET, role_id: uuid.UUID | None | Any = _UNSET,
is_active: bool | None = None, 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: ) -> User | None:
"""Update a user. """Update a user.
@@ -147,6 +153,21 @@ class UserService:
user_tenant.role_id = role_id user_tenant.role_id = role_id
if is_active is not None: if is_active is not None:
user.is_active = is_active 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() await db.flush()
return user return user
BIN
View File
Binary file not shown.
+9
View File
@@ -12,6 +12,9 @@ export interface UserResponse {
id: string; id: string;
email: string; email: string;
name: string; name: string;
first_name: string | null;
last_name: string | null;
avatar_url: string | null;
role: string; role: string;
role_id: string | null; role_id: string | null;
is_active: boolean; is_active: boolean;
@@ -29,9 +32,15 @@ export interface UserCreate {
export interface UserUpdate { export interface UserUpdate {
name?: string; name?: string;
first_name?: string;
last_name?: string;
email?: string;
avatar_url?: string | null;
role?: string; role?: string;
role_id?: string | null; role_id?: string | null;
is_active?: boolean; is_active?: boolean;
current_password?: string;
new_password?: string;
} }
export function useUsers(page = 1, pageSize = 25) { export function useUsers(page = 1, pageSize = 25) {
@@ -11,7 +11,7 @@ export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
useEffect(() => { useEffect(() => {
// Initial count // Initial count
apiClient.get('/api/v1/ai-proactive/suggestions').then(r => { apiClient.get('/ai-proactive/suggestions').then(r => {
setCount(r.data.items.length); setCount(r.data.items.length);
}).catch(() => {}); }).catch(() => {});
+1 -1
View File
@@ -9,7 +9,7 @@ export function useAIContext(entityType?: string, entityId?: string, entityData?
useEffect(() => { useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
apiClient.post('/api/v1/ai-proactive/context', { apiClient.post('/ai-proactive/context', {
page: location.pathname, page: location.pathname,
entity_type: entityType, entity_type: entityType,
entity_id: entityId, entity_id: entityId,