From 2fd4bd123ddfff9d04405c36cbff07c803801814 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Fri, 24 Jul 2026 17:17:53 +0200 Subject: [PATCH] fix: restore API response formats + ai-ui-control ws + profile fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- alembic/versions/0032_user_profile_fields.py | 25 +++++++++++ app/models/user.py | 3 ++ app/plugins/builtins/ai_ui_control/plugin.py | 8 ++-- app/routes/notifications.py | 2 +- app/routes/plugins.py | 4 +- app/routes/users.py | 42 ++++++++++++++---- app/schemas/user.py | 9 ++++ app/services/user_service.py | 21 +++++++++ dump.rdb | Bin 88 -> 88 bytes frontend/src/api/users.ts | 9 ++++ .../src/components/ai/SuggestionBadge.tsx | 2 +- frontend/src/hooks/useAIContext.ts | 2 +- 12 files changed, 110 insertions(+), 17 deletions(-) create mode 100644 alembic/versions/0032_user_profile_fields.py diff --git a/alembic/versions/0032_user_profile_fields.py b/alembic/versions/0032_user_profile_fields.py new file mode 100644 index 0000000..afe53d2 --- /dev/null +++ b/alembic/versions/0032_user_profile_fields.py @@ -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") diff --git a/app/models/user.py b/app/models/user.py index 01ed6c2..2a86c07 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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( diff --git a/app/plugins/builtins/ai_ui_control/plugin.py b/app/plugins/builtins/ai_ui_control/plugin.py index 7aaed8a..e65712d 100644 --- a/app/plugins/builtins/ai_ui_control/plugin.py +++ b/app/plugins/builtins/ai_ui_control/plugin.py @@ -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") diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 7c9f38f..9981056 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -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") diff --git a/app/routes/plugins.py b/app/routes/plugins.py index b5d0160..e41bafd 100644 --- a/app/routes/plugins.py +++ b/app/routes/plugins.py @@ -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") diff --git a/app/routes/users.py b/app/routes/users.py index 403138e..e0c42ff 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -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, diff --git a/app/schemas/user.py b/app/schemas/user.py index 0a811c0..7f07297 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -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 diff --git a/app/services/user_service.py b/app/services/user_service.py index c0c1f89..cb14b31 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -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 diff --git a/dump.rdb b/dump.rdb index 9fb73a81718b459666db15fa491ce8e07af43da5..51f71a4512bb2d2b05f923b48ae953405aa15db9 100644 GIT binary patch delta 45 zcma!um|&n0K0hhz7e{GvYKm@dYVM&O`MeCjI1=;IbdwT`Qx7ow=d0_#T=`A(0|29k B6OjM_ delta 45 zcma!um|&pM+Ln~{i=(tSHAOc!HTTetd|rlM9Etg9x=D$}sRtPTd;E|TH`9 { // Initial count - apiClient.get('/api/v1/ai-proactive/suggestions').then(r => { + apiClient.get('/ai-proactive/suggestions').then(r => { setCount(r.data.items.length); }).catch(() => {}); diff --git a/frontend/src/hooks/useAIContext.ts b/frontend/src/hooks/useAIContext.ts index 63c37da..9a822a2 100644 --- a/frontend/src/hooks/useAIContext.ts +++ b/frontend/src/hooks/useAIContext.ts @@ -9,7 +9,7 @@ export function useAIContext(entityType?: string, entityId?: string, entityData? useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - apiClient.post('/api/v1/ai-proactive/context', { + apiClient.post('/ai-proactive/context', { page: location.pathname, entity_type: entityType, entity_id: entityId,