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")
......
This diff is collapsed.
"""
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
This diff is collapsed.
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>
......
This diff is collapsed.
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