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 9fb73a8..51f71a4 100644 Binary files a/dump.rdb and b/dump.rdb differ diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index ec5a28e..9fdbdbd 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -12,6 +12,9 @@ export interface UserResponse { id: string; email: string; name: string; + first_name: string | null; + last_name: string | null; + avatar_url: string | null; role: string; role_id: string | null; is_active: boolean; @@ -29,9 +32,15 @@ export interface UserCreate { export interface UserUpdate { name?: string; + first_name?: string; + last_name?: string; + email?: string; + avatar_url?: string | null; role?: string; role_id?: string | null; is_active?: boolean; + current_password?: string; + new_password?: string; } export function useUsers(page = 1, pageSize = 25) { diff --git a/frontend/src/components/ai/SuggestionBadge.tsx b/frontend/src/components/ai/SuggestionBadge.tsx index ccf1464..c5abe9a 100644 --- a/frontend/src/components/ai/SuggestionBadge.tsx +++ b/frontend/src/components/ai/SuggestionBadge.tsx @@ -11,7 +11,7 @@ export function SuggestionBadge({ onClick }: SuggestionBadgeProps) { useEffect(() => { // 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,