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:
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user