Commit 738f4bcd authored by Administrator's avatar Administrator

Update 16 files via Son of Anton

parent 347bafba
"""
JWT authentication helpers.
Authentication helpers + permission system — Son of Anton v4.2.0
"""
from datetime import datetime, timedelta
......@@ -10,16 +10,15 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from backend import config
from backend.database import get_db
from backend.models import User
from backend.config import JWT_SECRET, DEFAULT_PERMISSIONS, SUPERADMIN_PERMISSIONS, PERMISSION_FIELDS
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
bearer_scheme = HTTPBearer()
def hash_password(plain: str) -> str:
return pwd_ctx.hash(plain)
def hash_password(password: str) -> str:
return pwd_ctx.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
......@@ -30,14 +29,14 @@ def create_token(user_id: str, role: str) -> str:
payload = {
"sub": user_id,
"role": role,
"exp": datetime.utcnow() + timedelta(hours=config.JWT_EXPIRY_HOURS),
"exp": datetime.utcnow() + timedelta(days=30),
}
return jwt.encode(payload, config.JWT_SECRET, algorithm=config.JWT_ALGORITHM)
return jwt.encode(payload, JWT_SECRET, algorithm="HS256")
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, config.JWT_SECRET, algorithms=[config.JWT_ALGORITHM])
return jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
except jwt.ExpiredSignatureError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token expired")
except jwt.InvalidTokenError:
......@@ -45,23 +44,177 @@ def decode_token(token: str) -> dict:
def get_current_user(
creds: HTTPAuthorizationCredentials = Depends(security),
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
) -> User:
payload = decode_token(creds.credentials)
):
from backend.models import User
payload = decode_token(credentials.credentials)
user = db.query(User).filter(User.id == payload["sub"]).first()
if not user or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or inactive")
if not user:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found")
if not user.is_active:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Account disabled")
return user
def require_admin(user: User = Depends(get_current_user)) -> User:
if user.role not in ("admin", "superadmin"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin access required")
def require_admin(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
):
from backend.models import User
payload = decode_token(credentials.credentials)
user = db.query(User).filter(User.id == payload["sub"]).first()
if not user or user.role not in ("admin", "superadmin"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin required")
return user
def require_superadmin(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
):
from backend.models import User
payload = decode_token(credentials.credentials)
user = db.query(User).filter(User.id == payload["sub"]).first()
if not user or user.role != "superadmin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Superadmin required")
return user
def require_superadmin(user: User = Depends(get_current_user)) -> User:
if user.role != "superadmin":
raise HTTPException(status.HTTP_403_FORBIDDEN, "Superadmin access required")
return user
\ No newline at end of file
# ═══════════════════════════════════════════════════
# PERMISSION SYSTEM
# ═══════════════════════════════════════════════════
def get_user_permissions(user_id: str, db: Session) -> dict:
"""
Get permissions dict for a user.
- Superadmins always get full permissions (bypass everything).
- Regular users get their stored permissions or defaults.
- Auto-creates permission row if missing.
"""
from backend.models import User, UserPermissions
user = db.query(User).filter(User.id == user_id).first()
if not user:
return dict(DEFAULT_PERMISSIONS)
if user.role == "superadmin":
return dict(SUPERADMIN_PERMISSIONS)
perms = db.query(UserPermissions).filter(UserPermissions.user_id == user_id).first()
if not perms:
perms = _create_default_permissions(user_id, db)
return _perms_to_dict(perms)
def get_default_permissions_template(db: Session) -> dict:
"""Get the stored default permissions template, or config defaults."""
from backend.models import UserPermissions
template = db.query(UserPermissions).filter(UserPermissions.user_id == "__defaults__").first()
if template:
return _perms_to_dict(template)
return dict(DEFAULT_PERMISSIONS)
def ensure_user_permissions(user_id: str, db: Session):
"""Make sure a user has a permissions row. Creates one from template if missing."""
from backend.models import UserPermissions
existing = db.query(UserPermissions).filter(UserPermissions.user_id == user_id).first()
if not existing:
_create_default_permissions(user_id, db)
def _create_default_permissions(user_id: str, db: Session):
"""Create a permissions row using the stored template or config defaults."""
from backend.models import UserPermissions
template = get_default_permissions_template(db)
perms = UserPermissions(user_id=user_id)
for field in PERMISSION_FIELDS:
if hasattr(perms, field):
setattr(perms, field, template.get(field, DEFAULT_PERMISSIONS.get(field)))
db.add(perms)
db.commit()
db.refresh(perms)
return perms
def _perms_to_dict(perms) -> dict:
"""Convert a UserPermissions ORM object to a dict."""
return {
"can_use_web_search": perms.can_use_web_search,
"can_use_ui_design": perms.can_use_ui_design,
"can_use_knowledge_base": perms.can_use_knowledge_base,
"can_use_gitlab": perms.can_use_gitlab,
"can_use_attachments": perms.can_use_attachments,
"can_export_pptx": perms.can_export_pptx,
"can_export_docx": perms.can_export_docx,
"allowed_models": perms.allowed_models or "all",
"max_tokens_cap": perms.max_tokens_cap or 4096,
"max_reasoning_budget": perms.max_reasoning_budget or 0,
"max_chats": perms.max_chats or 0,
"max_messages_per_day": perms.max_messages_per_day or 0,
"max_knowledge_bases": perms.max_knowledge_bases or 0,
"max_documents_per_kb": perms.max_documents_per_kb or 0,
"max_attachment_size_mb": perms.max_attachment_size_mb or 10,
"max_attachments_per_message": perms.max_attachments_per_message or 5,
}
def check_feature(user_id: str, feature: str, db: Session):
"""Check if user has access to a feature. Raises 403 if denied."""
perms = get_user_permissions(user_id, db)
key = f"can_{feature}" if not feature.startswith("can_") else feature
if not perms.get(key, False):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Feature '{feature.replace('can_', '').replace('use_', '')}' is not enabled for your account. Contact your admin.",
)
def check_model_allowed(user_id: str, model_id: str, db: Session) -> str:
"""Validate model access. Returns the model_id if allowed, or falls back to first allowed model."""
perms = get_user_permissions(user_id, db)
allowed = perms.get("allowed_models", "all")
if allowed == "all":
return model_id
allowed_list = [m.strip() for m in allowed.split(",") if m.strip()]
if model_id in allowed_list:
return model_id
if allowed_list:
return allowed_list[0]
return model_id
def count_user_messages_today(user_id: str, db: Session) -> int:
"""Count user messages sent today (UTC)."""
from backend.models import Chat, Message
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
return (
db.query(Message)
.join(Chat)
.filter(
Chat.user_id == user_id,
Message.role == "user",
Message.created_at >= today_start,
)
.count()
)
def count_user_chats(user_id: str, db: Session) -> int:
"""Count total chats for a user."""
from backend.models import Chat
return db.query(Chat).filter(Chat.user_id == user_id).count()
def count_user_knowledge_bases(user_id: str, db: Session) -> int:
"""Count knowledge bases owned by user."""
from backend.models import KnowledgeBase
return db.query(KnowledgeBase).filter(KnowledgeBase.user_id == user_id).count()
def count_kb_documents(kb_id: str, db: Session) -> int:
"""Count documents in a knowledge base."""
from backend.models import KnowledgeDocument
return db.query(KnowledgeDocument).filter(KnowledgeDocument.knowledge_base_id == kb_id).count()
\ No newline at end of file
"""
Application configuration — reads from environment variables.
Son of Anton v4.0.0
Son of Anton v4.2.0 — Configuration
"""
import os
import secrets
BEDROCK_API_KEY: str = os.getenv(
"BEDROCK_API_KEY",
os.getenv("AWS_BEARER_TOKEN_BEDROCK", ""),
)
AWS_REGION: str = os.getenv("AWS_REGION", "eu-central-1")
PRIMARY_MODEL: str = os.getenv("PRIMARY_MODEL", "eu.anthropic.claude-opus-4-6-v1")
FAST_MODEL: str = os.getenv("FAST_MODEL", "eu.anthropic.claude-haiku-4-5-20251001-v1:0")
APP_VERSION = "4.2.0"
JWT_SECRET: str = os.getenv("JWT_SECRET", secrets.token_hex(32))
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRY_HOURS: int = 72
# ═══════════════════════════════════════════════════
# AWS Bedrock
# ═══════════════════════════════════════════════════
BEDROCK_API_KEY = os.getenv("BEDROCK_API_KEY", "")
AWS_REGION = os.getenv("AWS_REGION", "eu-central-1")
BEDROCK_ENDPOINT = f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com"
SUPERADMIN_PASSWORD: str = os.getenv("SUPERADMIN_PASSWORD", "admin123")
# Models
PRIMARY_MODEL = os.getenv("PRIMARY_MODEL", "eu.anthropic.claude-opus-4-6-v1")
FAST_MODEL = os.getenv("FAST_MODEL", "eu.anthropic.claude-haiku-4-5-20251001-v1:0")
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:////data/sonofanton.db")
AVAILABLE_MODELS = [
{"id": "eu.anthropic.claude-opus-4-6-v1", "label": "Opus 4.6", "tier": "expensive"},
{"id": "eu.anthropic.claude-haiku-4-5-20251001-v1:0", "label": "Haiku 4.5", "tier": "cheap"},
]
CHROMADB_PATH: str = os.getenv("CHROMADB_PATH", "/data/chromadb")
UPLOAD_PATH: str = os.getenv("UPLOAD_PATH", "/data/uploads")
ATTACHMENT_PATH: str = os.getenv("ATTACHMENT_PATH", "/data/uploads/chat_attachments")
# ═══════════════════════════════════════════════════
# Auth
# ═══════════════════════════════════════════════════
JWT_SECRET = os.getenv("JWT_SECRET", "CreatedSystemsOverloadedFunctionsBySonOfAnton")
SUPERADMIN_PASSWORD = os.getenv("SUPERADMIN_PASSWORD", "admin123")
DEFAULT_QUOTA: int = int(os.getenv("DEFAULT_QUOTA", "2000000"))
MAX_UPLOAD_BYTES: int = int(os.getenv("MAX_UPLOAD_MB", "50")) * 1024 * 1024
MAX_ATTACHMENT_BYTES: int = int(os.getenv("MAX_ATTACHMENT_MB", "25")) * 1024 * 1024
# ═══════════════════════════════════════════════════
# Database
# ═══════════════════════════════════════════════════
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:////data/sonofanton.db")
MAX_IMAGE_DIMENSION: int = 1568
MAX_VIDEO_FRAMES: int = 6
# ═══════════════════════════════════════════════════
# Quotas & Uploads
# ═══════════════════════════════════════════════════
DEFAULT_QUOTA = int(os.getenv("DEFAULT_QUOTA", "2000000"))
MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "50"))
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
MAX_ATTACHMENT_BYTES = MAX_UPLOAD_BYTES
BEDROCK_ENDPOINT: str = (
f"https://bedrock-runtime.{AWS_REGION}.amazonaws.com"
)
# ═══════════════════════════════════════════════════
# Attachments
# ═══════════════════════════════════════════════════
ATTACHMENT_PATH = os.getenv("ATTACHMENT_PATH", "/data/uploads/chat_attachments")
MAX_IMAGE_DIMENSION = 2048
MAX_VIDEO_FRAMES = 6
# SerpAPI for web search
SERPAPI_KEY: str = os.getenv(
"SERPAPI_KEY",
"0f9efa98fb0fe7b27af609e8dd80e04c4af1e098ec81fe628a6d63aaaebe8bd6",
)
# ═══════════════════════════════════════════════════
# ChromaDB / RAG
# ═══════════════════════════════════════════════════
CHROMADB_PATH = os.getenv("CHROMADB_PATH", "/data/chromadb")
APP_VERSION: str = "4.2.0"
\ No newline at end of file
# ═══════════════════════════════════════════════════
# Web Search (SerpAPI)
# ═══════════════════════════════════════════════════
SERPAPI_KEY = os.getenv("SERPAPI_KEY", "")
# ═══════════════════════════════════════════════════
# PERMISSION DEFAULTS — applied to new regular users
# ═══════════════════════════════════════════════════
DEFAULT_PERMISSIONS = {
"can_use_web_search": False,
"can_use_ui_design": False,
"can_use_knowledge_base": True,
"can_use_gitlab": False,
"can_use_attachments": True,
"can_export_pptx": True,
"can_export_docx": True,
"allowed_models": "eu.anthropic.claude-haiku-4-5-20251001-v1:0",
"max_tokens_cap": 4096,
"max_reasoning_budget": 0,
"max_chats": 50,
"max_messages_per_day": 100,
"max_knowledge_bases": 3,
"max_documents_per_kb": 20,
"max_attachment_size_mb": 10,
"max_attachments_per_message": 5,
}
SUPERADMIN_PERMISSIONS = {
"can_use_web_search": True,
"can_use_ui_design": True,
"can_use_knowledge_base": True,
"can_use_gitlab": True,
"can_use_attachments": True,
"can_export_pptx": True,
"can_export_docx": True,
"allowed_models": "all",
"max_tokens_cap": 65536,
"max_reasoning_budget": 32000,
"max_chats": 0,
"max_messages_per_day": 0,
"max_knowledge_bases": 0,
"max_documents_per_kb": 0,
"max_attachment_size_mb": 50,
"max_attachments_per_message": 20,
}
PERMISSION_FIELDS = list(DEFAULT_PERMISSIONS.keys())
\ No newline at end of file
"""
Son of Anton v4.1.0 — Main FastAPI Application
Son of Anton v4.2.0 — Main FastAPI Application
"""
import time
......@@ -48,6 +48,12 @@ def _run_migrations():
from backend.models import ChatAttachment
ChatAttachment.__table__.create(bind=engine, checkfirst=True)
# Create user_permissions table if missing
if "user_permissions" not in existing_tables:
from backend.models import UserPermissions
UserPermissions.__table__.create(bind=engine, checkfirst=True)
print(" Created user_permissions table")
for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]:
if table_name not in existing_tables:
print(f" Creating {table_name} table")
......
"""
SQLAlchemy ORM models — Son of Anton v4.0.0
SQLAlchemy ORM models — Son of Anton v4.2.0
"""
from datetime import datetime
......@@ -39,6 +39,46 @@ class User(Base):
created_at = Column(DateTime, default=datetime.utcnow)
chats = relationship("Chat", back_populates="user", cascade="all,delete-orphan")
permissions = relationship(
"UserPermissions", back_populates="user", uselist=False,
cascade="all,delete-orphan",
)
class UserPermissions(Base):
__tablename__ = "user_permissions"
id = Column(String(36), primary_key=True, default=new_id)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"),
unique=True, nullable=False, index=True,
)
# Feature access
can_use_web_search = Column(Boolean, default=False)
can_use_ui_design = Column(Boolean, default=False)
can_use_knowledge_base = Column(Boolean, default=True)
can_use_gitlab = Column(Boolean, default=False)
can_use_attachments = Column(Boolean, default=True)
can_export_pptx = Column(Boolean, default=True)
can_export_docx = Column(Boolean, default=True)
# Model access — "all" or comma-separated model IDs
allowed_models = Column(Text, default="eu.anthropic.claude-haiku-4-5-20251001-v1:0")
# Limits (0 = unlimited for count-based limits)
max_tokens_cap = Column(Integer, default=4096)
max_reasoning_budget = Column(Integer, default=0)
max_chats = Column(Integer, default=50)
max_messages_per_day = Column(Integer, default=100)
max_knowledge_bases = Column(Integer, default=3)
max_documents_per_kb = Column(Integer, default=20)
max_attachment_size_mb = Column(Integer, default=10)
max_attachments_per_message = Column(Integer, default=5)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="permissions")
class Chat(Base):
......@@ -128,7 +168,7 @@ class KnowledgeDocument(Base):
# ═══════════════════════════════════════════════════════════
# GitLab Integration Models — v4.0.0
# GitLab Integration Models
# ═══════════════════════════════════════════════════════════
class GitLabSettings(Base):
......
"""
Superadmin routes: user management, stats, oversight.
Superadmin routes: user management, stats, permissions — v4.2.0
"""
from pydantic import BaseModel
......@@ -10,8 +10,12 @@ from sqlalchemy.orm import Session
from sqlalchemy import func
from backend.database import get_db
from backend.models import User, Chat, Message, KnowledgeBase
from backend.auth import require_superadmin, hash_password
from backend.models import User, Chat, Message, KnowledgeBase, UserPermissions
from backend.auth import (
require_superadmin, hash_password, get_user_permissions,
ensure_user_permissions, get_default_permissions_template,
)
from backend.config import PERMISSION_FIELDS, DEFAULT_PERMISSIONS, SUPERADMIN_PERMISSIONS, AVAILABLE_MODELS
router = APIRouter()
......@@ -32,6 +36,29 @@ class CreateUserBody(BaseModel):
quota_tokens_monthly: int = 2_000_000
class PermissionsBody(BaseModel):
can_use_web_search: Optional[bool] = None
can_use_ui_design: Optional[bool] = None
can_use_knowledge_base: Optional[bool] = None
can_use_gitlab: Optional[bool] = None
can_use_attachments: Optional[bool] = None
can_export_pptx: Optional[bool] = None
can_export_docx: Optional[bool] = None
allowed_models: Optional[str] = None
max_tokens_cap: Optional[int] = None
max_reasoning_budget: Optional[int] = None
max_chats: Optional[int] = None
max_messages_per_day: Optional[int] = None
max_knowledge_bases: Optional[int] = None
max_documents_per_kb: Optional[int] = None
max_attachment_size_mb: Optional[int] = None
max_attachments_per_message: Optional[int] = None
# ═══════════════════════════════════════════════════
# Stats & Users
# ═══════════════════════════════════════════════════
@router.get("/stats")
def get_stats(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
return {
......@@ -79,6 +106,9 @@ def create_user(body: CreateUserBody, admin: User = Depends(require_superadmin),
)
db.add(user)
db.commit()
db.refresh(user)
# Auto-create permissions from defaults template
ensure_user_permissions(user.id, db)
return {"id": user.id, "username": user.username}
......@@ -127,4 +157,78 @@ def list_all_chats(admin: User = Depends(require_superadmin), db: Session = Depe
"message_count": msg_count,
"updated_at": str(c.updated_at),
})
return result
\ No newline at end of file
return result
# ═══════════════════════════════════════════════════
# PERMISSIONS MANAGEMENT
# ═══════════════════════════════════════════════════
@router.get("/models")
def list_available_models(admin: User = Depends(require_superadmin)):
return AVAILABLE_MODELS
@router.get("/permissions/defaults")
def get_defaults(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
return get_default_permissions_template(db)
@router.put("/permissions/defaults")
def update_defaults(body: PermissionsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
template = db.query(UserPermissions).filter(UserPermissions.user_id == "__defaults__").first()
if not template:
template = UserPermissions(user_id="__defaults__")
db.add(template)
_apply_permissions_body(template, body)
db.commit()
return get_default_permissions_template(db)
@router.post("/permissions/apply-defaults")
def apply_defaults_to_all(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
"""Apply default permissions template to ALL non-superadmin users."""
template = get_default_permissions_template(db)
users = db.query(User).filter(User.role != "superadmin").all()
count = 0
for user in users:
perms = db.query(UserPermissions).filter(UserPermissions.user_id == user.id).first()
if not perms:
perms = UserPermissions(user_id=user.id)
db.add(perms)
for field in PERMISSION_FIELDS:
if hasattr(perms, field):
setattr(perms, field, template.get(field))
count += 1
db.commit()
return {"ok": True, "users_updated": count}
@router.get("/users/{user_id}/permissions")
def get_user_perms(user_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
return get_user_permissions(user_id, db)
@router.put("/users/{user_id}/permissions")
def update_user_perms(user_id: str, body: PermissionsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
if user.role == "superadmin":
raise HTTPException(400, "Cannot modify superadmin permissions — they always have full access")
ensure_user_permissions(user_id, db)
perms = db.query(UserPermissions).filter(UserPermissions.user_id == user_id).first()
_apply_permissions_body(perms, body)
db.commit()
return get_user_permissions(user_id, db)
def _apply_permissions_body(perms: UserPermissions, body: PermissionsBody):
"""Apply non-None fields from the body to a permissions object."""
data = body.dict(exclude_none=True)
for field, value in data.items():
if hasattr(perms, field):
setattr(perms, field, value)
\ No newline at end of file
"""
Chat attachment upload, serve, and delete routes.
Chat attachment upload, serve, delete — v4.2.0 with permission enforcement.
"""
import os
......@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, Chat, ChatAttachment
from backend.auth import get_current_user, decode_token
from backend.auth import get_current_user, decode_token, get_user_permissions, check_feature
from backend.services import attachment_service
from backend.config import MAX_ATTACHMENT_BYTES
......@@ -41,6 +41,15 @@ async def upload_attachments(
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
check_feature(user.id, "use_attachments", db)
perms = get_user_permissions(user.id, db)
max_per_msg = perms.get("max_attachments_per_message", 5)
if len(files) > max_per_msg:
raise HTTPException(400, f"Too many files. Max {max_per_msg} per message.")
max_size = perms.get("max_attachment_size_mb", 10) * 1024 * 1024
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat:
raise HTTPException(404, "Chat not found")
......@@ -50,28 +59,22 @@ async def upload_attachments(
filename = file.filename or "file"
try:
content = await file.read()
if len(content) > MAX_ATTACHMENT_BYTES:
results.append({"error": f"Too large: {filename}"})
if len(content) > max_size:
results.append({"error": f"Too large: {filename} ({len(content) // 1024 // 1024}MB). Your limit: {perms.get('max_attachment_size_mb', 10)}MB."})
continue
meta = attachment_service.save_attachment(
chat_id=chat_id, filename=filename,
content=content, content_type=file.content_type,
)
att = ChatAttachment(
id=meta["id"], chat_id=chat_id,
filename=meta["filename"],
original_filename=meta["original_filename"],
mime_type=meta["mime_type"],
file_type=meta["file_type"],
file_size=meta["file_size"],
storage_path=meta["storage_path"],
filename=meta["filename"], original_filename=meta["original_filename"],
mime_type=meta["mime_type"], file_type=meta["file_type"],
file_size=meta["file_size"], storage_path=meta["storage_path"],
text_extract=meta.get("text_extract"),
)
db.add(att)
db.commit()
db.refresh(att)
db.add(att); db.commit(); db.refresh(att)
results.append(_att_dict(att))
except Exception as e:
results.append({"error": f"Failed: {filename}: {str(e)}"})
......@@ -80,16 +83,10 @@ async def upload_attachments(
@router.get("/attachments/{attachment_id}/file")
def serve_attachment(
attachment_id: str,
request: Request,
token: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
def serve_attachment(attachment_id: str, request: Request, token: Optional[str] = Query(None), db: Session = Depends(get_db)):
user = _get_user_flexible(request, db, token)
att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
if not att:
raise HTTPException(404, "Attachment not found")
if not att: raise HTTPException(404, "Attachment not found")
chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
if not chat or (chat.user_id != user.id and user.role != "superadmin"):
raise HTTPException(403, "Access denied")
......@@ -99,20 +96,14 @@ def serve_attachment(
@router.delete("/attachments/{attachment_id}")
def delete_attachment(
attachment_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
def delete_attachment(attachment_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
att = db.query(ChatAttachment).filter(ChatAttachment.id == attachment_id).first()
if not att:
raise HTTPException(404)
if not att: raise HTTPException(404)
chat = db.query(Chat).filter(Chat.id == att.chat_id).first()
if not chat or (chat.user_id != user.id and user.role != "superadmin"):
raise HTTPException(403)
attachment_service.delete_attachment_file(att.storage_path)
db.delete(att)
db.commit()
db.delete(att); db.commit()
return {"ok": True}
......@@ -122,4 +113,4 @@ def _att_dict(att):
"filename": att.filename, "original_filename": att.original_filename,
"mime_type": att.mime_type, "file_type": att.file_type,
"file_size": att.file_size, "created_at": str(att.created_at),
}
}
\ No newline at end of file
"""
Authentication routes: register, login, profile.
Authentication routes: register, login, profile — with permissions.
"""
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
......@@ -10,6 +10,7 @@ from backend.database import get_db
from backend.models import User
from backend.auth import (
hash_password, verify_password, create_token, get_current_user,
get_user_permissions, ensure_user_permissions,
)
from backend import config
......@@ -27,20 +28,6 @@ class LoginBody(BaseModel):
password: str
class ProfileOut(BaseModel):
id: str
username: str
email: str
role: str
is_active: bool
quota_tokens_monthly: int
tokens_used_this_month: int
created_at: str
class Config:
from_attributes = True
@router.post("/register")
def register(body: RegisterBody, db: Session = Depends(get_db)):
if db.query(User).filter(
......@@ -58,8 +45,13 @@ def register(body: RegisterBody, db: Session = Depends(get_db)):
db.add(user)
db.commit()
db.refresh(user)
# Auto-create permissions from defaults template
ensure_user_permissions(user.id, db)
token = create_token(user.id, user.role)
return {"token": token, "user": _user_dict(user)}
perms = get_user_permissions(user.id, db)
return {"token": token, "user": _user_dict(user, perms)}
@router.post("/login")
......@@ -70,16 +62,18 @@ def login(body: LoginBody, db: Session = Depends(get_db)):
if not user.is_active:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Account disabled")
token = create_token(user.id, user.role)
return {"token": token, "user": _user_dict(user)}
perms = get_user_permissions(user.id, db)
return {"token": token, "user": _user_dict(user, perms)}
@router.get("/me")
def me(user: User = Depends(get_current_user)):
return _user_dict(user)
def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
perms = get_user_permissions(user.id, db)
return _user_dict(user, perms)
def _user_dict(u: User) -> dict:
return {
def _user_dict(u: User, perms: dict = None) -> dict:
d = {
"id": u.id,
"username": u.username,
"email": u.email,
......@@ -88,4 +82,7 @@ def _user_dict(u: User) -> dict:
"quota_tokens_monthly": u.quota_tokens_monthly,
"tokens_used_this_month": u.tokens_used_this_month,
"created_at": str(u.created_at),
}
\ No newline at end of file
}
if perms is not None:
d["permissions"] = perms
return d
\ No newline at end of file
"""
Chat CRUD + message streaming — v4.1.0 with web search support.
Chat CRUD + message streaming — v4.2.0 with permission enforcement.
"""
import json
......@@ -13,7 +13,10 @@ from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, Chat, Message, ChatAttachment, LinkedRepo, GitLabSettings
from backend.auth import get_current_user
from backend.auth import (
get_current_user, get_user_permissions, check_feature,
check_model_allowed, count_user_chats, count_user_messages_today,
)
from backend.services import attachment_service, gitlab_service
from backend.services.generation_manager import manager as gen_manager
......@@ -62,7 +65,32 @@ def list_chats(user: User = Depends(get_current_user), db: Session = Depends(get
@router.post("")
def create_chat(body: CreateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = Chat(user_id=user.id, title=body.title, model=body.model, knowledge_base_id=body.knowledge_base_id or None, linked_repo_id=body.linked_repo_id or None, max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget)
perms = get_user_permissions(user.id, db)
# Enforce max chats
max_chats = perms.get("max_chats", 0)
if max_chats > 0:
current_count = count_user_chats(user.id, db)
if current_count >= max_chats:
raise HTTPException(403, f"Chat limit reached ({max_chats}). Delete old chats or contact admin.")
# Validate KB permission
if body.knowledge_base_id:
if not perms.get("can_use_knowledge_base"):
raise HTTPException(403, "Knowledge base access not enabled for your account.")
# Validate GitLab permission
if body.linked_repo_id:
if not perms.get("can_use_gitlab"):
raise HTTPException(403, "GitLab access not enabled for your account.")
chat = Chat(
user_id=user.id, title=body.title,
model=check_model_allowed(user.id, body.model, db),
knowledge_base_id=body.knowledge_base_id or None,
linked_repo_id=body.linked_repo_id or None,
max_tokens=min(body.max_tokens, perms.get("max_tokens_cap", 4096)),
reasoning_budget=min(body.reasoning_budget, perms.get("max_reasoning_budget", 0)),
)
db.add(chat); db.commit(); db.refresh(chat)
return _chat_dict(chat, db)
......@@ -78,12 +106,19 @@ def get_chat(chat_id: str, user: User = Depends(get_current_user), db: Session =
def update_chat(chat_id: str, body: UpdateChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: raise HTTPException(404)
perms = get_user_permissions(user.id, db)
if body.title is not None: chat.title = body.title
if body.model is not None: chat.model = body.model
if body.max_tokens is not None: chat.max_tokens = body.max_tokens
if body.reasoning_budget is not None: chat.reasoning_budget = body.reasoning_budget
if body.knowledge_base_id is not None: chat.knowledge_base_id = body.knowledge_base_id or None
if body.linked_repo_id is not None: chat.linked_repo_id = body.linked_repo_id or None
if body.model is not None: chat.model = check_model_allowed(user.id, body.model, db)
if body.max_tokens is not None: chat.max_tokens = min(body.max_tokens, perms.get("max_tokens_cap", 4096))
if body.reasoning_budget is not None: chat.reasoning_budget = min(body.reasoning_budget, perms.get("max_reasoning_budget", 0))
if body.knowledge_base_id is not None:
if body.knowledge_base_id and not perms.get("can_use_knowledge_base"):
raise HTTPException(403, "Knowledge base not enabled.")
chat.knowledge_base_id = body.knowledge_base_id or None
if body.linked_repo_id is not None:
if body.linked_repo_id and not perms.get("can_use_gitlab"):
raise HTTPException(403, "GitLab not enabled.")
chat.linked_repo_id = body.linked_repo_id or None
db.commit()
return _chat_dict(chat, db)
......@@ -128,14 +163,41 @@ async def reconnect_stream(chat_id: str, user: User = Depends(get_current_user))
@router.post("/{chat_id}/messages")
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user)):
async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
perms = get_user_permissions(user.id, db)
# Enforce daily message limit
max_per_day = perms.get("max_messages_per_day", 0)
if max_per_day > 0:
today_count = count_user_messages_today(user.id, db)
if today_count >= max_per_day:
raise HTTPException(429, f"Daily message limit reached ({max_per_day}). Try again tomorrow.")
# Enforce web search permission
web_search = body.web_search
if web_search and not perms.get("can_use_web_search"):
web_search = False
# Enforce attachment permission
if body.attachment_ids and not perms.get("can_use_attachments"):
raise HTTPException(403, "File attachments not enabled for your account.")
if body.attachment_ids:
max_att = perms.get("max_attachments_per_message", 5)
if len(body.attachment_ids) > max_att:
raise HTTPException(400, f"Too many attachments. Max {max_att} per message.")
# Enforce model & limits
model = check_model_allowed(user.id, body.model or "eu.anthropic.claude-opus-4-6-v1", db)
max_tokens = min(body.max_tokens, perms.get("max_tokens_cap", 4096))
reasoning_budget = min(body.reasoning_budget, perms.get("max_reasoning_budget", 0))
gen_manager.start(
chat_id=chat_id, user_id=user.id, content=body.content,
model=body.model or "eu.anthropic.claude-opus-4-6-v1",
max_tokens=body.max_tokens, reasoning_budget=body.reasoning_budget,
model=model, max_tokens=max_tokens, reasoning_budget=reasoning_budget,
knowledge_base_id=body.knowledge_base_id,
attachment_ids=body.attachment_ids,
web_search=body.web_search,
web_search=web_search,
)
async def generate():
async for event in gen_manager.stream_events(chat_id):
......@@ -145,6 +207,7 @@ async def send_message(chat_id: str, body: SendMessageBody, user: User = Depends
@router.post("/{chat_id}/commit")
async def commit_from_chat(chat_id: str, body: CommitFromChatBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
check_feature(user.id, "use_gitlab", db)
chat = db.query(Chat).filter(Chat.id == chat_id, Chat.user_id == user.id).first()
if not chat: raise HTTPException(404, "Chat not found")
if not chat.linked_repo_id: raise HTTPException(400, "No repository linked")
......
"""
Export routes — PPTX and DOCX generation from markdown.
Export routes — PPTX and DOCX with permission checks.
"""
import re
......@@ -10,10 +10,12 @@ from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
import io
from backend.auth import get_current_user
from backend.database import get_db
from backend.auth import get_current_user, check_feature
from backend.models import User
from backend.services.pptx_service import generate_pptx
from backend.services.docx_service import generate_docx
from sqlalchemy.orm import Session
router = APIRouter()
......@@ -32,7 +34,8 @@ def _safe_filename(title: str, ext: str) -> str:
@router.post("/pptx")
def export_pptx(body: ExportBody, user: User = Depends(get_current_user)):
def export_pptx(body: ExportBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
check_feature(user.id, "export_pptx", db)
title = body.title or "Presentation"
data = generate_pptx(body.markdown, title)
filename = _safe_filename(title, "pptx")
......@@ -44,7 +47,8 @@ def export_pptx(body: ExportBody, user: User = Depends(get_current_user)):
@router.post("/docx")
def export_docx(body: ExportBody, user: User = Depends(get_current_user)):
def export_docx(body: ExportBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
check_feature(user.id, "export_docx", db)
title = body.title or "Document"
data = generate_docx(body.markdown, title)
filename = _safe_filename(title, "docx")
......
"""
Knowledge base management and document upload.
Full CRUD for knowledge bases AND their individual documents.
Knowledge base management — v4.2.0 with permission enforcement.
"""
from pydantic import BaseModel
......@@ -11,7 +10,10 @@ from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, KnowledgeBase, KnowledgeDocument
from backend.auth import get_current_user
from backend.auth import (
get_current_user, get_user_permissions, check_feature,
count_user_knowledge_bases, count_kb_documents,
)
from backend.services import rag_service
from backend.config import MAX_UPLOAD_BYTES
......@@ -28,10 +30,6 @@ class UpdateKBBody(BaseModel):
description: Optional[str] = None
# ═══════════════════════════════════════════════════
# Knowledge Base CRUD
# ═══════════════════════════════════════════════════
@router.get("")
def list_knowledge_bases(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
kbs = db.query(KnowledgeBase).filter(
......@@ -42,6 +40,14 @@ def list_knowledge_bases(user: User = Depends(get_current_user), db: Session = D
@router.post("")
def create_kb(body: CreateKBBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
check_feature(user.id, "use_knowledge_base", db)
perms = get_user_permissions(user.id, db)
max_kbs = perms.get("max_knowledge_bases", 0)
if max_kbs > 0:
current = count_user_knowledge_bases(user.id, db)
if current >= max_kbs:
raise HTTPException(403, f"Knowledge base limit reached ({max_kbs}). Delete old ones or contact admin.")
kb = KnowledgeBase(user_id=user.id, name=body.name, description=body.description)
db.add(kb)
db.commit()
......@@ -59,200 +65,102 @@ def get_kb(kb_id: str, user: User = Depends(get_current_user), db: Session = Dep
.order_by(KnowledgeDocument.created_at.desc())
.all()
)
return {
**_kb_dict(kb),
"documents": [_doc_dict(d) for d in docs],
}
return {**_kb_dict(kb), "documents": [_doc_dict(d) for d in docs]}
@router.put("/{kb_id}")
def update_kb(
kb_id: str,
body: UpdateKBBody,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
def update_kb(kb_id: str, body: UpdateKBBody, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
kb = _get_kb(kb_id, user, db)
if body.name is not None:
kb.name = body.name
if body.description is not None:
kb.description = body.description
db.commit()
db.refresh(kb)
if body.name is not None: kb.name = body.name
if body.description is not None: kb.description = body.description
db.commit(); db.refresh(kb)
return _kb_dict(kb)
@router.delete("/{kb_id}")
def delete_kb(kb_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
kb = _get_kb(kb_id, user, db)
try:
rag_service.delete_collection(kb_id)
except Exception:
pass
try: rag_service.delete_collection(kb_id)
except Exception: pass
db.query(KnowledgeDocument).filter(KnowledgeDocument.knowledge_base_id == kb_id).delete()
db.delete(kb)
db.commit()
db.delete(kb); db.commit()
return {"ok": True}
# ═══════════════════════════════════════════════════
# Document Management
# ═══════════════════════════════════════════════════
@router.get("/{kb_id}/documents")
def list_documents(
kb_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List all documents in a knowledge base."""
def list_documents(kb_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
_get_kb(kb_id, user, db)
docs = (
db.query(KnowledgeDocument)
.filter(KnowledgeDocument.knowledge_base_id == kb_id)
.order_by(KnowledgeDocument.created_at.desc())
.all()
)
docs = db.query(KnowledgeDocument).filter(KnowledgeDocument.knowledge_base_id == kb_id).order_by(KnowledgeDocument.created_at.desc()).all()
return [_doc_dict(d) for d in docs]
@router.delete("/{kb_id}/documents/{doc_id}")
def delete_document(
kb_id: str,
doc_id: str,
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete a single document from a knowledge base, including its vector chunks."""
def delete_document(kb_id: str, doc_id: str, user: User = Depends(get_current_user), db: Session = Depends(get_db)):
kb = _get_kb(kb_id, user, db)
doc = (
db.query(KnowledgeDocument)
.filter(KnowledgeDocument.id == doc_id, KnowledgeDocument.knowledge_base_id == kb_id)
.first()
)
if not doc:
raise HTTPException(404, "Document not found")
# Remove vector chunks from ChromaDB
try:
removed_count = rag_service.delete_document_chunks(kb_id, doc.filename)
except Exception:
removed_count = 0
# Update KB aggregate stats
doc = db.query(KnowledgeDocument).filter(KnowledgeDocument.id == doc_id, KnowledgeDocument.knowledge_base_id == kb_id).first()
if not doc: raise HTTPException(404, "Document not found")
try: removed_count = rag_service.delete_document_chunks(kb_id, doc.filename)
except Exception: removed_count = 0
kb.document_count = max((kb.document_count or 0) - 1, 0)
kb.chunk_count = max((kb.chunk_count or 0) - (doc.chunk_count or 0), 0)
# Estimate character reduction (rough: chunk_count * avg_chunk_size)
estimated_chars = (doc.chunk_count or 0) * 2000
kb.total_characters = max((kb.total_characters or 0) - estimated_chars, 0)
db.delete(doc); db.commit()
return {"ok": True, "chunks_removed": removed_count, "document_id": doc_id, "filename": doc.filename}
db.delete(doc)
db.commit()
return {
"ok": True,
"chunks_removed": removed_count,
"document_id": doc_id,
"filename": doc.filename,
}
# ═══════════════════════════════════════════════════
# Upload Documents
# ═══════════════════════════════════════════════════
@router.post("/{kb_id}/upload")
async def upload_documents(
kb_id: str,
files: list[UploadFile] = File(...),
user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Upload one or more documents to a knowledge base."""
async def upload_documents(kb_id: str, files: list[UploadFile] = File(...), user: User = Depends(get_current_user), db: Session = Depends(get_db)):
check_feature(user.id, "use_knowledge_base", db)
kb = _get_kb(kb_id, user, db)
perms = get_user_permissions(user.id, db)
results = []
total_new_docs = 0
total_new_chunks = 0
total_new_chars = 0
# Enforce max documents per KB
max_docs = perms.get("max_documents_per_kb", 0)
if max_docs > 0:
current_docs = count_kb_documents(kb_id, db)
if current_docs + len(files) > max_docs:
raise HTTPException(403, f"Document limit for this KB: {max_docs}. Currently: {current_docs}. Trying to add: {len(files)}.")
results = []; total_new_docs = 0; total_new_chunks = 0; total_new_chars = 0
for file in files:
filename = file.filename or "document.txt"
try:
content_bytes = await file.read()
if len(content_bytes) > MAX_UPLOAD_BYTES:
results.append({
"filename": filename,
"error": f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024}MB",
})
results.append({"filename": filename, "error": f"File too large. Max {MAX_UPLOAD_BYTES // 1024 // 1024}MB"})
continue
text = _extract_text(filename, content_bytes)
if not text.strip():
results.append({"filename": filename, "error": "Could not extract text from file"})
continue
chunks = _chunk_text(text, chunk_size=3000, overlap=300)
rag_service.add_documents(
collection_id=kb_id,
documents=chunks,
metadatas=[{"filename": filename, "chunk_index": i} for i in range(len(chunks))],
)
doc = KnowledgeDocument(
knowledge_base_id=kb_id,
filename=filename,
file_size=len(content_bytes),
chunk_count=len(chunks),
)
rag_service.add_documents(collection_id=kb_id, documents=chunks, metadatas=[{"filename": filename, "chunk_index": i} for i in range(len(chunks))])
doc = KnowledgeDocument(knowledge_base_id=kb_id, filename=filename, file_size=len(content_bytes), chunk_count=len(chunks))
db.add(doc)
total_new_docs += 1
total_new_chunks += len(chunks)
total_new_chars += len(text)
results.append({
"filename": filename,
"chunks_added": len(chunks),
"characters": len(text),
"estimated_tokens": len(text) // 4,
})
total_new_docs += 1; total_new_chunks += len(chunks); total_new_chars += len(text)
results.append({"filename": filename, "chunks_added": len(chunks), "characters": len(text), "estimated_tokens": len(text) // 4})
except HTTPException as e:
results.append({"filename": filename, "error": str(e.detail)})
except Exception as e:
results.append({"filename": filename, "error": str(e)})
# Update KB aggregate stats
kb.document_count = (kb.document_count or 0) + total_new_docs
kb.chunk_count = (kb.chunk_count or 0) + total_new_chunks
kb.total_characters = (kb.total_characters or 0) + total_new_chars
db.commit()
return {
"files": results,
"total_files": len(results),
"total_chunks_added": total_new_chunks,
"total_characters": total_new_chars,
}
return {"files": results, "total_files": len(results), "total_chunks_added": total_new_chunks, "total_characters": total_new_chars}
# ═══════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════
def _get_kb(kb_id: str, user: User, db: Session) -> KnowledgeBase:
def _get_kb(kb_id, user, db):
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first()
if not kb:
raise HTTPException(404, "Knowledge base not found")
if not kb: raise HTTPException(404, "Knowledge base not found")
if kb.user_id and kb.user_id != user.id and user.role != "superadmin":
raise HTTPException(403, "Access denied")
return kb
def _extract_text(filename: str, content: bytes) -> str:
def _extract_text(filename, content):
lower = filename.lower()
if lower.endswith(".pdf"):
try:
......@@ -263,48 +171,27 @@ def _extract_text(filename: str, content: bytes) -> str:
except Exception as e:
raise HTTPException(400, f"PDF extraction failed: {e}")
else:
try:
return content.decode("utf-8")
except UnicodeDecodeError:
return content.decode("latin-1")
try: return content.decode("utf-8")
except UnicodeDecodeError: return content.decode("latin-1")
def _chunk_text(text: str, chunk_size: int = 3000, overlap: int = 300) -> list[str]:
chunks = []
start = 0
def _chunk_text(text, chunk_size=3000, overlap=300):
chunks = []; start = 0
while start < len(text):
end = start + chunk_size
if end < len(text):
for sep in ["\n\n", "\n", ". ", " "]:
pos = text.rfind(sep, start + chunk_size // 2, end)
if pos > start:
end = pos + len(sep)
break
if pos > start: end = pos + len(sep); break
chunk = text[start:end].strip()
if chunk:
chunks.append(chunk)
if chunk: chunks.append(chunk)
start = end - overlap if end < len(text) else end
return chunks
def _kb_dict(kb: KnowledgeBase) -> dict:
return {
"id": kb.id,
"name": kb.name,
"description": kb.description or "",
"document_count": kb.document_count or 0,
"chunk_count": kb.chunk_count or 0,
"total_characters": kb.total_characters or 0,
"estimated_tokens": (kb.total_characters or 0) // 4,
"created_at": str(kb.created_at),
}
def _doc_dict(d: KnowledgeDocument) -> dict:
return {
"id": d.id,
"filename": d.filename,
"file_size": d.file_size or 0,
"chunk_count": d.chunk_count or 0,
"created_at": str(d.created_at),
}
\ No newline at end of file
def _kb_dict(kb):
return {"id": kb.id, "name": kb.name, "description": kb.description or "", "document_count": kb.document_count or 0, "chunk_count": kb.chunk_count or 0, "total_characters": kb.total_characters or 0, "estimated_tokens": (kb.total_characters or 0) // 4, "created_at": str(kb.created_at)}
def _doc_dict(d):
return {"id": d.id, "filename": d.filename, "file_size": d.file_size or 0, "chunk_count": d.chunk_count or 0, "created_at": str(d.created_at)}
\ No newline at end of file
"""
Seed the superadmin account on first startup.
Seed superadmin user and default permissions template.
"""
from backend.database import SessionLocal
from backend.models import User
from backend.models import User, UserPermissions
from backend.auth import hash_password
from backend import config
from backend.config import SUPERADMIN_PASSWORD, SUPERADMIN_PERMISSIONS, DEFAULT_PERMISSIONS, PERMISSION_FIELDS
def seed_superadmin():
......@@ -13,17 +13,50 @@ def seed_superadmin():
try:
existing = db.query(User).filter(User.username == "superadmin").first()
if not existing:
admin = User(
user = User(
username="superadmin",
email="admin@sonofanton.local",
password_hash=hash_password(config.SUPERADMIN_PASSWORD),
password_hash=hash_password(SUPERADMIN_PASSWORD),
role="superadmin",
quota_tokens_monthly=999_999_999,
)
db.add(admin)
db.add(user)
db.commit()
print("✅ Superadmin account created.")
db.refresh(user)
print(f" Created superadmin (password: {SUPERADMIN_PASSWORD})")
# Create superadmin permissions
perms = UserPermissions(user_id=user.id)
for field in PERMISSION_FIELDS:
if hasattr(perms, field):
setattr(perms, field, SUPERADMIN_PERMISSIONS.get(field))
db.add(perms)
db.commit()
print(" Created superadmin permissions")
else:
print("ℹ️ Superadmin account already exists.")
# Ensure superadmin has permissions row
sp = db.query(UserPermissions).filter(UserPermissions.user_id == existing.id).first()
if not sp:
perms = UserPermissions(user_id=existing.id)
for field in PERMISSION_FIELDS:
if hasattr(perms, field):
setattr(perms, field, SUPERADMIN_PERMISSIONS.get(field))
db.add(perms)
db.commit()
print(" Created superadmin permissions (existing user)")
# Create/update defaults template (special row with user_id = "__defaults__")
defaults = db.query(UserPermissions).filter(UserPermissions.user_id == "__defaults__").first()
if not defaults:
defaults = UserPermissions(user_id="__defaults__")
for field in PERMISSION_FIELDS:
if hasattr(defaults, field):
setattr(defaults, field, DEFAULT_PERMISSIONS.get(field))
db.add(defaults)
db.commit()
print(" Created default permissions template")
except Exception as e:
print(f" Seed error: {e}")
finally:
db.close()
\ No newline at end of file
......@@ -65,59 +65,33 @@ export const adminUpdateUser = (t, id, d) => request("PUT", `/admin/users/${id}`
export const adminDeleteUser = (t, id) => request("DELETE", `/admin/users/${id}`, t);
export const adminListChats = (t) => request("GET", "/admin/chats", t);
// Admin — Permissions
export const adminGetUserPermissions = (t, uid) => request("GET", `/admin/users/${uid}/permissions`, t);
export const adminUpdateUserPermissions = (t, uid, d) => request("PUT", `/admin/users/${uid}/permissions`, t, d);
export const adminGetDefaultPermissions = (t) => request("GET", "/admin/permissions/defaults", t);
export const adminUpdateDefaultPermissions = (t, d) => request("PUT", "/admin/permissions/defaults", t, d);
export const adminApplyDefaults = (t) => request("POST", "/admin/permissions/apply-defaults", t);
export const adminGetModels = (t) => request("GET", "/admin/models", t);
// Code Download
export async function downloadZip(t, md, title) { const res = await fetch(`${BASE}/files/download-zip`, { method: "POST", headers: headers(t), body: JSON.stringify({ markdown: md, title: title || null }) }); if (!res.ok) throw new Error("Download failed"); const ct = res.headers.get("content-type") || ""; if (ct.includes("application/zip")) { const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const raw = (title || "").trim(); a.download = `${raw && raw !== "New Chat" ? raw.replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 60) || "code" : "code"}.zip`; a.click(); URL.revokeObjectURL(url); } else { const data = await res.json(); if (data.error) throw new Error(data.error); } }
// ═══════ EXPORT PPTX / DOCX ═══════
// Export PPTX / DOCX
export async function exportPptx(token, markdown, title) {
const res = await fetch(`${BASE}/export/pptx`, { method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title }) });
if (!res.ok) throw new Error("PPTX export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "PPTX export failed")); }
const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url;
const safe = (title || "presentation").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 50) || "presentation";
a.download = `${safe}.pptx`;
a.click();
URL.revokeObjectURL(url);
a.download = `${safe}.pptx`; a.click(); URL.revokeObjectURL(url);
}
export async function exportDocx(token, markdown, title) {
const res = await fetch(`${BASE}/export/docx`, { method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title }) });
if (!res.ok) throw new Error("DOCX export failed");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "DOCX export failed")); }
const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url;
const safe = (title || "document").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").slice(0, 50) || "document";
a.download = `${safe}.docx`;
a.click();
URL.revokeObjectURL(url);
a.download = `${safe}.docx`; a.click(); URL.revokeObjectURL(url);
}
// Utilities
const CODE_BLOCK_RE = /```(\S*?)(?::(\S+?))?\s*?\n([\s\S]*?)```/g;
export function extractCodeBlocks(md) { if (!md) return []; const blocks = []; let m; const re = new RegExp(CODE_BLOCK_RE.source, "g"); while ((m = re.exec(md)) !== null) { const lang = (m[1] || "text").toLowerCase(); const fn = m[2] || null; const code = (m[3] || "").trim(); if (code) blocks.push({ language: lang, filename: fn, code }); } return blocks; }
// GitLab
export const gitlabGetSettings = (t) => request("GET", "/gitlab/settings", t);
export const gitlabUpdateSettings = (t, d) => request("PUT", "/gitlab/settings", t, d);
export const gitlabTestConnection = (t) => request("POST", "/gitlab/test-connection", t);
export const gitlabSearchProjects = (t, s, o) => request("GET", `/gitlab/projects?search=${encodeURIComponent(s || "")}&owned=${o || false}`, t);
export const gitlabCreateProject = (t, d) => request("POST", "/gitlab/projects", t, d);
export const gitlabListRepos = (t) => request("GET", "/gitlab/repos", t);
export const gitlabLinkRepo = (t, pid) => request("POST", "/gitlab/repos", t, { gitlab_project_id: pid });
export const gitlabUnlinkRepo = (t, id) => request("DELETE", `/gitlab/repos/${id}`, t);
export const gitlabGetTree = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/tree?path=${encodeURIComponent(p || "")}&ref=${encodeURIComponent(r || "")}`, t);
export const gitlabGetFile = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/file?path=${encodeURIComponent(p)}&ref=${encodeURIComponent(r || "")}`, t);
export const gitlabGetBranches = (t, id) => request("GET", `/gitlab/repos/${id}/branches`, t);
export const gitlabCreateBranch = (t, id, d) => request("POST", `/gitlab/repos/${id}/branches`, t, d);
export const gitlabCommit = (t, id, d) => request("POST", `/gitlab/repos/${id}/commit`, t, d);
export const gitlabCommitSingle = (t, id, d) => request("POST", `/gitlab/repos/${id}/commit-single`, t, d);
export const gitlabCreateMR = (t, id, d) => request("POST", `/gitlab/repos/${id}/merge-request`, t, d);
export const gitlabReanalyzeRepo = (t, id) => request("POST", `/gitlab/repos/${id}/analyze`, t);
export const gitlabGetRepoMap = (t, id) => request("GET", `/gitlab/repos/${id}/map`, t);
export const gitlabListActions = (t, s) => request("GET", `/gitlab/actions?status=${s || "pending"}`, t);
export const gitlabCreateAction = (t, d) => request("POST", "/gitlab/actions", t, d);
export const gitlabApproveAction = (t, id) => request("POST", `/gitlab/actions/${id}/approve`, t);
export const gitlabRejectAction = (t, id) => request("POST", `/gitlab/actions/${id}/reject`, t);
\ No newline at end of file
const CODE_BLOCK_RE = /
\ No newline at end of file
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useApp } from "../store";
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useApp, usePermissions } from "../store";
import { getMessages, downloadZip, listKnowledgeBases, updateChat, uploadAttachments, gitlabListRepos, gitlabCommitSingle, refreshRepoContext, exportPptx, exportDocx } from "../api";
import * as streamManager from "../streamManager";
import MessageBubble from "./MessageBubble";
import { Send, Square, Settings2, X, Brain, BookOpen, Paperclip, FileText, Loader2, Upload, Film, Image as ImageIcon, FileCode, GitBranch, RefreshCw, Globe, Presentation, FileOutput, Wand2, ChevronDown, Paintbrush } from "lucide-react";
const MODELS = [
const ALL_MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5" },
];
......@@ -13,38 +13,33 @@ const TYPE_ICONS = { image: ImageIcon, video: Film, document: FileText, text: Fi
const TYPE_COLORS = { image: "border-blue-500/40 bg-blue-500/10", video: "border-purple-500/40 bg-purple-500/10", document: "border-amber-500/40 bg-amber-500/10", text: "border-green-500/40 bg-green-500/10" };
const TYPE_ICON_COLORS = { image: "text-blue-400", video: "text-purple-400", document: "text-amber-400", text: "text-green-400" };
const UI_DESIGN_PREFIX = `[UI DESIGN MODE] You are now a world-class UI/UX designer. Generate COMPLETE, SELF-CONTAINED HTML files that render beautifully in a browser preview.
CRITICAL RULES:
- Output a SINGLE HTML file using the format \`\`\`html:design.html with ALL CSS and JS embedded inline
- Include <meta name="viewport" content="width=device-width, initial-scale=1.0">
- Use modern CSS (flexbox, grid, custom properties, transitions, animations)
- Make it fully responsive (mobile-first)
- Use realistic, professional content (NOT "Lorem ipsum")
- Include hover states, focus states, and micro-interactions
- Use a cohesive color palette and typography (Google Fonts via CDN OK)
- If the design uses Tailwind-like utilities, include the Tailwind CDN script
- For multi-screen apps, generate separate HTML files per screen
- The output MUST look production-quality and polished
- Include subtle shadows, gradients, and modern design patterns
`;
const UI_DESIGN_PREFIX = `[UI DESIGN MODE] You are now a world-class UI/UX designer. Generate COMPLETE, SELF-CONTAINED HTML files that render beautifully in a browser preview.\n\nCRITICAL RULES:\n- Output a SINGLE HTML file using the format \`\`\`html:design.html with ALL CSS and JS embedded inline\n- Include <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n- Use modern CSS (flexbox, grid, custom properties, transitions, animations)\n- Make it fully responsive (mobile-first)\n- Use realistic, professional content (NOT \"Lorem ipsum\")\n- Include hover states, focus states, and micro-interactions\n- Use a cohesive color palette and typography (Google Fonts via CDN OK)\n- If the design uses Tailwind-like utilities, include the Tailwind CDN script\n- For multi-screen apps, generate separate HTML files per screen\n- The output MUST look production-quality and polished\n- Include subtle shadows, gradients, and modern design patterns\n\n`;
function classifyFile(f) { const ext = (f.name || "").split(".").pop().toLowerCase(); const mime = f.type || ""; if (mime.startsWith("image/") || ["jpg", "jpeg", "png", "gif", "webp", "bmp"].includes(ext)) return "image"; if (mime.startsWith("video/") || ["mp4", "mov", "avi", "mkv", "webm"].includes(ext)) return "video"; if (mime === "application/pdf" || ext === "pdf") return "document"; return "text"; }
function fmtSize(b) { if (!b) return "0B"; if (b < 1024) return b + "B"; if (b < 1048576) return (b / 1024).toFixed(0) + "KB"; return (b / 1048576).toFixed(1) + "MB"; }
export default function ChatView({ chatId }) {
const { state, dispatch } = useApp();
const perms = usePermissions();
const currentChat = state.chats.find(c => c.id === chatId);
const messages = state.chatMessages[chatId] || [];
const isSuperadmin = state.user?.role === "superadmin";
// Filter models based on permissions
const MODELS = useMemo(() => {
const allowed = perms.allowed_models;
if (!allowed || allowed === "all") return ALL_MODELS;
const list = allowed.split(",").map(s => s.trim());
const filtered = ALL_MODELS.filter(m => list.includes(m.id));
return filtered.length > 0 ? filtered : ALL_MODELS;
}, [perms.allowed_models]);
const [input, setInput] = useState("");
const [showSettings, setShowSettings] = useState(false);
const [showTools, setShowTools] = useState(false);
const [model, setModel] = useState(currentChat?.model || MODELS[0].id);
const [maxTokens, setMaxTokens] = useState(currentChat?.max_tokens || 4096);
const [reasoningBudget, setReasoningBudget] = useState(currentChat?.reasoning_budget ?? 0);
const [model, setModel] = useState(currentChat?.model || MODELS[0]?.id);
const [maxTokens, setMaxTokens] = useState(Math.min(currentChat?.max_tokens || 4096, perms.max_tokens_cap || 4096));
const [reasoningBudget, setReasoningBudget] = useState(Math.min(currentChat?.reasoning_budget ?? 0, perms.max_reasoning_budget || 0));
const [selectedKbId, setSelectedKbId] = useState(currentChat?.knowledge_base_id || null);
const [selectedRepoId, setSelectedRepoId] = useState(currentChat?.linked_repo_id || null);
const [webSearch, setWebSearch] = useState(false);
......@@ -59,13 +54,16 @@ export default function ChatView({ chatId }) {
const scrollRef = useRef(null); const inputRef = useRef(null); const fileRef = useRef(null); const autoScroll = useRef(true); const rafRef = useRef(null);
const maxTokensCap = perms.max_tokens_cap || 4096;
const maxReasoningCap = perms.max_reasoning_budget || 0;
useEffect(() => { setStreamData(streamManager.getStreamData(chatId)); return streamManager.subscribe(chatId, () => setStreamData(streamManager.getStreamData(chatId))); }, [chatId]);
const scrollBottom = useCallback(() => { if (!autoScroll.current || rafRef.current) return; rafRef.current = requestAnimationFrame(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); rafRef.current = null; }); }, []);
useEffect(() => { (async () => { try { const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), listKnowledgeBases(state.token)]); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); setKbs(kbData); if (isSuperadmin) { try { setRepos(await gitlabListRepos(state.token)); } catch { } } } catch { } })(); }, [chatId, state.token, dispatch]);
useEffect(() => { (async () => { try { const [msgs, kbData] = await Promise.all([getMessages(state.token, chatId), perms.can_use_knowledge_base ? listKnowledgeBases(state.token) : []]); dispatch({ type: "SET_MESSAGES", chatId, messages: msgs }); setKbs(kbData); if (isSuperadmin || perms.can_use_gitlab) { try { setRepos(await gitlabListRepos(state.token)); } catch { } } } catch { } })(); }, [chatId, state.token, dispatch]);
useEffect(scrollBottom, [messages, streamData.text, streamData.thinking, scrollBottom]);
useEffect(() => { inputRef.current?.focus(); }, [chatId]);
useEffect(() => { if (currentChat) { setModel(currentChat.model || MODELS[0].id); setMaxTokens(currentChat.max_tokens || 4096); setReasoningBudget(currentChat.reasoning_budget ?? 0); setSelectedKbId(currentChat.knowledge_base_id || null); setSelectedRepoId(currentChat.linked_repo_id || null); } }, [chatId]);
useEffect(() => { if (currentChat) { const m = currentChat.model || MODELS[0]?.id; setModel(MODELS.find(x => x.id === m) ? m : MODELS[0]?.id); setMaxTokens(Math.min(currentChat.max_tokens || 4096, maxTokensCap)); setReasoningBudget(Math.min(currentChat.reasoning_budget ?? 0, maxReasoningCap)); setSelectedKbId(currentChat.knowledge_base_id || null); setSelectedRepoId(currentChat.linked_repo_id || null); } }, [chatId]);
const onScroll = useCallback(() => { const el = scrollRef.current; if (el) autoScroll.current = el.scrollHeight - el.scrollTop - el.clientHeight < 200; }, []);
const saveSettings = useCallback(async () => { try { await updateChat(state.token, chatId, { model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId || "", linked_repo_id: selectedRepoId || "" }); const repoObj = selectedRepoId ? repos.find(r => r.id === selectedRepoId) || null : null; dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, model, max_tokens: maxTokens, reasoning_budget: reasoningBudget, knowledge_base_id: selectedKbId, linked_repo_id: selectedRepoId, linked_repo: repoObj } }); } catch { } }, [state.token, chatId, model, maxTokens, reasoningBudget, selectedKbId, selectedRepoId, repos, dispatch]);
......@@ -77,12 +75,10 @@ export default function ChatView({ chatId }) {
const handleSend = useCallback(async () => {
const raw = input.trim(); if ((!raw && !pendingFiles.length) || streamData.streaming) return;
const text = raw || "Please analyze the attached file(s).";
// Prepend UI design instructions if mode is active
const content = uiDesign ? UI_DESIGN_PREFIX + text : text;
let attIds = [], uploaded = [];
if (pendingFiles.length) { setUploading(true); try { const res = await uploadAttachments(state.token, chatId, pendingFiles.map(p => p.file)); uploaded = (res.attachments || []).filter(a => !a.error); attIds = uploaded.map(a => a.id); } catch { setUploading(false); return; } setUploading(false); }
if (pendingFiles.length) { setUploading(true); try { const res = await uploadAttachments(state.token, chatId, pendingFiles.map(p => p.file)); uploaded = (res.attachments || []).filter(a => !a.error); attIds = uploaded.map(a => a.id); } catch (e) { setUploading(false); alert(e.message); return; } setUploading(false); }
dispatch({ type: "ADD_MESSAGE", chatId, message: { id: `tmp-${Date.now()}`, role: "user", content: text, created_at: new Date().toISOString(), attachments: uploaded } });
setInput(""); pendingFiles.forEach(p => { if (p.preview) URL.revokeObjectURL(p.preview); }); setPendingFiles([]); autoScroll.current = true;
if (inputRef.current) inputRef.current.style.height = "auto";
......@@ -115,9 +111,18 @@ export default function ChatView({ chatId }) {
try { await gitlabCommitSingle(state.token, linkedRepo.id, { branch: linkedRepo.default_branch, file_path: filePath, content: code, commit_message: msg, action }); try { await refreshRepoContext(state.token, chatId); } catch { } } catch (e) { alert(`❌ ${e.message}`); throw e; }
}, [linkedRepo, state.token, chatId]);
const canAttach = perms.can_use_attachments;
const canWebSearch = perms.can_use_web_search;
const canUiDesign = perms.can_use_ui_design;
const canExportPptx = perms.can_export_pptx;
const canExportDocx = perms.can_export_docx;
const canKB = perms.can_use_knowledge_base;
const canGitlab = perms.can_use_gitlab || isSuperadmin;
const hasAnyTool = canWebSearch || canUiDesign || canExportPptx || canExportDocx;
return (
<div className="flex-1 flex flex-col min-h-0 relative" onDrop={handleDrop} onDragOver={e => { e.preventDefault(); setDragOver(true); }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}>
{dragOver && <div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"><div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div></div>}
{dragOver && canAttach && <div className="absolute inset-0 z-40 bg-anton-accent/10 backdrop-blur-sm border-2 border-dashed border-anton-accent rounded-lg flex items-center justify-center pointer-events-none"><div className="text-center"><Upload size={36} className="text-anton-accent mx-auto mb-2 animate-bounce" /><p className="text-white font-semibold text-sm">Drop files here</p></div></div>}
{linkedRepo && (
<div className="px-3 py-1.5 bg-orange-500/10 border-b border-orange-500/20 flex items-center gap-2 text-xs flex-wrap">
......@@ -125,12 +130,9 @@ export default function ChatView({ chatId }) {
</div>
)}
{/* UI Design Mode Banner */}
{uiDesign && (
<div className="px-3 py-1.5 bg-blue-500/10 border-b border-blue-500/20 flex items-center gap-2 text-xs">
<Paintbrush size={12} className="text-blue-400" />
<span className="text-blue-300 font-medium">UI Design Mode</span>
<span className="text-blue-300/60">— Generating previewable HTML designs</span>
<Paintbrush size={12} className="text-blue-400" /><span className="text-blue-300 font-medium">UI Design Mode</span><span className="text-blue-300/60">— Generating previewable HTML designs</span>
<button onClick={() => setUiDesign(false)} className="ml-auto text-blue-400/60 hover:text-blue-300 transition"><X size={12} /></button>
</div>
)}
......@@ -151,10 +153,16 @@ export default function ChatView({ chatId }) {
<div className="mb-2 bg-anton-card border border-anton-border rounded-xl p-3 space-y-3 animate-fade-in max-h-[50vh] overflow-y-auto">
<div className="flex items-center justify-between"><h3 className="text-sm font-semibold text-white flex items-center gap-1.5"><Settings2 size={14} className="text-anton-accent" /> Settings</h3><button onClick={toggleSettings} className="p-1 text-anton-muted hover:text-white"><X size={14} /></button></div>
<div><label className="text-xs text-anton-muted mb-1 block">Model</label><select value={model} onChange={e => setModel(e.target.value)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent">{MODELS.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}</select></div>
<div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}</span></div><input type="range" min={256} max={65536} step={256} value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} /></div>
<div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}</span></div><input type="range" min={0} max={32000} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} /></div>
<div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label><select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"><option value="">None</option>{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}</select></div>
{isSuperadmin && repos.length > 0 && (<div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label><select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400"><option value="">None</option>{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name}</option>)}</select></div>)}
<div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted">Max Tokens</span><span className="text-anton-accent font-mono">{maxTokens.toLocaleString()}{maxTokensCap < 65536 && <span className="text-anton-muted"> / {maxTokensCap.toLocaleString()}</span>}</span></div><input type="range" min={256} max={maxTokensCap} step={256} value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} /></div>
{maxReasoningCap > 0 && (
<div><div className="flex justify-between text-xs mb-1.5"><span className="text-anton-muted flex items-center gap-1"><Brain size={12} className="text-purple-400" /> Reasoning</span><span className="text-purple-400 font-mono">{reasoningBudget === 0 ? "Off" : reasoningBudget.toLocaleString()}{maxReasoningCap < 32000 && <span className="text-anton-muted"> / {maxReasoningCap.toLocaleString()}</span>}</span></div><input type="range" min={0} max={maxReasoningCap} step={500} value={reasoningBudget} onChange={e => setReasoningBudget(Number(e.target.value))} /></div>
)}
{canKB && (
<div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><BookOpen size={12} /> Knowledge Base</label><select value={selectedKbId || ""} onChange={e => setSelectedKbId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-anton-accent"><option value="">None</option>{kbs.map(kb => <option key={kb.id} value={kb.id}>{kb.name} ({kb.document_count} docs)</option>)}</select></div>
)}
{canGitlab && repos.length > 0 && (
<div><label className="text-xs text-anton-muted mb-1 flex items-center gap-1"><GitBranch size={12} className="text-orange-400" /> Repository</label><select value={selectedRepoId || ""} onChange={e => setSelectedRepoId(e.target.value || null)} className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-orange-400"><option value="">None</option>{repos.map(r => <option key={r.id} value={r.id}>🔀 {r.name}</option>)}</select></div>
)}
</div>
)}
......@@ -175,40 +183,49 @@ export default function ChatView({ chatId }) {
<div className="flex items-end gap-1.5">
<button onClick={toggleSettings} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showSettings ? "bg-anton-accent/20 text-anton-accent" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}><Settings2 size={18} /></button>
{/* Tools menu button */}
<div className="relative">
<button onClick={() => { setShowTools(!showTools); setShowSettings(false); }} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showTools ? "bg-blue-500/20 text-blue-400" : (webSearch || uiDesign) ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Tools"><Wand2 size={18} /></button>
{showTools && (
<div className="absolute bottom-full left-0 mb-2 w-56 bg-anton-card border border-anton-border rounded-xl shadow-2xl p-2 space-y-1 animate-fade-in z-30">
<p className="text-[10px] text-anton-muted px-2 py-1 uppercase tracking-wider font-semibold">Tools</p>
{/* UI Design Mode Toggle */}
<button onClick={() => { setUiDesign(!uiDesign); }} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${uiDesign ? "bg-blue-500/15 text-blue-400" : "text-white hover:bg-anton-bg"}`}>
<span className="flex items-center gap-2"><Paintbrush size={15} /> UI Design</span>
<div className={`w-8 h-4.5 rounded-full transition-colors ${uiDesign ? "bg-blue-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${uiDesign ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
</button>
{/* Web Search Toggle */}
<button onClick={() => { setWebSearch(!webSearch); }} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${webSearch ? "bg-green-500/15 text-green-400" : "text-white hover:bg-anton-bg"}`}>
<span className="flex items-center gap-2"><Globe size={15} /> Web Search</span>
<div className={`w-8 h-4.5 rounded-full transition-colors ${webSearch ? "bg-green-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${webSearch ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
</button>
<hr className="border-anton-border" />
<button onClick={() => handleExport("pptx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
{exporting === "pptx" ? <Loader2 size={15} className="animate-spin" /> : <Presentation size={15} className="text-orange-400" />} Download PPTX
</button>
<button onClick={() => handleExport("docx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
{exporting === "docx" ? <Loader2 size={15} className="animate-spin" /> : <FileOutput size={15} className="text-blue-400" />} Download DOCX
</button>
</div>
)}
</div>
{hasAnyTool && (
<div className="relative">
<button onClick={() => { setShowTools(!showTools); setShowSettings(false); }} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${showTools ? "bg-blue-500/20 text-blue-400" : (webSearch || uiDesign) ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Tools"><Wand2 size={18} /></button>
{showTools && (
<div className="absolute bottom-full left-0 mb-2 w-56 bg-anton-card border border-anton-border rounded-xl shadow-2xl p-2 space-y-1 animate-fade-in z-30">
<p className="text-[10px] text-anton-muted px-2 py-1 uppercase tracking-wider font-semibold">Tools</p>
{canUiDesign && (
<button onClick={() => setUiDesign(!uiDesign)} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${uiDesign ? "bg-blue-500/15 text-blue-400" : "text-white hover:bg-anton-bg"}`}>
<span className="flex items-center gap-2"><Paintbrush size={15} /> UI Design</span>
<div className={`w-8 h-4.5 rounded-full transition-colors ${uiDesign ? "bg-blue-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${uiDesign ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
</button>
)}
{canWebSearch && (
<button onClick={() => setWebSearch(!webSearch)} className={`w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg text-sm transition ${webSearch ? "bg-green-500/15 text-green-400" : "text-white hover:bg-anton-bg"}`}>
<span className="flex items-center gap-2"><Globe size={15} /> Web Search</span>
<div className={`w-8 h-4.5 rounded-full transition-colors ${webSearch ? "bg-green-500" : "bg-anton-border"}`}><div className={`w-3.5 h-3.5 rounded-full bg-white shadow transform transition-transform mt-0.5 ${webSearch ? "translate-x-4 ml-0.5" : "translate-x-0.5"}`} /></div>
</button>
)}
{(canExportPptx || canExportDocx) && <hr className="border-anton-border" />}
{canExportPptx && (
<button onClick={() => handleExport("pptx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
{exporting === "pptx" ? <Loader2 size={15} className="animate-spin" /> : <Presentation size={15} className="text-orange-400" />} Download PPTX
</button>
)}
{canExportDocx && (
<button onClick={() => handleExport("docx")} disabled={!lastAssistantContent || !!exporting} className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-white hover:bg-anton-bg transition disabled:opacity-30 disabled:cursor-not-allowed">
{exporting === "docx" ? <Loader2 size={15} className="animate-spin" /> : <FileOutput size={15} className="text-blue-400" />} Download DOCX
</button>
)}
</div>
)}
</div>
)}
{canAttach && (
<>
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
</>
)}
<button onClick={() => fileRef.current?.click()} className={`p-2.5 rounded-xl transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center ${pendingFiles.length ? "bg-green-500/20 text-green-400" : "text-anton-muted hover:text-white hover:bg-anton-card"}`} title="Attach files"><Paperclip size={18} /></button>
<input ref={fileRef} type="file" multiple className="hidden" accept="image/*,video/*,.pdf,.txt,.md,.py,.js,.ts,.jsx,.tsx,.cs,.java,.cpp,.c,.h,.go,.rs,.rb,.php,.html,.css,.json,.yaml,.yml,.xml,.toml,.csv,.sql,.sh,.swift,.kt,.lua,.gd,.dart,.vue,.svelte,.log" onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ""; }} />
<div className="flex-1 min-w-0">
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={uiDesign ? "Describe a UI design… (e.g. a modern dashboard with dark theme)" : webSearch ? "Search the web & ask…" : pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"} rows={1} style={{ maxHeight: "120px" }} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug" onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; }} />
<textarea ref={inputRef} value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={uiDesign ? "Describe a UI design…" : webSearch ? "Search the web & ask…" : pendingFiles.length ? "Add a message…" : linkedRepo ? `Ask about ${linkedRepo.name}…` : "Ask anything…"} rows={1} style={{ maxHeight: "120px" }} className="w-full bg-anton-card border border-anton-border rounded-xl px-3 py-2.5 text-white resize-none focus:outline-none focus:border-anton-accent transition leading-snug" onInput={e => { e.target.style.height = "auto"; e.target.style.height = Math.min(e.target.scrollHeight, 120) + "px"; }} />
</div>
{streaming ? (
<button onClick={() => streamManager.abortStream(chatId)} className="p-2.5 rounded-xl bg-anton-danger text-white hover:opacity-80 transition shrink-0 min-w-[40px] min-h-[40px] flex items-center justify-center"><Square size={18} /></button>
......
import React, { useState, useEffect } from "react";
import { useApp } from "../store";
import { useApp, usePermissions } from "../store";
import { useNavigate } from "react-router-dom";
import { listChats, createChat, deleteChat, renameChat } from "../api";
import {
......@@ -9,6 +9,7 @@ import {
export default function Sidebar({ mobile, onClose }) {
const { state, dispatch } = useApp();
const perms = usePermissions();
const nav = useNavigate();
const activeChatId = state.activeChatId;
const [editId, setEditId] = useState(null);
......@@ -32,24 +33,18 @@ export default function Sidebar({ mobile, onClose }) {
try {
const chat = await createChat(state.token);
dispatch({ type: "ADD_CHAT", chat });
} catch { }
} catch (e) { alert(e.message); }
}
async function handleDelete(e, chatId) {
e.stopPropagation();
if (!confirm("Delete this chat?")) return;
try {
await deleteChat(state.token, chatId);
dispatch({ type: "REMOVE_CHAT", chatId });
} catch { }
try { await deleteChat(state.token, chatId); dispatch({ type: "REMOVE_CHAT", chatId }); } catch { }
}
async function handleRename(chatId) {
if (!editTitle.trim()) { setEditId(null); return; }
try {
await renameChat(state.token, chatId, editTitle.trim());
dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } });
} catch { }
try { await renameChat(state.token, chatId, editTitle.trim()); dispatch({ type: "UPDATE_CHAT", chat: { id: chatId, title: editTitle.trim() } }); } catch { }
setEditId(null);
}
......@@ -57,7 +52,6 @@ export default function Sidebar({ mobile, onClose }) {
return (
<div className={`${mobile ? "h-full" : "h-dvh"} w-72 bg-anton-surface border-r border-anton-border flex flex-col`}>
{/* Header */}
<div className="p-3 border-b border-anton-border">
<div className="flex items-center gap-2 mb-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center">
......@@ -65,7 +59,7 @@ export default function Sidebar({ mobile, onClose }) {
</div>
<div>
<h1 className="text-sm font-bold text-white">Son of Anton</h1>
<p className="text-[10px] text-anton-muted">v4.1.0 — The Architect</p>
<p className="text-[10px] text-anton-muted">v4.2.0 — The Architect</p>
</div>
</div>
<button onClick={handleNew} className="w-full flex items-center justify-center gap-1.5 bg-anton-accent text-white rounded-lg py-2 text-sm hover:opacity-80 transition">
......@@ -73,7 +67,6 @@ export default function Sidebar({ mobile, onClose }) {
</button>
</div>
{/* Chat list */}
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
{state.chats.map((c) => (
<div key={c.id} onClick={() => handleSelectChat(c.id)}
......@@ -82,8 +75,7 @@ export default function Sidebar({ mobile, onClose }) {
{editId === c.id ? (
<div className="flex-1 flex items-center gap-1">
<input value={editTitle} onChange={(e) => setEditTitle(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleRename(c.id)}
className="flex-1 bg-anton-bg border border-anton-border rounded px-1.5 py-0.5 text-xs text-white" autoFocus
onClick={(e) => e.stopPropagation()} />
className="flex-1 bg-anton-bg border border-anton-border rounded px-1.5 py-0.5 text-xs text-white" autoFocus onClick={(e) => e.stopPropagation()} />
<button onClick={(e) => { e.stopPropagation(); handleRename(c.id); }} className="text-green-400"><Check size={12} /></button>
<button onClick={(e) => { e.stopPropagation(); setEditId(null); }} className="text-red-400"><X size={12} /></button>
</div>
......@@ -101,21 +93,23 @@ export default function Sidebar({ mobile, onClose }) {
))}
</div>
{/* Footer */}
<div className="p-2 border-t border-anton-border space-y-0.5">
{/* GitLab — only if superadmin OR has gitlab permission */}
{(isSuperadmin || perms.can_use_gitlab) && (
<button onClick={() => nav("/gitlab")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-anton-card transition">
<GitBranch size={14} /> GitLab Center
</button>
)}
{isSuperadmin && (
<>
<button onClick={() => nav("/gitlab")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-orange-400 hover:bg-anton-card transition">
<GitBranch size={14} /> GitLab Center
</button>
<button onClick={() => nav("/admin")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<Shield size={14} /> Admin
</button>
</>
<button onClick={() => nav("/admin")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<Shield size={14} /> Admin
</button>
)}
{perms.can_use_knowledge_base && (
<button onClick={() => nav("/knowledge")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<BookOpen size={14} /> Knowledge
</button>
)}
<button onClick={() => nav("/knowledge")} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-white transition">
<BookOpen size={14} /> Knowledge
</button>
<button onClick={() => dispatch({ type: "LOGOUT" })} className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-anton-muted hover:bg-anton-card hover:text-red-400 transition">
<LogOut size={14} /> Logout
</button>
......
......@@ -2,17 +2,52 @@ import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import {
adminStats,
adminListUsers,
adminCreateUser,
adminUpdateUser,
adminDeleteUser,
adminStats, adminListUsers, adminCreateUser, adminUpdateUser, adminDeleteUser,
adminGetUserPermissions, adminUpdateUserPermissions,
adminGetDefaultPermissions, adminUpdateDefaultPermissions, adminApplyDefaults,
} from "../api";
import {
ArrowLeft, Users, MessageSquare, Database, Zap,
UserPlus, Trash2, Shield, ShieldOff, Save, X,
Settings2, Check, Globe, Paintbrush, BookOpen, GitBranch,
Paperclip, Presentation, FileOutput, Cpu, Gauge, Lock,
Loader2, RotateCcw, Copy,
} from "lucide-react";
const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6", tier: "💰 Expensive" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5", tier: "⚡ Cheap" },
];
const FEATURE_DEFS = [
{ key: "can_use_web_search", label: "Web Search", icon: Globe, color: "text-green-400", desc: "SerpAPI calls — costs money per search" },
{ key: "can_use_ui_design", label: "UI Design Mode", icon: Paintbrush, color: "text-blue-400", desc: "Generates full HTML — uses more tokens" },
{ key: "can_use_knowledge_base", label: "Knowledge Bases", icon: BookOpen, color: "text-emerald-400", desc: "RAG document upload & query" },
{ key: "can_use_gitlab", label: "GitLab Access", icon: GitBranch, color: "text-orange-400", desc: "Repository linking & commits" },
{ key: "can_use_attachments", label: "File Attachments", icon: Paperclip, color: "text-cyan-400", desc: "Upload images, videos, documents" },
{ key: "can_export_pptx", label: "Export PPTX", icon: Presentation, color: "text-amber-400", desc: "Download as PowerPoint" },
{ key: "can_export_docx", label: "Export DOCX", icon: FileOutput, color: "text-indigo-400", desc: "Download as Word document" },
];
const LIMIT_DEFS = [
{ key: "max_tokens_cap", label: "Max Output Tokens", min: 256, max: 65536, step: 256, desc: "Maximum output tokens user can set" },
{ key: "max_reasoning_budget", label: "Max Reasoning Budget", min: 0, max: 32000, step: 500, desc: "0 = reasoning disabled" },
{ key: "max_chats", label: "Max Chats", min: 0, max: 1000, step: 1, desc: "0 = unlimited" },
{ key: "max_messages_per_day", label: "Messages / Day", min: 0, max: 10000, step: 10, desc: "0 = unlimited" },
{ key: "max_knowledge_bases", label: "Max Knowledge Bases", min: 0, max: 100, step: 1, desc: "0 = unlimited" },
{ key: "max_documents_per_kb", label: "Max Docs per KB", min: 0, max: 500, step: 5, desc: "0 = unlimited" },
{ key: "max_attachment_size_mb", label: "Attachment Size (MB)", min: 1, max: 100, step: 1, desc: "Per-file upload limit" },
{ key: "max_attachments_per_message", label: "Attachments / Message", min: 1, max: 50, step: 1, desc: "Files per single message" },
];
function Toggle({ value, onChange }) {
return (
<button onClick={() => onChange(!value)} className={`w-10 h-5 rounded-full transition-colors relative ${value ? "bg-green-500" : "bg-anton-border"}`}>
<div className={`w-4 h-4 rounded-full bg-white shadow absolute top-0.5 transition-transform ${value ? "translate-x-5.5 left-0.5" : "left-0.5"}`} style={{ transform: value ? "translateX(20px)" : "translateX(0)" }} />
</button>
);
}
export default function AdminPage() {
const { state } = useApp();
const navigate = useNavigate();
......@@ -21,50 +56,77 @@ export default function AdminPage() {
const [showCreate, setShowCreate] = useState(false);
const [editId, setEditId] = useState(null);
const [editData, setEditData] = useState({});
const [newUser, setNewUser] = useState({
username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000,
});
const [newUser, setNewUser] = useState({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
const [error, setError] = useState("");
// Permissions modal
const [permsUser, setPermsUser] = useState(null); // user object or { id: "__defaults__", username: "Default Template" }
const [permsData, setPermsData] = useState(null);
const [permsSaving, setPermsSaving] = useState(false);
const [permsLoading, setPermsLoading] = useState(false);
const [applyingDefaults, setApplyingDefaults] = useState(false);
const load = useCallback(async () => {
try {
const [s, u] = await Promise.all([
adminStats(state.token),
adminListUsers(state.token),
]);
setStats(s);
setUsers(u);
} catch (err) {
setError(err.message);
}
const [s, u] = await Promise.all([adminStats(state.token), adminListUsers(state.token)]);
setStats(s); setUsers(u);
} catch (err) { setError(err.message); }
}, [state.token]);
useEffect(() => { load(); }, [load]);
async function handleCreate(e) {
e.preventDefault();
try {
await adminCreateUser(state.token, newUser);
setShowCreate(false);
setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
load();
} catch (err) { setError(err.message); }
try { await adminCreateUser(state.token, newUser); setShowCreate(false); setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 }); load(); } catch (err) { setError(err.message); }
}
async function handleSaveEdit(userId) {
try {
await adminUpdateUser(state.token, userId, editData);
setEditId(null);
load();
} catch (err) { setError(err.message); }
try { await adminUpdateUser(state.token, userId, editData); setEditId(null); load(); } catch (err) { setError(err.message); }
}
async function handleDelete(userId, username) {
if (!confirm(`Delete user "${username}"? This is permanent.`)) return;
try { await adminDeleteUser(state.token, userId); load(); } catch (err) { setError(err.message); }
}
// ═══ Permissions ═══
async function openPerms(user) {
setPermsUser(user); setPermsLoading(true); setPermsData(null);
try {
await adminDeleteUser(state.token, userId);
load();
const data = user.id === "__defaults__"
? await adminGetDefaultPermissions(state.token)
: await adminGetUserPermissions(state.token, user.id);
setPermsData(data);
} catch (err) { setError(err.message); setPermsUser(null); }
setPermsLoading(false);
}
async function savePerms() {
if (!permsUser || !permsData) return;
setPermsSaving(true);
try {
if (permsUser.id === "__defaults__") {
await adminUpdateDefaultPermissions(state.token, permsData);
} else {
await adminUpdateUserPermissions(state.token, permsUser.id, permsData);
}
setPermsUser(null); setPermsData(null);
} catch (err) { setError(err.message); }
setPermsSaving(false);
}
async function handleApplyDefaults() {
if (!confirm("Apply default permissions to ALL regular users? This will overwrite their current permissions.")) return;
setApplyingDefaults(true);
try {
const res = await adminApplyDefaults(state.token);
alert(`✅ Applied to ${res.users_updated} users`);
} catch (err) { setError(err.message); }
setApplyingDefaults(false);
}
function updatePerm(key, value) {
setPermsData(prev => ({ ...prev, [key]: value }));
}
function formatNum(n) {
......@@ -74,35 +136,22 @@ export default function AdminPage() {
}
if (state.user?.role !== "superadmin") {
return (
<div className="h-full flex items-center justify-center">
<p className="text-anton-danger text-lg">⛔ Access Denied</p>
</div>
);
return <div className="h-full flex items-center justify-center"><p className="text-anton-danger text-lg">⛔ Access Denied</p></div>;
}
return (
<div className="h-full overflow-y-auto bg-anton-bg p-6">
<div className="h-full overflow-y-auto bg-anton-bg p-4 sm:p-6">
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={() => navigate("/")} className="p-2 rounded-lg bg-anton-surface border border-anton-border hover:border-anton-accent transition">
<ArrowLeft size={20} />
</button>
<button onClick={() => navigate("/")} className="p-2 rounded-lg bg-anton-surface border border-anton-border hover:border-anton-accent transition"><ArrowLeft size={20} /></button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Shield size={24} className="text-anton-accent" /> Admin Panel
</h1>
<p className="text-anton-muted text-sm">Manage everything</p>
<h1 className="text-2xl font-bold text-white flex items-center gap-2"><Shield size={24} className="text-anton-accent" /> Admin Panel</h1>
<p className="text-anton-muted text-sm">Users, permissions & cost control</p>
</div>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3">
{error}
<button onClick={() => setError("")} className="ml-2 text-red-300 hover:text-white"></button>
</div>
)}
{error && (<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm rounded-lg p-3">{error}<button onClick={() => setError("")} className="ml-2 text-red-300 hover:text-white"></button></div>)}
{/* Stats */}
{stats && (
......@@ -114,10 +163,7 @@ export default function AdminPage() {
{ label: "Tokens Used", value: formatNum(stats.total_tokens_used), icon: Database, color: "text-purple-400" },
].map((s) => (
<div key={s.label} className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1">
<s.icon size={16} className={s.color} />
<span className="text-anton-muted text-sm">{s.label}</span>
</div>
<div className="flex items-center gap-2 mb-1"><s.icon size={16} className={s.color} /><span className="text-anton-muted text-sm">{s.label}</span></div>
<p className="text-2xl font-bold text-white">{s.value}</p>
</div>
))}
......@@ -126,125 +172,72 @@ export default function AdminPage() {
{/* User Management */}
<div className="bg-anton-surface border border-anton-border rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-anton-border flex items-center justify-between">
<div className="px-5 py-4 border-b border-anton-border flex items-center justify-between flex-wrap gap-2">
<h2 className="text-lg font-semibold text-white">Users</h2>
<button
onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-anton-accent text-white rounded-lg text-sm font-medium hover:opacity-90 transition"
>
{showCreate ? <X size={14} /> : <UserPlus size={14} />}
{showCreate ? "Cancel" : "New User"}
</button>
<div className="flex items-center gap-2">
<button onClick={() => openPerms({ id: "__defaults__", username: "Default Template" })} className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-500/20 text-purple-400 border border-purple-500/30 rounded-lg text-sm font-medium hover:bg-purple-500/30 transition" title="Edit default permissions for new users">
<Lock size={14} /> Defaults
</button>
<button onClick={handleApplyDefaults} disabled={applyingDefaults} className="flex items-center gap-1.5 px-3 py-1.5 bg-anton-card border border-anton-border text-anton-muted rounded-lg text-sm hover:text-white transition disabled:opacity-50">
{applyingDefaults ? <Loader2 size={14} className="animate-spin" /> : <Copy size={14} />} Apply to All
</button>
<button onClick={() => setShowCreate(!showCreate)} className="flex items-center gap-1.5 px-3 py-1.5 bg-anton-accent text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
{showCreate ? <X size={14} /> : <UserPlus size={14} />} {showCreate ? "Cancel" : "New User"}
</button>
</div>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="px-5 py-4 border-b border-anton-border bg-anton-card grid grid-cols-1 md:grid-cols-3 gap-3">
<input placeholder="Username" required value={newUser.username}
onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
<input placeholder="Email" type="email" required value={newUser.email}
onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
<input placeholder="Password" required value={newUser.password}
onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
<select value={newUser.role}
onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="superadmin">Superadmin</option>
<input placeholder="Username" required value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Email" type="email" required value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Password" required value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="user">User</option><option value="admin">Admin</option><option value="superadmin">Superadmin</option>
</select>
<input placeholder="Monthly quota" type="number" value={newUser.quota_tokens_monthly}
onChange={(e) => setNewUser({ ...newUser, quota_tokens_monthly: Number(e.target.value) })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent"
/>
<button type="submit" className="bg-anton-success text-white rounded-lg px-3 py-2 text-sm font-medium hover:opacity-90">
Create
</button>
<input placeholder="Monthly quota" type="number" value={newUser.quota_tokens_monthly} onChange={(e) => setNewUser({ ...newUser, quota_tokens_monthly: Number(e.target.value) })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<button type="submit" className="bg-anton-success text-white rounded-lg px-3 py-2 text-sm font-medium hover:opacity-90">Create</button>
</form>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-anton-muted border-b border-anton-border">
<th className="px-5 py-3">User</th>
<th className="px-5 py-3">Role</th>
<th className="px-5 py-3">Quota</th>
<th className="px-5 py-3">Used</th>
<th className="px-5 py-3">Chats</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3">Actions</th>
</tr>
</thead>
<thead><tr className="text-left text-anton-muted border-b border-anton-border">
<th className="px-5 py-3">User</th><th className="px-5 py-3">Role</th><th className="px-5 py-3">Quota</th><th className="px-5 py-3">Used</th><th className="px-5 py-3">Chats</th><th className="px-5 py-3">Status</th><th className="px-5 py-3">Actions</th>
</tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-anton-border/50 hover:bg-anton-card/50 transition">
<td className="px-5 py-3">
<div className="text-white font-medium">{u.username}</div>
<div className="text-anton-muted text-xs">{u.email}</div>
</td>
<td className="px-5 py-3"><div className="text-white font-medium">{u.username}</div><div className="text-anton-muted text-xs">{u.email}</div></td>
<td className="px-5 py-3">
{editId === u.id ? (
<select value={editData.role ?? u.role}
onChange={(e) => setEditData({ ...editData, role: e.target.value })}
className="bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs"
>
<option value="user">user</option>
<option value="admin">admin</option>
<option value="superadmin">superadmin</option>
<select value={editData.role ?? u.role} onChange={(e) => setEditData({ ...editData, role: e.target.value })} className="bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs">
<option value="user">user</option><option value="admin">admin</option><option value="superadmin">superadmin</option>
</select>
) : (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
u.role === "superadmin" ? "bg-anton-accent/20 text-anton-accent"
: u.role === "admin" ? "bg-blue-500/20 text-blue-400"
: "bg-anton-border text-anton-muted"
}`}>
{u.role}
</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${u.role === "superadmin" ? "bg-anton-accent/20 text-anton-accent" : u.role === "admin" ? "bg-blue-500/20 text-blue-400" : "bg-anton-border text-anton-muted"}`}>{u.role}</span>
)}
</td>
<td className="px-5 py-3 text-anton-muted">
{editId === u.id ? (
<input type="number" value={editData.quota_tokens_monthly ?? u.quota_tokens_monthly}
onChange={(e) => setEditData({ ...editData, quota_tokens_monthly: Number(e.target.value) })}
className="bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs w-28"
/>
) : formatNum(u.quota_tokens_monthly)}
</td>
<td className="px-5 py-3 text-anton-muted">
{formatNum(u.tokens_used_this_month)}
{editId === u.id ? <input type="number" value={editData.quota_tokens_monthly ?? u.quota_tokens_monthly} onChange={(e) => setEditData({ ...editData, quota_tokens_monthly: Number(e.target.value) })} className="bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs w-28" /> : formatNum(u.quota_tokens_monthly)}
</td>
<td className="px-5 py-3 text-anton-muted">{formatNum(u.tokens_used_this_month)}</td>
<td className="px-5 py-3 text-anton-muted">{u.chat_count}</td>
<td className="px-5 py-3">
<span className={`w-2 h-2 inline-block rounded-full mr-1 ${u.is_active ? "bg-anton-success" : "bg-anton-danger"}`} />
<span className="text-xs text-anton-muted">{u.is_active ? "Active" : "Disabled"}</span>
</td>
<td className="px-5 py-3"><span className={`w-2 h-2 inline-block rounded-full mr-1 ${u.is_active ? "bg-anton-success" : "bg-anton-danger"}`} /><span className="text-xs text-anton-muted">{u.is_active ? "Active" : "Off"}</span></td>
<td className="px-5 py-3">
<div className="flex items-center gap-1">
{editId === u.id ? (
<>
<button onClick={() => handleSaveEdit(u.id)} className="p-1 rounded hover:bg-anton-success/20 text-anton-success"><Save size={14} /></button>
<button onClick={() => setEditId(null)} className="p-1 rounded hover:bg-anton-border text-anton-muted"><X size={14} /></button>
</>
<><button onClick={() => handleSaveEdit(u.id)} className="p-1 rounded hover:bg-anton-success/20 text-anton-success"><Save size={14} /></button><button onClick={() => setEditId(null)} className="p-1 rounded hover:bg-anton-border text-anton-muted"><X size={14} /></button></>
) : (
<>
<button onClick={() => { setEditId(u.id); setEditData({}); }}
className="p-1 rounded hover:bg-anton-accent/20 text-anton-accent text-xs">Edit</button>
<button onClick={() => adminUpdateUser(state.token, u.id, { is_active: !u.is_active }).then(load)}
className="p-1 rounded hover:bg-anton-border text-anton-muted"
title={u.is_active ? "Disable" : "Enable"}>
{u.is_active ? <ShieldOff size={14} /> : <Shield size={14} />}
</button>
{u.role !== "superadmin" && (
<button onClick={() => handleDelete(u.id, u.username)}
className="p-1 rounded hover:bg-red-500/20 text-anton-danger"><Trash2 size={14} /></button>
<button onClick={() => openPerms(u)} className="p-1 rounded hover:bg-purple-500/20 text-purple-400" title="Permissions"><Settings2 size={14} /></button>
)}
<button onClick={() => { setEditId(u.id); setEditData({}); }} className="p-1 rounded hover:bg-anton-accent/20 text-anton-accent text-xs">Edit</button>
<button onClick={() => adminUpdateUser(state.token, u.id, { is_active: !u.is_active }).then(load)} className="p-1 rounded hover:bg-anton-border text-anton-muted" title={u.is_active ? "Disable" : "Enable"}>
{u.is_active ? <ShieldOff size={14} /> : <Shield size={14} />}
</button>
{u.role !== "superadmin" && <button onClick={() => handleDelete(u.id, u.username)} className="p-1 rounded hover:bg-red-500/20 text-anton-danger"><Trash2 size={14} /></button>}
</>
)}
</div>
......@@ -256,6 +249,115 @@ export default function AdminPage() {
</div>
</div>
</div>
{/* ═══ PERMISSIONS MODAL ═══ */}
{permsUser && (
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-start justify-center overflow-y-auto p-4 animate-fade-in" onClick={() => { setPermsUser(null); setPermsData(null); }}>
<div className="bg-anton-surface border border-anton-border rounded-2xl w-full max-w-2xl my-8 shadow-2xl" onClick={e => e.stopPropagation()}>
{/* Modal Header */}
<div className="px-6 py-4 border-b border-anton-border flex items-center justify-between">
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<Settings2 size={20} className="text-purple-400" />
{permsUser.id === "__defaults__" ? "Default Permissions Template" : `Permissions: @${permsUser.username}`}
</h2>
<p className="text-xs text-anton-muted mt-0.5">
{permsUser.id === "__defaults__" ? "Applied to new users on creation" : `Role: ${permsUser.role}`}
</p>
</div>
<button onClick={() => { setPermsUser(null); setPermsData(null); }} className="p-2 rounded-lg text-anton-muted hover:text-white hover:bg-anton-card transition"><X size={18} /></button>
</div>
{permsLoading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="text-anton-accent animate-spin" /></div>
) : permsData ? (
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto">
{/* Feature Access */}
<div>
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2"><Lock size={14} className="text-anton-accent" /> Feature Access</h3>
<div className="space-y-2">
{FEATURE_DEFS.map(f => {
const Icon = f.icon;
return (
<div key={f.key} className="flex items-center justify-between bg-anton-card rounded-lg px-4 py-3 border border-anton-border">
<div className="flex items-center gap-3">
<Icon size={16} className={f.color} />
<div>
<p className="text-sm text-white font-medium">{f.label}</p>
<p className="text-[10px] text-anton-muted">{f.desc}</p>
</div>
</div>
<Toggle value={!!permsData[f.key]} onChange={v => updatePerm(f.key, v)} />
</div>
);
})}
</div>
</div>
{/* Model Access */}
<div>
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2"><Cpu size={14} className="text-cyan-400" /> Model Access</h3>
<div className="space-y-2">
<label className="flex items-center gap-3 bg-anton-card rounded-lg px-4 py-3 border border-anton-border cursor-pointer">
<input type="checkbox" checked={permsData.allowed_models === "all"} onChange={e => updatePerm("allowed_models", e.target.checked ? "all" : MODELS.map(m => m.id).join(","))}
className="w-4 h-4 rounded accent-anton-accent" />
<div><p className="text-sm text-white font-medium">All Models</p><p className="text-[10px] text-anton-muted">Access to every available model</p></div>
</label>
{permsData.allowed_models !== "all" && (
<div className="pl-4 space-y-1.5">
{MODELS.map(m => {
const list = (permsData.allowed_models || "").split(",").map(s => s.trim()).filter(Boolean);
const checked = list.includes(m.id);
return (
<label key={m.id} className="flex items-center gap-3 bg-anton-bg rounded-lg px-3 py-2 border border-anton-border/50 cursor-pointer">
<input type="checkbox" checked={checked} onChange={e => {
let next = checked ? list.filter(x => x !== m.id) : [...list, m.id];
if (!next.length) next = [m.id]; // Can't have zero models
updatePerm("allowed_models", next.join(","));
}} className="w-4 h-4 rounded accent-anton-accent" />
<div><span className="text-sm text-white">{m.label}</span><span className="text-[10px] text-anton-muted ml-2">{m.tier}</span></div>
</label>
);
})}
</div>
)}
</div>
</div>
{/* Limits */}
<div>
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2"><Gauge size={14} className="text-amber-400" /> Usage Limits</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{LIMIT_DEFS.map(l => (
<div key={l.key} className="bg-anton-card rounded-lg px-4 py-3 border border-anton-border">
<div className="flex justify-between items-center mb-1">
<label className="text-xs text-anton-muted">{l.label}</label>
<span className="text-xs text-white font-mono">{(permsData[l.key] || 0) === 0 && l.desc?.includes("unlimited") ? "∞" : (permsData[l.key] || 0).toLocaleString()}</span>
</div>
<input type="number" min={l.min} max={l.max} step={l.step} value={permsData[l.key] || 0}
onChange={e => updatePerm(l.key, Math.min(Math.max(Number(e.target.value) || 0, l.min), l.max))}
className="w-full bg-anton-bg border border-anton-border rounded px-2.5 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-anton-accent"
/>
<p className="text-[9px] text-anton-muted mt-1">{l.desc}</p>
</div>
))}
</div>
</div>
</div>
) : null}
{/* Modal Footer */}
{permsData && (
<div className="px-6 py-4 border-t border-anton-border flex items-center justify-between">
<button onClick={() => { setPermsUser(null); setPermsData(null); }} className="px-4 py-2 rounded-lg border border-anton-border text-anton-muted hover:text-white transition text-sm">Cancel</button>
<button onClick={savePerms} disabled={permsSaving} className="flex items-center gap-2 px-5 py-2 bg-anton-accent text-white rounded-lg text-sm font-semibold hover:opacity-80 transition disabled:opacity-50">
{permsSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Save Permissions
</button>
</div>
)}
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
import React, { createContext, useContext, useReducer, useCallback } from "react";
import React, { createContext, useContext, useReducer } from "react";
const AppContext = createContext(null);
......@@ -28,56 +28,23 @@ function reducer(state, action) {
case "SET_ACTIVE_CHAT":
return { ...state, activeChatId: action.chatId, sidebarOpen: false };
case "ADD_CHAT":
return {
...state,
chats: [action.chat, ...state.chats],
activeChatId: action.chat.id,
sidebarOpen: false,
};
return { ...state, chats: [action.chat, ...state.chats], activeChatId: action.chat.id, sidebarOpen: false };
case "UPDATE_CHAT":
return {
...state,
chats: state.chats.map((c) =>
c.id === action.chat.id ? { ...c, ...action.chat } : c
),
};
return { ...state, chats: state.chats.map(c => c.id === action.chat.id ? { ...c, ...action.chat } : c) };
case "REMOVE_CHAT": {
const remaining = state.chats.filter((c) => c.id !== action.chatId);
return {
...state,
chats: remaining,
activeChatId:
state.activeChatId === action.chatId
? remaining[0]?.id || null
: state.activeChatId,
};
const remaining = state.chats.filter(c => c.id !== action.chatId);
return { ...state, chats: remaining, activeChatId: state.activeChatId === action.chatId ? remaining[0]?.id || null : state.activeChatId };
}
case "SET_MESSAGES":
return {
...state,
chatMessages: { ...state.chatMessages, [action.chatId]: action.messages },
};
return { ...state, chatMessages: { ...state.chatMessages, [action.chatId]: action.messages } };
case "ADD_MESSAGE": {
const prev = state.chatMessages[action.chatId] || [];
return {
...state,
chatMessages: {
...state.chatMessages,
[action.chatId]: [...prev, action.message],
},
};
return { ...state, chatMessages: { ...state.chatMessages, [action.chatId]: [...prev, action.message] } };
}
case "SET_STREAMING":
return {
...state,
activeStreams: action.streaming
? { ...state.activeStreams, [action.chatId]: true }
: Object.fromEntries(
Object.entries(state.activeStreams).filter(([k]) => k !== action.chatId)
),
};
return { ...state, activeStreams: action.streaming ? { ...state.activeStreams, [action.chatId]: true } : Object.fromEntries(Object.entries(state.activeStreams).filter(([k]) => k !== action.chatId)) };
case "SET_SIDEBAR_OPEN":
return { ...state, sidebarOpen: action.open };
......@@ -91,15 +58,25 @@ function reducer(state, action) {
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
return <AppContext.Provider value={{ state, dispatch }}>{children}</AppContext.Provider>;
}
export function useApp() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error("useApp must be inside AppProvider");
return ctx;
}
/** Helper to get current user permissions with sane defaults */
export function usePermissions() {
const { state } = useApp();
const p = state.user?.permissions;
if (!p) return {
can_use_web_search: false, can_use_ui_design: false, can_use_knowledge_base: true,
can_use_gitlab: false, can_use_attachments: true, can_export_pptx: true, can_export_docx: true,
allowed_models: "all", max_tokens_cap: 4096, max_reasoning_budget: 0, max_chats: 50,
max_messages_per_day: 100, max_knowledge_bases: 3, max_documents_per_kb: 20,
max_attachment_size_mb: 10, max_attachments_per_message: 5,
};
return p;
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment