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