diff --git a/AGENTS.md b/AGENTS.md index c97cc604..af046dab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,10 +284,10 @@ wegent/ │ └── init_data/ # YAML initialization data ├── frontend/ # Next.js frontend │ └── src/ -│ ├── app/ # Pages: /, /login, /settings, /chat, /code, /tasks, /shared/task -│ ├── apis/ # API clients (client.ts + module-specific) +│ ├── app/ # Pages: /, /login, /settings, /chat, /code, /tasks, /shared/task, /admin +│ ├── apis/ # API clients (client.ts + module-specific, admin.ts) │ ├── components/ # UI components (ui/ for shadcn, common/) -│ ├── features/ # Feature modules (common, layout, login, settings, tasks, theme, onboarding) +│ ├── features/ # Feature modules (common, layout, login, settings, tasks, theme, onboarding, admin) │ ├── hooks/ # Custom hooks (useChatStream, useTranslation, useAttachment, useStreamingRecovery) │ ├── i18n/ # Internationalization (en, zh-CN) │ └── types/ # TypeScript types @@ -521,7 +521,26 @@ spec: | `/api/dify` | Dify app info, parameters | | `/api/v1/namespaces/{ns}/{kinds}` | Kubernetes-style Kind API | | `/api/v1/kinds/skills` | Skill upload/management | -| `/api/admin` | Admin operations | +| `/api/admin` | Admin operations (user management, public models, system stats) | + +### Admin API Endpoints (`/api/admin`) + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/users` | GET | List all users with pagination | +| `/users` | POST | Create new user | +| `/users/{user_id}` | GET | Get user details | +| `/users/{user_id}` | PUT | Update user info | +| `/users/{user_id}` | DELETE | Soft delete user (deactivate) | +| `/users/{user_id}/reset-password` | POST | Reset user password | +| `/users/{user_id}/toggle-status` | POST | Toggle user active status | +| `/users/{user_id}/role` | PUT | Update user role | +| `/public-models` | GET | List public models | +| `/public-models` | POST | Create public model | +| `/public-models/{model_id}` | GET | Get public model details | +| `/public-models/{model_id}` | PUT | Update public model | +| `/public-models/{model_id}` | DELETE | Delete public model | +| `/stats` | GET | Get system statistics | ### Executor Manager Routes @@ -534,6 +553,42 @@ spec: --- +## 👥 User Role System + +### Role Types + +| Role | Description | Permissions | +|------|-------------|-------------| +| `admin` | System administrator | Full access to admin panel, user management, public model management | +| `user` | Regular user | Standard access to tasks, teams, bots, models, shells | + +### Role-Based Access Control + +- **Admin Panel** (`/admin`): Only accessible to users with `role='admin'` +- **User Menu**: Admin users see additional "Admin" menu item +- **API Protection**: Admin endpoints require `get_admin_user` dependency + +### User Model Fields + +| Field | Type | Description | +|-------|------|-------------| +| `id` | int | Primary key | +| `user_name` | string | Unique username | +| `email` | string | Optional email | +| `role` | enum | 'admin' or 'user' (default: 'user') | +| `auth_source` | enum | 'password', 'oidc', or 'unknown' | +| `is_active` | bool | Account status | +| `created_at` | datetime | Creation timestamp | +| `updated_at` | datetime | Last update timestamp | + +### Database Migration + +The `role` column was added via migration `b2c3d4e5f6a7_add_role_to_users.py`: +- Default value: 'user' +- Users with `user_name='admin'` are automatically set to `role='admin'` + +--- + ## 🔒 Security - Never commit credentials - use `.env` files @@ -541,6 +596,7 @@ spec: - Backend encrypts Git tokens and API keys (AES-256-CBC) - Change default passwords in production - OIDC support for enterprise SSO +- Role-based access control for admin operations --- @@ -601,5 +657,5 @@ cd backend && alembic revision --autogenerate -m "msg" && alembic upgrade head --- **Last Updated**: 2025-12 -**Wegent Version**: 1.0.19 +**Wegent Version**: 1.0.20 **Maintained by**: WeCode-AI Team diff --git a/backend/alembic/versions/00162199d565_merge_storage_backend_and_shared_tasks_.py b/backend/alembic/versions/00162199d565_merge_storage_backend_and_shared_tasks_.py index 42cd37c5..780ddf13 100644 --- a/backend/alembic/versions/00162199d565_merge_storage_backend_and_shared_tasks_.py +++ b/backend/alembic/versions/00162199d565_merge_storage_backend_and_shared_tasks_.py @@ -5,15 +5,19 @@ Create Date: 2025-12-08 10:49:03.869486+08:00 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = '00162199d565' -down_revision: Union[str, Sequence[str], None] = ('2b3c4d5e6f7g', 'add_storage_backend_columns') +revision: str = "00162199d565" +down_revision: Union[str, Sequence[str], None] = ( + "2b3c4d5e6f7g", + "add_storage_backend_columns", +) branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend/alembic/versions/add_storage_backend_columns.py b/backend/alembic/versions/add_storage_backend_columns.py index 49eb93cb..980d2847 100644 --- a/backend/alembic/versions/add_storage_backend_columns.py +++ b/backend/alembic/versions/add_storage_backend_columns.py @@ -14,13 +14,13 @@ """ from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'add_storage_backend_columns' -down_revision: Union[str, None] = 'add_subtask_attachments' +revision: str = "add_storage_backend_columns" +down_revision: Union[str, None] = "add_subtask_attachments" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -29,23 +29,22 @@ def upgrade() -> None: """Add storage backend columns to subtask_attachments table.""" # Add storage_key column for external storage reference op.add_column( - 'subtask_attachments', - sa.Column('storage_key', sa.String(500), nullable=True) + "subtask_attachments", sa.Column("storage_key", sa.String(500), nullable=True) ) # Add storage_backend column to track which backend stores the data op.add_column( - 'subtask_attachments', - sa.Column('storage_backend', sa.String(50), nullable=True) + "subtask_attachments", + sa.Column("storage_backend", sa.String(50), nullable=True), ) # Make binary_data nullable to support external storage backends # When using external storage, binary_data can be NULL op.alter_column( - 'subtask_attachments', - 'binary_data', + "subtask_attachments", + "binary_data", existing_type=sa.LargeBinary(), - nullable=True + nullable=True, ) # Update existing records to have 'mysql' as storage_backend @@ -62,12 +61,12 @@ def downgrade() -> None: # Make binary_data non-nullable again op.alter_column( - 'subtask_attachments', - 'binary_data', + "subtask_attachments", + "binary_data", existing_type=sa.LargeBinary(), - nullable=False + nullable=False, ) # Drop the storage columns - op.drop_column('subtask_attachments', 'storage_backend') - op.drop_column('subtask_attachments', 'storage_key') + op.drop_column("subtask_attachments", "storage_backend") + op.drop_column("subtask_attachments", "storage_key") diff --git a/backend/alembic/versions/b2c3d4e5f6a7_add_role_to_users.py b/backend/alembic/versions/b2c3d4e5f6a7_add_role_to_users.py new file mode 100644 index 00000000..b74306d4 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_add_role_to_users.py @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +"""Add role column to users table + +Revision ID: b2c3d4e5f6a7 +Revises: 2b3c4d5e6f7g +Create Date: 2025-07-22 10:00:00.000000+08:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b2c3d4e5f6a7' +down_revision: Union[str, Sequence[str], None] = '2b3c4d5e6f7g' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add role column to users table. + + Values: 'admin', 'user' + Default 'user' for existing users. + Users with user_name='admin' will be set to role='admin'. + """ + # Check if column already exists before adding + op.execute(""" + SET @column_exists = ( + SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME = 'role' + ); + """) + + op.execute(""" + SET @query = IF(@column_exists = 0, + 'ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT ''user'' AFTER is_active', + 'SELECT 1' + ); + """) + + op.execute("PREPARE stmt FROM @query;") + op.execute("EXECUTE stmt;") + op.execute("DEALLOCATE PREPARE stmt;") + + # Set admin role for users with user_name='admin' + op.execute(""" + UPDATE users SET role = 'admin' WHERE user_name = 'admin'; + """) + + +def downgrade() -> None: + """Remove role column from users table.""" + op.drop_column('users', 'role') diff --git a/backend/app/api/endpoints/admin.py b/backend/app/api/endpoints/admin.py index 91015545..507d0167 100644 --- a/backend/app/api/endpoints/admin.py +++ b/backend/app/api/endpoints/admin.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Path, Query, status +from sqlalchemy import func from sqlalchemy.orm import Session from app.api.dependencies import get_db @@ -17,9 +18,23 @@ validate_user_exists, ) from app.api.endpoints.kind.kinds import KIND_SCHEMA_MAP -from app.core.security import create_access_token, get_admin_user +from app.core.security import create_access_token, get_admin_user, get_password_hash from app.models.kind import Kind +from app.models.public_model import PublicModel from app.models.user import User +from app.schemas.admin import ( + AdminUserCreate, + AdminUserListResponse, + AdminUserResponse, + AdminUserUpdate, + PasswordReset, + PublicModelCreate, + PublicModelListResponse, + PublicModelResponse, + PublicModelUpdate, + RoleUpdate, + SystemStats, +) from app.schemas.kind import BatchResponse from app.schemas.task import TaskCreate, TaskInDB from app.schemas.user import Token, UserInDB, UserInfo @@ -31,18 +46,46 @@ router = APIRouter() -@router.get("/users", response_model=List[UserInfo]) +# ==================== User Management Endpoints ==================== + + +@router.get("/users", response_model=AdminUserListResponse) async def list_all_users( - db: Session = Depends(get_db), current_user: User = Depends(get_admin_user) + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + include_inactive: bool = Query(False), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), ): """ - Get list of all user names + Get list of all users with pagination """ - users = db.query(User).filter(User.is_active == True).all() - return [UserInfo(id=user.id, user_name=user.user_name) for user in users] + query = db.query(User) + if not include_inactive: + query = query.filter(User.is_active == True) + + total = query.count() + users = query.offset((page - 1) * limit).limit(limit).all() + + return AdminUserListResponse( + total=total, + items=[ + AdminUserResponse( + id=user.id, + user_name=user.user_name, + email=user.email, + role=user.role, + auth_source=user.auth_source, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + ) + for user in users + ], + ) -@router.get("/users/{user_id}", response_model=UserInDB) +@router.get("/users/{user_id}", response_model=AdminUserResponse) async def get_user_by_id_endpoint( user_id: int = Path(..., description="User ID"), db: Session = Depends(get_db), @@ -52,7 +95,469 @@ async def get_user_by_id_endpoint( Get detailed information for specified user ID """ user = user_service.get_user_by_id(db, user_id) - return user + return AdminUserResponse( + id=user.id, + user_name=user.user_name, + email=user.email, + role=user.role, + auth_source=user.auth_source, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.post( + "/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED +) +async def create_user( + user_data: AdminUserCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Create a new user (admin only) + """ + # Check if username already exists + existing_user = db.query(User).filter(User.user_name == user_data.user_name).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"User with username '{user_data.user_name}' already exists", + ) + + # Validate password for password auth source + if user_data.auth_source == "password" and not user_data.password: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password is required for password authentication", + ) + + # Create user + password_hash = ( + get_password_hash(user_data.password) + if user_data.password + else get_password_hash("oidc_placeholder") + ) + new_user = User( + user_name=user_data.user_name, + email=user_data.email, + password_hash=password_hash, + role=user_data.role, + auth_source=user_data.auth_source, + is_active=True, + ) + db.add(new_user) + db.commit() + db.refresh(new_user) + + return AdminUserResponse( + id=new_user.id, + user_name=new_user.user_name, + email=new_user.email, + role=new_user.role, + auth_source=new_user.auth_source, + is_active=new_user.is_active, + created_at=new_user.created_at, + updated_at=new_user.updated_at, + ) + + +@router.put("/users/{user_id}", response_model=AdminUserResponse) +async def update_user( + user_data: AdminUserUpdate, + user_id: int = Path(..., description="User ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Update user information (admin only) + """ + user = user_service.get_user_by_id(db, user_id) + + # Prevent admin from deactivating themselves + if user.id == current_user.id and user_data.is_active is False: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot deactivate your own account", + ) + + # Prevent admin from demoting themselves + if user.id == current_user.id and user_data.role == "user": + # Check if there are other admins + admin_count = ( + db.query(User).filter(User.role == "admin", User.is_active == True).count() + ) + if admin_count <= 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot demote yourself when you are the only admin", + ) + + # Check username uniqueness if being changed + if user_data.user_name and user_data.user_name != user.user_name: + existing_user = ( + db.query(User).filter(User.user_name == user_data.user_name).first() + ) + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"User with username '{user_data.user_name}' already exists", + ) + + # Update fields + if user_data.user_name is not None: + user.user_name = user_data.user_name + if user_data.email is not None: + user.email = user_data.email + if user_data.role is not None: + user.role = user_data.role + if user_data.is_active is not None: + user.is_active = user_data.is_active + + db.commit() + db.refresh(user) + + return AdminUserResponse( + id=user.id, + user_name=user.user_name, + email=user.email, + role=user.role, + auth_source=user.auth_source, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int = Path(..., description="User ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Delete a user (soft delete by setting is_active=False) + """ + user = user_service.get_user_by_id(db, user_id) + + # Prevent admin from deleting themselves + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete your own account", + ) + + # Soft delete + user.is_active = False + db.commit() + + return None + + +@router.post("/users/{user_id}/reset-password", response_model=AdminUserResponse) +async def reset_user_password( + password_data: PasswordReset, + user_id: int = Path(..., description="User ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Reset user password (admin only) + """ + user = user_service.get_user_by_id(db, user_id) + + # Only allow password reset for password-authenticated users + if user.auth_source not in ["password", "unknown"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot reset password for OIDC-authenticated users", + ) + + user.password_hash = get_password_hash(password_data.new_password) + if user.auth_source == "unknown": + user.auth_source = "password" + db.commit() + db.refresh(user) + + return AdminUserResponse( + id=user.id, + user_name=user.user_name, + email=user.email, + role=user.role, + auth_source=user.auth_source, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.post("/users/{user_id}/toggle-status", response_model=AdminUserResponse) +async def toggle_user_status( + user_id: int = Path(..., description="User ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Toggle user active status (enable/disable) + """ + user = user_service.get_user_by_id(db, user_id) + + # Prevent admin from disabling themselves + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot toggle your own account status", + ) + + user.is_active = not user.is_active + db.commit() + db.refresh(user) + + return AdminUserResponse( + id=user.id, + user_name=user.user_name, + email=user.email, + role=user.role, + auth_source=user.auth_source, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.put("/users/{user_id}/role", response_model=AdminUserResponse) +async def update_user_role( + role_data: RoleUpdate, + user_id: int = Path(..., description="User ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Update user role (admin only) + """ + user = user_service.get_user_by_id(db, user_id) + + # Prevent admin from demoting themselves if they're the only admin + if user.id == current_user.id and role_data.role == "user": + admin_count = ( + db.query(User).filter(User.role == "admin", User.is_active == True).count() + ) + if admin_count <= 1: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot demote yourself when you are the only admin", + ) + + user.role = role_data.role + db.commit() + db.refresh(user) + + return AdminUserResponse( + id=user.id, + user_name=user.user_name, + email=user.email, + role=user.role, + auth_source=user.auth_source, + is_active=user.is_active, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +# ==================== Public Model Management Endpoints ==================== + + +@router.get("/public-models", response_model=PublicModelListResponse) +async def list_public_models( + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Get list of all public models with pagination + """ + query = db.query(PublicModel) + total = query.count() + models = query.offset((page - 1) * limit).limit(limit).all() + + return PublicModelListResponse( + total=total, + items=[ + PublicModelResponse( + id=model.id, + name=model.name, + namespace=model.namespace, + json=model.json, + is_active=model.is_active, + created_at=model.created_at, + updated_at=model.updated_at, + ) + for model in models + ], + ) + + +@router.post( + "/public-models", + response_model=PublicModelResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_public_model( + model_data: PublicModelCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Create a new public model (admin only) + """ + # Check if model with same name and namespace already exists + existing_model = ( + db.query(PublicModel) + .filter( + PublicModel.name == model_data.name, + PublicModel.namespace == model_data.namespace, + ) + .first() + ) + if existing_model: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Public model '{model_data.name}' already exists in namespace '{model_data.namespace}'", + ) + + new_model = PublicModel( + name=model_data.name, + namespace=model_data.namespace, + json=model_data.json, + is_active=True, + ) + db.add(new_model) + db.commit() + db.refresh(new_model) + + return PublicModelResponse( + id=new_model.id, + name=new_model.name, + namespace=new_model.namespace, + json=new_model.json, + is_active=new_model.is_active, + created_at=new_model.created_at, + updated_at=new_model.updated_at, + ) + + +@router.put("/public-models/{model_id}", response_model=PublicModelResponse) +async def update_public_model( + model_data: PublicModelUpdate, + model_id: int = Path(..., description="Model ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Update a public model (admin only) + """ + model = db.query(PublicModel).filter(PublicModel.id == model_id).first() + if not model: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Public model with id {model_id} not found", + ) + + # Check name uniqueness if being changed + if model_data.name and model_data.name != model.name: + namespace = model_data.namespace or model.namespace + existing_model = ( + db.query(PublicModel) + .filter( + PublicModel.name == model_data.name, PublicModel.namespace == namespace + ) + .first() + ) + if existing_model: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Public model '{model_data.name}' already exists in namespace '{namespace}'", + ) + + # Update fields + if model_data.name is not None: + model.name = model_data.name + if model_data.namespace is not None: + model.namespace = model_data.namespace + if model_data.json is not None: + model.json = model_data.json + if model_data.is_active is not None: + model.is_active = model_data.is_active + + db.commit() + db.refresh(model) + + return PublicModelResponse( + id=model.id, + name=model.name, + namespace=model.namespace, + json=model.json, + is_active=model.is_active, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + +@router.delete("/public-models/{model_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_public_model( + model_id: int = Path(..., description="Model ID"), + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Delete a public model (admin only) + """ + model = db.query(PublicModel).filter(PublicModel.id == model_id).first() + if not model: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Public model with id {model_id} not found", + ) + + db.delete(model) + db.commit() + + return None + + +# ==================== System Stats Endpoint ==================== + + +@router.get("/stats", response_model=SystemStats) +async def get_system_stats( + db: Session = Depends(get_db), + current_user: User = Depends(get_admin_user), +): + """ + Get system statistics + """ + from app.models.task import Task + + total_users = db.query(User).count() + active_users = db.query(User).filter(User.is_active == True).count() + admin_count = ( + db.query(User).filter(User.role == "admin", User.is_active == True).count() + ) + total_tasks = db.query(Task).count() + total_public_models = db.query(PublicModel).count() + + return SystemStats( + total_users=total_users, + active_users=active_users, + admin_count=admin_count, + total_tasks=total_tasks, + total_public_models=total_public_models, + ) + + +# ==================== Task Management Endpoints ==================== @router.post( diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 1ca0d940..f94bf239 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -204,9 +204,8 @@ def get_admin_user(current_user: User = Depends(get_current_user)) -> User: Raises: HTTPException: If user is not admin """ - # Here we assume users with username 'admin' are administrators - # Actual projects may require more complex permission management - if current_user.user_name != "admin": + # Check user's role field to determine admin status + if current_user.role != "admin": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied. Admin access required.", diff --git a/backend/app/main.py b/backend/app/main.py index 6542110d..53fb6157 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -163,6 +163,7 @@ async def startup(): # Only run startup initialization if we acquired the lock or Redis is not available if acquired_lock or not redis_client: + startup_success = False try: # Step 1: Run database migrations if ( @@ -254,15 +255,28 @@ async def startup(): finally: db.close() - # Mark startup as done - if redis_client: - redis_client.set(STARTUP_DONE_KEY, "done", ex=86400) + # Mark startup as successful + startup_success = True + except Exception as e: + # Startup failed - do NOT mark as done so next restart will retry + logger.error(f"✗ Startup initialization failed: {e}") + startup_success = False finally: + # Only mark startup as done if it was successful + if redis_client and startup_success: + redis_client.set(STARTUP_DONE_KEY, "done", ex=86400) + logger.info("Marked startup initialization as done") + elif redis_client and not startup_success: + # Ensure STARTUP_DONE_KEY is deleted if startup failed + # This allows the next restart to retry + redis_client.delete(STARTUP_DONE_KEY) + logger.warning( + "Startup failed - cleared done flag to allow retry on next restart" + ) # Release lock if redis_client and acquired_lock: redis_client.delete(STARTUP_LOCK_KEY) logger.info("Released startup initialization lock") - # Start background jobs logger.info("Starting background jobs...") start_background_jobs(app) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index cbc452a2..3d6705bb 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -20,6 +20,8 @@ class User(Base): email = Column(String(100)) git_info = Column(JSON) is_active = Column(Boolean, default=True) + # User role: admin or user (default) + role = Column(String(20), nullable=False, default="user") # Authentication source: password, oidc, or unknown (for existing users) auth_source = Column(String(20), nullable=False, default="unknown") # User preferences (e.g., send_key: "enter" or "cmd_enter") diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 00000000..221210cd --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2025 Weibo, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +from datetime import datetime +from typing import List, Literal, Optional + +from pydantic import BaseModel, EmailStr, Field + + +# User Management Schemas +class AdminUserCreate(BaseModel): + """Admin user creation model""" + + user_name: str = Field(..., min_length=2, max_length=50) + password: Optional[str] = Field(None, min_length=6) + email: Optional[EmailStr] = None + role: Literal["admin", "user"] = "user" + auth_source: Literal["password", "oidc"] = "password" + + +class AdminUserUpdate(BaseModel): + """Admin user update model""" + + user_name: Optional[str] = Field(None, min_length=2, max_length=50) + email: Optional[EmailStr] = None + role: Optional[Literal["admin", "user"]] = None + is_active: Optional[bool] = None + + +class PasswordReset(BaseModel): + """Password reset model""" + + new_password: str = Field(..., min_length=6) + + +class AdminUserResponse(BaseModel): + """Admin user response model""" + + id: int + user_name: str + email: Optional[str] = None + role: str + auth_source: str + is_active: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class AdminUserListResponse(BaseModel): + """Admin user list response model""" + + total: int + items: List[AdminUserResponse] + + +# Public Model Management Schemas +class PublicModelCreate(BaseModel): + """Public model creation model""" + + name: str = Field(..., min_length=1, max_length=100) + namespace: str = Field(default="default", max_length=100) + json: dict + + +class PublicModelUpdate(BaseModel): + """Public model update model""" + + name: Optional[str] = Field(None, min_length=1, max_length=100) + namespace: Optional[str] = Field(None, max_length=100) + json: Optional[dict] = None + is_active: Optional[bool] = None + + +class PublicModelResponse(BaseModel): + """Public model response model""" + + id: int + name: str + namespace: str + json: dict + is_active: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class PublicModelListResponse(BaseModel): + """Public model list response model""" + + total: int + items: List[PublicModelResponse] + + +# System Stats Schemas +class SystemStats(BaseModel): + """System statistics model""" + + total_users: int + active_users: int + admin_count: int + total_tasks: int + total_public_models: int + + +# Role Update Schema +class RoleUpdate(BaseModel): + """Role update model""" + + role: Literal["admin", "user"] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 34a3c4dd..ab8d6914 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -73,6 +73,8 @@ class UserInDB(UserBase): id: int git_info: Optional[List[GitInfo]] = None preferences: Optional[UserPreferences] = None + role: str = "user" + auth_source: str = "unknown" created_at: datetime updated_at: datetime @@ -115,6 +117,7 @@ class UserInfo(BaseModel): id: int user_name: str + role: str = "user" class UserAuthTypeResponse(BaseModel): diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fe117354..320d1a6d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -98,7 +98,8 @@ def test_admin_user(test_db: Session) -> User: password_hash=get_password_hash("adminpassword123"), email="admin@example.com", is_active=True, - git_info=None + git_info=None, + role="admin" ) test_db.add(admin) test_db.commit() diff --git a/docs/en/concepts/architecture.md b/docs/en/concepts/architecture.md index 57156493..eef254a6 100644 --- a/docs/en/concepts/architecture.md +++ b/docs/en/concepts/architecture.md @@ -142,6 +142,7 @@ frontend/ - 📝 Complete CRUD operation support - 🔄 Real-time status synchronization - 🛡️ Data encryption (AES) +- 👥 Role-based access control (admin/user) **API Design**: ``` @@ -152,7 +153,8 @@ frontend/ ├── /bots # Bot resource management ├── /teams # Team resource management ├── /workspaces # Workspace resource management -└── /tasks # Task resource management +├── /tasks # Task resource management +└── /admin # Admin operations (user management, public models) ``` **Key Dependencies**: @@ -247,7 +249,8 @@ wegent_db/ ├── teams # Team definitions ├── workspaces # Workspace configurations ├── tasks # Task records -└── users # User information +├── users # User information (with role field) +└── public_models # System-wide public models ``` **Data Model Features**: @@ -441,6 +444,7 @@ status: - 🛡️ AES encryption for sensitive data - 🔐 Sandbox environment isolation - 🚫 Principle of least privilege +- 👥 Role-based access control (admin/user roles) ### 5. Observability diff --git a/docs/en/guides/developer/database-migrations.md b/docs/en/guides/developer/database-migrations.md index 66abc18a..6d96df88 100644 --- a/docs/en/guides/developer/database-migrations.md +++ b/docs/en/guides/developer/database-migrations.md @@ -126,12 +126,25 @@ alembic upgrade head ``` backend/alembic/ ├── versions/ # Migration scripts (never edit after applying) -│ └── 0c086b93f8b9_initial_migration.py +│ ├── 0c086b93f8b9_initial_migration.py +│ └── b2c3d4e5f6a7_add_role_to_users.py # User role migration ├── env.py # Alembic runtime environment ├── script.py.mako # Template for new migrations └── README # Quick reference ``` +## Notable Migrations + +### User Role Migration (`b2c3d4e5f6a7`) + +This migration adds the `role` column to the `users` table for role-based access control: + +- **Column**: `role` (VARCHAR(20), NOT NULL, default: 'user') +- **Values**: 'admin' or 'user' +- **Auto-upgrade**: Users with `user_name='admin'` are automatically set to `role='admin'` + +The migration uses conditional SQL to safely handle cases where the column already exists. + ## Workflow Example Here's a typical workflow for adding a new model field: diff --git a/docs/zh/concepts/architecture.md b/docs/zh/concepts/architecture.md index e0080166..e457b27f 100644 --- a/docs/zh/concepts/architecture.md +++ b/docs/zh/concepts/architecture.md @@ -142,6 +142,7 @@ frontend/ - 📝 完整的 CRUD 操作支持 - 🔄 实时状态同步 - 🛡️ 数据加密(AES) +- 👥 基于角色的访问控制(管理员/普通用户) **API 设计**: ``` @@ -152,7 +153,8 @@ frontend/ ├── /bots # Bot 资源管理 ├── /teams # Team 资源管理 ├── /workspaces # Workspace 资源管理 -└── /tasks # Task 资源管理 +├── /tasks # Task 资源管理 +└── /admin # 管理员操作(用户管理、公共模型) ``` **关键依赖**: @@ -247,7 +249,8 @@ wegent_db/ ├── teams # Team 定义 ├── workspaces # Workspace 配置 ├── tasks # Task 记录 -└── users # 用户信息 +├── users # 用户信息(含角色字段) +└── public_models # 系统级公共模型 ``` **数据模型特点**: @@ -441,6 +444,7 @@ status: - 🛡️ AES 加密敏感数据 - 🔐 沙箱环境隔离 - 🚫 最小权限原则 +- 👥 基于角色的访问控制(管理员/普通用户) ### 5. 可观测性 diff --git a/docs/zh/guides/developer/database-migrations.md b/docs/zh/guides/developer/database-migrations.md index 16e6a6cb..8b95e623 100644 --- a/docs/zh/guides/developer/database-migrations.md +++ b/docs/zh/guides/developer/database-migrations.md @@ -126,12 +126,25 @@ alembic upgrade head ``` backend/alembic/ ├── versions/ # 迁移脚本(应用后不要编辑) -│ └── 0c086b93f8b9_initial_migration.py +│ ├── 0c086b93f8b9_initial_migration.py +│ └── b2c3d4e5f6a7_add_role_to_users.py # 用户角色迁移 ├── env.py # Alembic 运行时环境 ├── script.py.mako # 新迁移的模板 └── README # 快速参考 ``` +## 重要迁移说明 + +### 用户角色迁移 (`b2c3d4e5f6a7`) + +此迁移为 `users` 表添加 `role` 列,用于基于角色的访问控制: + +- **列名**: `role` (VARCHAR(20), NOT NULL, 默认值: 'user') +- **可选值**: 'admin' 或 'user' +- **自动升级**: `user_name='admin'` 的用户会自动设置为 `role='admin'` + +该迁移使用条件 SQL 来安全处理列已存在的情况。 + ## 工作流示例 以下是添加新模型字段的典型工作流: diff --git a/frontend/src/apis/admin.ts b/frontend/src/apis/admin.ts new file mode 100644 index 00000000..42382066 --- /dev/null +++ b/frontend/src/apis/admin.ts @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +import { apiClient } from './client'; + +// Admin User Types +export type UserRole = 'admin' | 'user'; +export type AuthSource = 'password' | 'oidc' | 'unknown'; + +export interface AdminUser { + id: number; + user_name: string; + email: string | null; + role: UserRole; + auth_source: AuthSource; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface AdminUserListResponse { + total: number; + items: AdminUser[]; +} + +export interface AdminUserCreate { + user_name: string; + password?: string; + email?: string; + role?: UserRole; + auth_source?: 'password' | 'oidc'; +} + +export interface AdminUserUpdate { + user_name?: string; + email?: string; + role?: UserRole; + is_active?: boolean; +} + +export interface PasswordResetRequest { + new_password: string; +} + +export interface RoleUpdateRequest { + role: UserRole; +} + +// Public Model Types +export interface AdminPublicModel { + id: number; + name: string; + namespace: string; + json: Record; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface AdminPublicModelListResponse { + total: number; + items: AdminPublicModel[]; +} + +export interface AdminPublicModelCreate { + name: string; + namespace?: string; + json: Record; +} + +export interface AdminPublicModelUpdate { + name?: string; + namespace?: string; + json?: Record; + is_active?: boolean; +} + +// System Stats Types +export interface SystemStats { + total_users: number; + active_users: number; + admin_count: number; + total_tasks: number; + total_public_models: number; +} + +// Admin API Services +export const adminApis = { + // ==================== User Management ==================== + + /** + * Get list of all users with pagination + */ + async getUsers( + page: number = 1, + limit: number = 20, + includeInactive: boolean = false + ): Promise { + const params = new URLSearchParams(); + params.append('page', String(page)); + params.append('limit', String(limit)); + if (includeInactive) { + params.append('include_inactive', 'true'); + } + return apiClient.get(`/admin/users?${params.toString()}`); + }, + + /** + * Get user by ID + */ + async getUserById(userId: number): Promise { + return apiClient.get(`/admin/users/${userId}`); + }, + + /** + * Create a new user + */ + async createUser(userData: AdminUserCreate): Promise { + return apiClient.post('/admin/users', userData); + }, + + /** + * Update user information + */ + async updateUser(userId: number, userData: AdminUserUpdate): Promise { + return apiClient.put(`/admin/users/${userId}`, userData); + }, + + /** + * Delete a user (soft delete) + */ + async deleteUser(userId: number): Promise { + return apiClient.delete(`/admin/users/${userId}`); + }, + + /** + * Reset user password + */ + async resetPassword(userId: number, data: PasswordResetRequest): Promise { + return apiClient.post(`/admin/users/${userId}/reset-password`, data); + }, + + /** + * Toggle user active status + */ + async toggleUserStatus(userId: number): Promise { + return apiClient.post(`/admin/users/${userId}/toggle-status`); + }, + + /** + * Update user role + */ + async updateUserRole(userId: number, data: RoleUpdateRequest): Promise { + return apiClient.put(`/admin/users/${userId}/role`, data); + }, + + // ==================== Public Model Management ==================== + + /** + * Get list of all public models with pagination + */ + async getPublicModels(page: number = 1, limit: number = 20): Promise { + return apiClient.get(`/admin/public-models?page=${page}&limit=${limit}`); + }, + + /** + * Create a new public model + */ + async createPublicModel(modelData: AdminPublicModelCreate): Promise { + return apiClient.post('/admin/public-models', modelData); + }, + + /** + * Update a public model + */ + async updatePublicModel( + modelId: number, + modelData: AdminPublicModelUpdate + ): Promise { + return apiClient.put(`/admin/public-models/${modelId}`, modelData); + }, + + /** + * Delete a public model + */ + async deletePublicModel(modelId: number): Promise { + return apiClient.delete(`/admin/public-models/${modelId}`); + }, + + // ==================== System Stats ==================== + + /** + * Get system statistics + */ + async getSystemStats(): Promise { + return apiClient.get('/admin/stats'); + }, +}; diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 00000000..d582c08d --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,254 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import { Suspense, useState, useCallback, useEffect, useMemo } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import TopNavigation from '@/features/layout/TopNavigation'; +import UserMenu from '@/features/layout/UserMenu'; +import { Tab } from '@headlessui/react'; +import { UsersIcon, CpuChipIcon, ShieldExclamationIcon } from '@heroicons/react/24/outline'; +import UserList from '@/features/admin/components/UserList'; +import PublicModelList from '@/features/admin/components/PublicModelList'; +import { UserProvider, useUser } from '@/features/common/UserContext'; +import { useTranslation } from '@/hooks/useTranslation'; +import { GithubStarButton } from '@/features/layout/GithubStarButton'; +import { Button } from '@/components/ui/button'; + +function AccessDenied() { + const { t } = useTranslation('admin'); + + return ( +
+ +

+ {t('access_denied.title')} +

+

+ {t('access_denied.message')} +

+ + + +
+ ); +} + +function AdminContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { t } = useTranslation('admin'); + const { user, isLoading } = useUser(); + + // Check if user is admin + const isAdmin = user?.role === 'admin'; + + // Tab index to name mapping + const tabIndexToName = useMemo( + (): Record => ({ + 0: 'users', + 1: 'public-models', + }), + [] + ); + + // Tab name to index mapping + const tabNameToIndex = useMemo( + (): Record => ({ + users: 0, + 'public-models': 1, + }), + [] + ); + + // Function to get initial tab index from URL + const getInitialTabIndex = () => { + const tabParam = searchParams.get('tab'); + if (tabParam && tabNameToIndex.hasOwnProperty(tabParam)) { + return tabNameToIndex[tabParam]; + } + return 0; // default to first tab + }; + + // Initialize tabIndex based on URL parameter + const [tabIndex, setTabIndex] = useState(getInitialTabIndex); + + // Detect screen size for responsive behavior + const [isDesktop, setIsDesktop] = useState(false); + + useEffect(() => { + const checkScreenSize = () => { + setIsDesktop(window.innerWidth >= 1024); // 1024px as desktop breakpoint + }; + + checkScreenSize(); + window.addEventListener('resize', checkScreenSize); + return () => window.removeEventListener('resize', checkScreenSize); + }, []); + + const handleTabChange = useCallback( + (idx: number) => { + setTabIndex(idx); + const tabName = tabIndexToName[idx] || 'users'; + router.replace(`?tab=${tabName}`); + }, + [router, tabIndexToName] + ); + + // Show loading state + if (isLoading) { + return ( +
+
+
+ ); + } + + // Show access denied if not admin + if (!isAdmin) { + return ( +
+
+ + + + + +
+
+ ); + } + + return ( +
+ {/* Main Content */} +
+ {/* Top Navigation */} + + + + + + {/* Admin Content with Tabs */} +
+
+ + {/* Conditional rendering based on screen size */} + {isDesktop ? ( + /* Desktop Layout */ + <> + +
+

{t('title')}

+

{t('description')}

+
+ + `w-full flex items-center space-x-3 px-3 py-2 text-sm rounded-md transition-colors duration-200 focus:outline-none ${ + selected + ? 'bg-muted text-text-primary' + : 'text-text-muted hover:text-text-primary hover:bg-muted' + }` + } + > + + {t('tabs.users')} + + + + `w-full flex items-center space-x-3 px-3 py-2 text-sm rounded-md transition-colors duration-200 focus:outline-none ${ + selected + ? 'bg-muted text-text-primary' + : 'text-text-muted hover:text-text-primary hover:bg-muted' + }` + } + > + + {t('tabs.public_models')} + +
+ +
+ + + + + + + + +
+ + ) : ( + /* Mobile Layout */ + <> +
+

{t('title')}

+
+
+ + + `flex-1 flex items-center justify-center space-x-1 px-2 py-2 text-xs rounded-md transition-colors duration-200 focus:outline-none ${ + selected + ? 'bg-muted text-text-primary' + : 'text-text-muted hover:text-text-primary hover:bg-muted' + }` + } + > + + {t('tabs.users')} + + + + `flex-1 flex items-center justify-center space-x-1 px-2 py-2 text-xs rounded-md transition-colors duration-200 focus:outline-none ${ + selected + ? 'bg-muted text-text-primary' + : 'text-text-muted hover:text-text-primary hover:bg-muted' + }` + } + > + + {t('tabs.public_models')} + + +
+ +
+ + + + + + + + +
+ + )} +
+
+
+
+
+ ); +} + +export default function AdminPage() { + return ( + + Loading...}> + + + + ); +} diff --git a/frontend/src/features/admin/components/PublicModelList.tsx b/frontend/src/features/admin/components/PublicModelList.tsx new file mode 100644 index 00000000..55d8231c --- /dev/null +++ b/frontend/src/features/admin/components/PublicModelList.tsx @@ -0,0 +1,470 @@ +// SPDX-FileCopyrightText: 2025 Weibo, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Tag } from '@/components/ui/tag'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { CpuChipIcon, PencilIcon, TrashIcon, GlobeAltIcon } from '@heroicons/react/24/outline'; +import { Loader2 } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { useTranslation } from '@/hooks/useTranslation'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + adminApis, + AdminPublicModel, + AdminPublicModelCreate, + AdminPublicModelUpdate, +} from '@/apis/admin'; +import UnifiedAddButton from '@/components/common/UnifiedAddButton'; + +const PublicModelList: React.FC = () => { + const { t } = useTranslation('admin'); + const { toast } = useToast(); + const [models, setModels] = useState([]); + const [_total, setTotal] = useState(0); + const [page, _setPage] = useState(1); + const [loading, setLoading] = useState(true); + + // Dialog states + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); + + // Form states + const [formData, setFormData] = useState<{ + name: string; + namespace: string; + config: string; + }>({ + name: '', + namespace: 'default', + config: '{}', + }); + const [configError, setConfigError] = useState(''); + const [saving, setSaving] = useState(false); + + const fetchModels = useCallback(async () => { + setLoading(true); + try { + const response = await adminApis.getPublicModels(page, 20); + setModels(response.items); + setTotal(response.total); + } catch (_error) { + toast({ + variant: 'destructive', + title: t('public_models.errors.load_failed'), + }); + } finally { + setLoading(false); + } + }, [page, toast, t]); + + useEffect(() => { + fetchModels(); + }, [fetchModels]); + + const validateConfig = (value: string): Record | null => { + if (!value.trim()) { + setConfigError(t('public_models.errors.config_required')); + return null; + } + try { + const parsed = JSON.parse(value); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + setConfigError(t('public_models.errors.config_invalid_json')); + return null; + } + setConfigError(''); + return parsed as Record; + } catch { + setConfigError(t('public_models.errors.config_invalid_json')); + return null; + } + }; + + const handleCreateModel = async () => { + if (!formData.name.trim()) { + toast({ + variant: 'destructive', + title: t('public_models.errors.name_required'), + }); + return; + } + + const config = validateConfig(formData.config); + if (!config) { + toast({ + variant: 'destructive', + title: t('public_models.errors.config_invalid_json'), + }); + return; + } + + setSaving(true); + try { + const createData: AdminPublicModelCreate = { + name: formData.name.trim(), + namespace: formData.namespace.trim() || 'default', + json: config, + }; + await adminApis.createPublicModel(createData); + toast({ title: t('public_models.success.created') }); + setIsCreateDialogOpen(false); + resetForm(); + fetchModels(); + } catch (error) { + toast({ + variant: 'destructive', + title: t('public_models.errors.create_failed'), + description: (error as Error).message, + }); + } finally { + setSaving(false); + } + }; + + const handleUpdateModel = async () => { + if (!selectedModel) return; + + const config = validateConfig(formData.config); + if (!config) { + toast({ + variant: 'destructive', + title: t('public_models.errors.config_invalid_json'), + }); + return; + } + + setSaving(true); + try { + const updateData: AdminPublicModelUpdate = {}; + if (formData.name !== selectedModel.name) { + updateData.name = formData.name; + } + if (formData.namespace !== selectedModel.namespace) { + updateData.namespace = formData.namespace; + } + updateData.json = config; + + await adminApis.updatePublicModel(selectedModel.id, updateData); + toast({ title: t('public_models.success.updated') }); + setIsEditDialogOpen(false); + resetForm(); + fetchModels(); + } catch (error) { + toast({ + variant: 'destructive', + title: t('public_models.errors.update_failed'), + description: (error as Error).message, + }); + } finally { + setSaving(false); + } + }; + + const handleDeleteModel = async () => { + if (!selectedModel) return; + + setSaving(true); + try { + await adminApis.deletePublicModel(selectedModel.id); + toast({ title: t('public_models.success.deleted') }); + setIsDeleteDialogOpen(false); + setSelectedModel(null); + fetchModels(); + } catch (error) { + toast({ + variant: 'destructive', + title: t('public_models.errors.delete_failed'), + description: (error as Error).message, + }); + } finally { + setSaving(false); + } + }; + + const resetForm = () => { + setFormData({ + name: '', + namespace: 'default', + config: '{}', + }); + setConfigError(''); + setSelectedModel(null); + }; + + const openEditDialog = (model: AdminPublicModel) => { + setSelectedModel(model); + setFormData({ + name: model.name, + namespace: model.namespace, + config: JSON.stringify(model.json, null, 2), + }); + setIsEditDialogOpen(true); + }; + + const getModelProvider = (json: Record): string => { + const env = (json?.env as Record) || {}; + const model = env?.model as string; + if (model === 'openai') return 'OpenAI'; + if (model === 'claude') return 'Anthropic'; + return 'Unknown'; + }; + + const getModelId = (json: Record): string => { + const env = (json?.env as Record) || {}; + return (env?.model_id as string) || 'N/A'; + }; + + return ( +
+ {/* Header */} +
+

{t('public_models.title')}

+

{t('public_models.description')}

+
+ + {/* Content Container */} +
+ {/* Loading State */} + {loading && ( +
+ +
+ )} + + {/* Empty State */} + {!loading && models.length === 0 && ( +
+ +

{t('public_models.no_models')}

+
+ )} + + {/* Model List */} + {!loading && models.length > 0 && ( +
+ {models.map(model => ( + +
+
+ +
+
+

+ {model.name} +

+ {getModelProvider(model.json)} + {model.is_active ? ( + {t('public_models.status.active')} + ) : ( + {t('public_models.status.inactive')} + )} +
+
+ + {t('public_models.model_id')}: {getModelId(model.json)} + + + + {t('public_models.namespace_label')}: {model.namespace} + +
+
+
+
+ + +
+
+
+ ))} +
+ )} + + {/* Add Button */} + {!loading && ( +
+
+ setIsCreateDialogOpen(true)}> + {t('public_models.create_model')} + +
+
+ )} +
+ + {/* Create Model Dialog */} + + + + {t('public_models.create_model')} + {t('public_models.description')} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder={t('public_models.form.name_placeholder')} + /> +
+
+ + setFormData({ ...formData, namespace: e.target.value })} + placeholder={t('public_models.form.namespace_placeholder')} + /> +
+
+ +