Commit f96d6514 authored by Mahmoud Aglan's avatar Mahmoud Aglan

UPPPPP

parent 705bdcba
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -48,29 +48,12 @@ def _run_migrations(): ...@@ -48,29 +48,12 @@ def _run_migrations():
from backend.models import ChatAttachment from backend.models import ChatAttachment
ChatAttachment.__table__.create(bind=engine, checkfirst=True) ChatAttachment.__table__.create(bind=engine, checkfirst=True)
# Create user_permissions table if missing
if "user_permissions" not in existing_tables: if "user_permissions" not in existing_tables:
from backend.models import UserPermissions from backend.models import UserPermissions
UserPermissions.__table__.create(bind=engine, checkfirst=True) UserPermissions.__table__.create(bind=engine, checkfirst=True)
print(" Created user_permissions table") print(" Created user_permissions table")
# ── App Settings table ──
if "app_settings" not in existing_tables:
from backend.models import AppSettings
AppSettings.__table__.create(bind=engine, checkfirst=True)
print(" Created app_settings table")
# Ensure default app_settings row exists
from backend.models import AppSettings
from backend.database import SessionLocal
_db = SessionLocal()
try:
if not _db.query(AppSettings).first():
_db.add(AppSettings(allow_registration=True))
_db.commit()
print(" Created default app_settings row")
finally:
_db.close()
for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]: for table_name in ["gitlab_settings", "linked_repos", "pending_actions"]:
if table_name not in existing_tables: if table_name not in existing_tables:
print(f" Creating {table_name} table") print(f" Creating {table_name} table")
......
...@@ -54,6 +54,7 @@ class UserPermissions(Base): ...@@ -54,6 +54,7 @@ class UserPermissions(Base):
unique=True, nullable=False, index=True, unique=True, nullable=False, index=True,
) )
# Feature access
can_use_web_search = Column(Boolean, default=False) can_use_web_search = Column(Boolean, default=False)
can_use_ui_design = Column(Boolean, default=False) can_use_ui_design = Column(Boolean, default=False)
can_use_knowledge_base = Column(Boolean, default=True) can_use_knowledge_base = Column(Boolean, default=True)
...@@ -62,8 +63,10 @@ class UserPermissions(Base): ...@@ -62,8 +63,10 @@ class UserPermissions(Base):
can_export_pptx = Column(Boolean, default=True) can_export_pptx = Column(Boolean, default=True)
can_export_docx = 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") 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_tokens_cap = Column(Integer, default=4096)
max_reasoning_budget = Column(Integer, default=0) max_reasoning_budget = Column(Integer, default=0)
max_chats = Column(Integer, default=50) max_chats = Column(Integer, default=50)
...@@ -78,18 +81,6 @@ class UserPermissions(Base): ...@@ -78,18 +81,6 @@ class UserPermissions(Base):
user = relationship("User", back_populates="permissions") user = relationship("User", back_populates="permissions")
# ═══════════════════════════════════════════════════════════
# App-wide Settings (singleton row)
# ═══════════════════════════════════════════════════════════
class AppSettings(Base):
__tablename__ = "app_settings"
id = Column(String(36), primary_key=True, default=new_id)
allow_registration = Column(Boolean, default=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Chat(Base): class Chat(Base):
__tablename__ = "chats" __tablename__ = "chats"
......
""" """
Superadmin routes: user management, stats, permissions, app settings — v4.2.0 Superadmin routes: user management, stats, permissions — v4.2.0
""" """
from pydantic import BaseModel from pydantic import BaseModel
...@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session ...@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
from backend.database import get_db from backend.database import get_db
from backend.models import User, Chat, Message, KnowledgeBase, UserPermissions, AppSettings from backend.models import User, Chat, Message, KnowledgeBase, UserPermissions
from backend.auth import ( from backend.auth import (
require_superadmin, hash_password, get_user_permissions, require_superadmin, hash_password, get_user_permissions,
ensure_user_permissions, get_default_permissions_template, ensure_user_permissions, get_default_permissions_template,
...@@ -55,10 +55,6 @@ class PermissionsBody(BaseModel): ...@@ -55,10 +55,6 @@ class PermissionsBody(BaseModel):
max_attachments_per_message: Optional[int] = None max_attachments_per_message: Optional[int] = None
class AppSettingsBody(BaseModel):
allow_registration: Optional[bool] = None
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
# Stats & Users # Stats & Users
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
...@@ -111,6 +107,7 @@ def create_user(body: CreateUserBody, admin: User = Depends(require_superadmin), ...@@ -111,6 +107,7 @@ def create_user(body: CreateUserBody, admin: User = Depends(require_superadmin),
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
# Auto-create permissions from defaults template
ensure_user_permissions(user.id, db) ensure_user_permissions(user.id, db)
return {"id": user.id, "username": user.username} return {"id": user.id, "username": user.username}
...@@ -163,38 +160,6 @@ def list_all_chats(admin: User = Depends(require_superadmin), db: Session = Depe ...@@ -163,38 +160,6 @@ def list_all_chats(admin: User = Depends(require_superadmin), db: Session = Depe
return result return result
# ═══════════════════════════════════════════════════
# APP SETTINGS (registration toggle etc.)
# ═══════════════════════════════════════════════════
@router.get("/settings")
def get_app_settings(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
settings = db.query(AppSettings).first()
if not settings:
settings = AppSettings(allow_registration=True)
db.add(settings)
db.commit()
db.refresh(settings)
return {
"allow_registration": settings.allow_registration,
}
@router.put("/settings")
def update_app_settings(body: AppSettingsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
settings = db.query(AppSettings).first()
if not settings:
settings = AppSettings(allow_registration=True)
db.add(settings)
if body.allow_registration is not None:
settings.allow_registration = body.allow_registration
db.commit()
db.refresh(settings)
return {
"allow_registration": settings.allow_registration,
}
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
# PERMISSIONS MANAGEMENT # PERMISSIONS MANAGEMENT
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
......
""" """
Authentication routes: register, login, profile — with permissions and registration toggle. Authentication routes: register, login, profile — with permissions.
""" """
from pydantic import BaseModel from pydantic import BaseModel
...@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status ...@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import User, AppSettings from backend.models import User
from backend.auth import ( from backend.auth import (
hash_password, verify_password, create_token, get_current_user, hash_password, verify_password, create_token, get_current_user,
get_user_permissions, ensure_user_permissions, get_user_permissions, ensure_user_permissions,
...@@ -28,24 +28,8 @@ class LoginBody(BaseModel): ...@@ -28,24 +28,8 @@ class LoginBody(BaseModel):
password: str password: str
class ProfileOut(BaseModel):
pass
class Config(BaseModel):
pass
@router.post("/register") @router.post("/register")
def register(body: RegisterBody, db: Session = Depends(get_db)): def register(body: RegisterBody, db: Session = Depends(get_db)):
# Check if registration is enabled
settings = db.query(AppSettings).first()
if settings and not settings.allow_registration:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"Registration is currently disabled. Contact your administrator.",
)
if db.query(User).filter( if db.query(User).filter(
(User.username == body.username) | (User.email == body.email) (User.username == body.username) | (User.email == body.email)
).first(): ).first():
...@@ -88,16 +72,6 @@ def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)): ...@@ -88,16 +72,6 @@ def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
return _user_dict(user, perms) return _user_dict(user, perms)
@router.get("/config")
def get_public_config(db: Session = Depends(get_db)):
"""Public endpoint — no auth required. Returns app config the login page needs."""
settings = db.query(AppSettings).first()
allow_reg = True
if settings:
allow_reg = settings.allow_registration
return {"allow_registration": allow_reg}
def _user_dict(u: User, perms: dict = None) -> dict: def _user_dict(u: User, perms: dict = None) -> dict:
d = { d = {
"id": u.id, "id": u.id,
......
#!/usr/bin/env bash
set -uo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'
PROJECT_DIR="$(cd "$(dirname "$0")" && pwd)"
APP_NAME="son-of-anton"
ERRORS=""
WARNINGS=""
banner() {
echo ""
echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
echo -e "${BOLD} $1${NC}"
echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"
}
ok() { echo -e " ${GREEN}${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; ERRORS="${ERRORS}\n ${RED}${NC} $1"; }
step() { echo -e " ${CYAN}${NC} $1"; }
warn() { echo -e " ${YELLOW}${NC} $1"; WARNINGS="${WARNINGS}\n ${YELLOW}${NC} $1"; }
info() { echo -e " ${DIM}${NC} $1"; }
has_errors() { [[ -n "$ERRORS" ]]; }
cd "$PROJECT_DIR"
# ══════════════════════════════════════════════════
# Step 0: Check ALL files at once
# ══════════════════════════════════════════════════
banner "Step 0 — Full File Verification (checks everything)"
# ── api.js exports ──
step "Checking api.js exports..."
API_FILE="frontend/src/api.js"
if [[ ! -f "$API_FILE" ]]; then
fail "frontend/src/api.js DOES NOT EXIST"
else
API_MISSING=""
for EXPORT in adminStats adminListUsers adminCreateUser adminUpdateUser adminDeleteUser uploadAttachments getAttachmentUrl deleteAttachment downloadZip streamMessage login register getMe listChats createChat updateChat deleteChat getMessages listKnowledgeBases createKnowledgeBase deleteKnowledgeBase uploadDocuments; do
if ! grep -q "export.*${EXPORT}" "$API_FILE" 2>/dev/null; then
API_MISSING="$API_MISSING $EXPORT"
fi
done
if [[ -n "$API_MISSING" ]]; then
fail "api.js MISSING exports:${API_MISSING}"
else
ok "api.js — all 20 exports present"
fi
fi
# ── Dockerfile ──
step "Checking Dockerfile..."
if [[ ! -f Dockerfile ]]; then
fail "Dockerfile DOES NOT EXIST"
else
DF_ISSUES=""
grep -q "\-\-include=dev" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing --include=dev"
grep -q "npx vite build" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing 'npx vite build'"
grep -q "ENV NODE_ENV=" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing 'ENV NODE_ENV='"
grep -q "ffmpeg" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing ffmpeg install"
grep -q "chat_attachments" Dockerfile 2>/dev/null || DF_ISSUES="${DF_ISSUES} missing chat_attachments mkdir"
if [[ -n "$DF_ISSUES" ]]; then
fail "Dockerfile issues:${DF_ISSUES}"
else
ok "Dockerfile — all 5 checks pass"
fi
fi
# ── .dockerignore ──
step "Checking .dockerignore..."
if [[ ! -f .dockerignore ]]; then
fail ".dockerignore DOES NOT EXIST"
else
DI_ISSUES=""
grep -q "node_modules" .dockerignore 2>/dev/null || DI_ISSUES="${DI_ISSUES} missing node_modules"
grep -q "__pycache__" .dockerignore 2>/dev/null || DI_ISSUES="${DI_ISSUES} missing __pycache__"
if [[ -n "$DI_ISSUES" ]]; then
fail ".dockerignore issues:${DI_ISSUES}"
else
ok ".dockerignore — looks good"
fi
fi
# ── Backend files ──
step "Checking backend files..."
BACKEND_MISSING=""
for F in backend/main.py backend/config.py backend/models.py backend/auth.py backend/database.py backend/seed.py backend/system_prompt.py backend/routes/auth_routes.py backend/routes/chat_routes.py backend/routes/admin_routes.py backend/routes/knowledge_routes.py backend/routes/files_routes.py backend/routes/attachment_routes.py backend/services/bedrock_service.py backend/services/memory_service.py backend/services/rag_service.py backend/services/code_extractor.py backend/services/attachment_service.py; do
if [[ ! -f "$F" ]]; then
BACKEND_MISSING="$BACKEND_MISSING $F"
fi
done
if [[ -n "$BACKEND_MISSING" ]]; then
fail "Missing backend files:${BACKEND_MISSING}"
else
ok "All 18 backend files present"
fi
# ── Backend content checks ──
step "Checking backend file contents..."
CONTENT_ISSUES=""
grep -q "attachment_router" backend/main.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} main.py: missing attachment_router import"
grep -q "ChatAttachment" backend/models.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} models.py: missing ChatAttachment model"
grep -q "ATTACHMENT_PATH" backend/config.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} config.py: missing ATTACHMENT_PATH"
grep -q "attachment_ids" backend/routes/chat_routes.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} chat_routes.py: missing attachment_ids"
grep -q "build_claude_content_blocks" backend/services/attachment_service.py 2>/dev/null || CONTENT_ISSUES="${CONTENT_ISSUES} attachment_service.py: missing build_claude_content_blocks"
if [[ -n "$CONTENT_ISSUES" ]]; then
fail "Backend content issues:${CONTENT_ISSUES}"
else
ok "Backend file contents verified"
fi
# ── Frontend component files ──
step "Checking frontend files..."
FRONTEND_MISSING=""
for F in frontend/src/App.jsx frontend/src/main.jsx frontend/src/store.jsx frontend/src/streamManager.js frontend/src/index.css frontend/src/components/ChatView.jsx frontend/src/components/MessageBubble.jsx frontend/src/components/CodeBlock.jsx frontend/src/components/Sidebar.jsx frontend/src/pages/LoginPage.jsx frontend/src/pages/ChatPage.jsx frontend/src/pages/AdminPage.jsx frontend/package.json frontend/vite.config.js frontend/tailwind.config.js frontend/postcss.config.js frontend/index.html; do
if [[ ! -f "$F" ]]; then
FRONTEND_MISSING="$FRONTEND_MISSING $F"
fi
done
if [[ -n "$FRONTEND_MISSING" ]]; then
fail "Missing frontend files:${FRONTEND_MISSING}"
else
ok "All 17 frontend files present"
fi
# ── Frontend content checks ──
step "Checking frontend file contents..."
FE_ISSUES=""
grep -q "uploadAttachments" frontend/src/components/ChatView.jsx 2>/dev/null || FE_ISSUES="${FE_ISSUES} ChatView.jsx: missing uploadAttachments"
grep -q "getAttachmentUrl" frontend/src/components/MessageBubble.jsx 2>/dev/null || FE_ISSUES="${FE_ISSUES} MessageBubble.jsx: missing getAttachmentUrl"
grep -q "Paperclip\|paperclip" frontend/src/components/ChatView.jsx 2>/dev/null || FE_ISSUES="${FE_ISSUES} ChatView.jsx: missing Paperclip (no file attach UI)"
grep -q "Pillow" requirements.txt 2>/dev/null || FE_ISSUES="${FE_ISSUES} requirements.txt: missing Pillow"
if [[ -n "$FE_ISSUES" ]]; then
fail "Frontend/deps content issues:${FE_ISSUES}"
else
ok "Frontend file contents verified"
fi
# ── requirements.txt ──
step "Checking requirements.txt..."
REQ_MISSING=""
for PKG in fastapi uvicorn sqlalchemy pyjwt passlib httpx chromadb PyPDF2 pydantic Pillow; do
if ! grep -qi "$PKG" requirements.txt 2>/dev/null; then
REQ_MISSING="$REQ_MISSING $PKG"
fi
done
if [[ -n "$REQ_MISSING" ]]; then
fail "requirements.txt missing:${REQ_MISSING}"
else
ok "requirements.txt — all 10 packages present"
fi
# ══════════════════════════════════════════════════
# STOP if any errors
# ══════════════════════════════════════════════════
echo ""
if has_errors; then
echo -e "${RED}${BOLD}═══════════════════════════════════════════════════${NC}"
echo -e "${RED}${BOLD} FILES ARE BROKEN — Cannot deploy${NC}"
echo -e "${RED}${BOLD}═══════════════════════════════════════════════════${NC}"
echo ""
echo -e "${BOLD}All issues found:${NC}"
echo -e "$ERRORS"
echo ""
echo -e "${YELLOW}${BOLD}Fix ALL of the above, then run this script again.${NC}"
echo ""
exit 1
fi
echo ""
echo -e " ${GREEN}${BOLD}All file checks passed ✔${NC}"
# ══════════════════════════════════════════════════
# Step 1: Local build test
# ══════════════════════════════════════════════════
banner "Step 1 — Local Build Test"
step "Cleaning frontend/node_modules and dist..."
rm -rf frontend/node_modules frontend/dist 2>/dev/null || true
step "npm install..."
cd frontend
if ! npm install --legacy-peer-deps --include=dev 2>&1 | tail -5; then
echo -e "\n ${RED}${BOLD}npm install failed. Fix above errors.${NC}"
exit 1
fi
ok "Dependencies installed"
step "Building frontend..."
if ! npm run build 2>&1; then
echo -e "\n ${RED}${BOLD}BUILD FAILED. Fix the errors shown above.${NC}"
exit 1
fi
ok "Frontend builds clean"
cd "$PROJECT_DIR"
# ══════════════════════════════════════════════════
# Step 2: Get CapRover credentials
# ══════════════════════════════════════════════════
banner "Step 2 — CapRover Connection"
echo -e " ${BOLD}CapRover URL${NC} (e.g. https://captain.yourdomain.com):"
read -r CAPROVER_URL
CAPROVER_URL="${CAPROVER_URL%/}"
[[ -z "$CAPROVER_URL" ]] && { echo -e " ${RED}URL required${NC}"; exit 1; }
echo -e " ${BOLD}CapRover password:${NC}"
read -rs CAPROVER_PASSWORD
echo ""
[[ -z "$CAPROVER_PASSWORD" ]] && { echo -e " ${RED}Password required${NC}"; exit 1; }
step "Authenticating..."
LOGIN_RESP=$(curl -s -X POST "${CAPROVER_URL}/api/v2/login" \
-H "Content-Type: application/json" \
-H "x-namespace: captain" \
-d "{\"password\": \"${CAPROVER_PASSWORD}\"}" \
--connect-timeout 15 --max-time 30 2>&1) || { echo -e " ${RED}Cannot reach CapRover${NC}"; exit 1; }
TOKEN=$(echo "$LOGIN_RESP" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
print(d['data']['token'] if d.get('status') == 100 else '')
except: pass
" 2>/dev/null)
[[ -z "$TOKEN" ]] && { echo -e " ${RED}Login failed: $(echo "$LOGIN_RESP" | head -c 200)${NC}"; exit 1; }
ok "Authenticated"
# ══════════════════════════════════════════════════
# Step 3: Build tarball from LOCAL files
# ══════════════════════════════════════════════════
banner "Step 3 — Build Deploy Package"
rm -rf frontend/node_modules frontend/dist 2>/dev/null || true
TMPDIR=$(mktemp -d)
TARBALL="${TMPDIR}/deploy.tar"
step "Creating tarball..."
tar cf "$TARBALL" \
--exclude='.git' \
--exclude='node_modules' \
--exclude='dist' \
--exclude='.DS_Store' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.env' \
--exclude='.env.local' \
--exclude='.idea' \
--exclude='.vscode' \
--exclude='create-project.ps1' \
--exclude='*.sh' \
.
TARSIZE=$(du -sh "$TARBALL" | cut -f1)
ok "Tarball: ${TARSIZE}"
# Verify inside tarball
step "Verifying tarball contents..."
TAR_ERRORS=""
for F in Dockerfile .dockerignore requirements.txt warmup.py backend/main.py backend/routes/attachment_routes.py backend/services/attachment_service.py frontend/package.json frontend/src/api.js frontend/src/components/ChatView.jsx frontend/src/components/MessageBubble.jsx; do
if ! tar tf "$TARBALL" "./${F}" > /dev/null 2>&1; then
TAR_ERRORS="${TAR_ERRORS} ${F}"
fi
done
if [[ -n "$TAR_ERRORS" ]]; then
echo -e " ${RED}Tarball missing:${TAR_ERRORS}${NC}"
rm -rf "$TMPDIR"
exit 1
fi
# Verify actual content inside tarball
API_COUNT=$(tar xf "$TARBALL" -O ./frontend/src/api.js 2>/dev/null | grep -c "adminStats" || true)
DF_COUNT=$(tar xf "$TARBALL" -O ./Dockerfile 2>/dev/null | grep -c "\-\-include=dev" || true)
if [[ "$API_COUNT" -lt 1 ]]; then
echo -e " ${RED}api.js inside tarball has NO adminStats export!${NC}"
rm -rf "$TMPDIR"
exit 1
fi
if [[ "$DF_COUNT" -lt 1 ]]; then
echo -e " ${RED}Dockerfile inside tarball is OLD (no --include=dev)!${NC}"
rm -rf "$TMPDIR"
exit 1
fi
ok "Tarball contents verified (api.js ✔, Dockerfile ✔)"
# ══════════════════════════════════════════════════
# Step 4: Deploy
# ══════════════════════════════════════════════════
banner "Step 4 — Deploy to CapRover"
step "Uploading & building (5-15 min)..."
info "node:20 → npm install → vite build → python:3.11 → pip install → chromadb warmup"
echo ""
DEPLOY_RESP=$(curl -s -X POST \
"${CAPROVER_URL}/api/v2/user/apps/appData/${APP_NAME}?gitHash=$(date +%s)" \
-H "x-namespace: captain" \
-H "x-captain-auth: ${TOKEN}" \
-F "sourceFile=@${TARBALL}" \
--connect-timeout 30 \
--max-time 1200 \
2>&1)
rm -rf "$TMPDIR"
DEPLOY_STATUS=$(echo "$DEPLOY_RESP" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
if d.get('status') == 100:
print('SUCCESS')
else:
print('FAIL:' + (d.get('description','') or d.get('message','') or str(d)))
except Exception as e:
print('FAIL:' + str(e))
" 2>/dev/null)
echo ""
if [[ "$DEPLOY_STATUS" == "SUCCESS" ]]; then
echo -e " ${GREEN}${BOLD}═══════════════════════════════════════════════════${NC}"
echo -e " ${GREEN}${BOLD} 🔥 DEPLOYMENT SUCCESSFUL!${NC}"
echo -e " ${GREEN}${BOLD}═══════════════════════════════════════════════════${NC}"
echo ""
ok "Son of Anton is live."
echo ""
info "Default login: superadmin / (your SUPERADMIN_PASSWORD env var)"
echo ""
else
ERRMSG="${DEPLOY_STATUS#FAIL:}"
echo -e " ${RED}${BOLD}═══════════════════════════════════════════════════${NC}"
echo -e " ${RED}${BOLD} ✘ DEPLOYMENT FAILED${NC}"
echo -e " ${RED}${BOLD}═══════════════════════════════════════════════════${NC}"
echo ""
echo -e " ${RED}Error: ${ERRMSG}${NC}"
echo ""
step "Fetching build logs..."
sleep 3
BUILD_LOG=$(curl -s -X POST "${CAPROVER_URL}/api/v2/user/apps/appData/${APP_NAME}/buildLogs" \
-H "x-namespace: captain" \
-H "x-captain-auth: ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"appName\": \"${APP_NAME}\"}" \
--connect-timeout 10 --max-time 15 2>&1)
LOGS=$(echo "$BUILD_LOG" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
logs = d.get('data', {}).get('logs', '') or ''
if not logs: logs = json.dumps(d, indent=2)
for line in logs.strip().split('\n')[-60:]:
print(line)
except: print('Could not fetch logs')
" 2>/dev/null)
if [[ -n "$LOGS" ]]; then
echo -e " ${YELLOW}── Build Logs (last 60 lines) ──${NC}"
echo ""
echo "$LOGS" | while IFS= read -r line; do
if echo "$line" | grep -qiE "error|ERR!|failed|FATAL|not exported"; then
echo -e " ${RED}${line}${NC}"
elif echo "$line" | grep -qiE "Step "; then
echo -e " ${CYAN}${line}${NC}"
else
echo -e " ${DIM}${line}${NC}"
fi
done
echo ""
echo -e " ${YELLOW}── End ──${NC}"
fi
exit 1
fi
\ No newline at end of file
#!/usr/bin/env bash
# ══════════════════════════════════════════════════════════
# Son of Anton — Fix & Complete Deploy (picks up from Step 7)
# ══════════════════════════════════════════════════════════
set -uo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; WHITE='\033[1;37m'
MAGENTA='\033[0;35m'; BOLD='\033[1m'; NC='\033[0m'
ok() { echo -e " ${GREEN}${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; }
info() { echo -e " ${BLUE}${NC} $1"; }
warn() { echo -e " ${YELLOW}${NC} $1"; }
step() { echo -e "\n${CYAN}═══════════════════════════════════════════════════${NC}"; echo -e "${WHITE} $1${NC}"; echo -e "${CYAN}═══════════════════════════════════════════════════${NC}"; }
die() { fail "$1"; exit 1; }
CAPROVER_URL="http://localhost:3000"
APP_NAME="son-of-anton"
BEDROCK_API_KEY="ABSKQmVkcm9ja0FQSUtleS12ZGdrLWF0LTc2OTE4NzYxNDEyODpnc0ZzdmVGUlRVendqcVpJUURzR0Nzb2RSZ09hK1JLTnJTekFBY3FJQjBqL0F1UXVyekxic3VmaEtpRT0="
AWS_REGION="eu-central-1"
PRIMARY_MODEL="eu.anthropic.claude-opus-4-6-v1"
FAST_MODEL="eu.anthropic.claude-haiku-4-5-20251001-v1:0"
DEFAULT_QUOTA="2000000"
MAX_UPLOAD_MB="50"
WORK_DIR="/tmp/son-of-anton-fix-$$"
mkdir -p "$WORK_DIR"
trap "rm -rf $WORK_DIR" EXIT
echo ""
echo -e "${MAGENTA} 🔥 Son of Anton — Completing Deploy 🔥${NC}"
echo ""
# ── Get passwords ──────────────────────────────────
echo -e " ${WHITE}${BOLD}CapRover admin password:${NC}"
read -s -r -p " > " CAPROVER_PASSWORD; echo ""
[[ -n "$CAPROVER_PASSWORD" ]] || die "Empty password"
echo -e " ${WHITE}${BOLD}Son of Anton superadmin password:${NC}"
read -s -r -p " > " SUPERADMIN_PASSWORD; echo ""
[[ -n "$SUPERADMIN_PASSWORD" ]] || die "Empty password"
JWT_SECRET=$(openssl rand -hex 32)
ok "JWT secret: ${JWT_SECRET:0:16}..."
# ── Login ──────────────────────────────────────────
step "Login to CapRover"
LOGIN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-d "{\"password\":\"${CAPROVER_PASSWORD}\"}" \
"${CAPROVER_URL}/api/v2/login")
CAPTAIN_TOKEN=$(echo "$LOGIN_RESP" | jq -r '.data.token // empty')
[[ -n "$CAPTAIN_TOKEN" ]] || die "Login failed: $(echo "$LOGIN_RESP" | jq -r '.description // "bad password"')"
ok "Logged in"
# ── Set env vars + volume (THE FIX) ───────────────
step "Configure App (env vars + storage)"
# The fix: volumeName must be a real name, not empty string
CONFIG_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "x-captain-auth: ${CAPTAIN_TOKEN}" \
-d "{
\"appName\": \"${APP_NAME}\",
\"instanceCount\": 1,
\"envVars\": [
{\"key\":\"BEDROCK_API_KEY\",\"value\":\"${BEDROCK_API_KEY}\"},
{\"key\":\"JWT_SECRET\",\"value\":\"${JWT_SECRET}\"},
{\"key\":\"SUPERADMIN_PASSWORD\",\"value\":\"${SUPERADMIN_PASSWORD}\"},
{\"key\":\"AWS_REGION\",\"value\":\"${AWS_REGION}\"},
{\"key\":\"PRIMARY_MODEL\",\"value\":\"${PRIMARY_MODEL}\"},
{\"key\":\"FAST_MODEL\",\"value\":\"${FAST_MODEL}\"},
{\"key\":\"DEFAULT_QUOTA\",\"value\":\"${DEFAULT_QUOTA}\"},
{\"key\":\"MAX_UPLOAD_MB\",\"value\":\"${MAX_UPLOAD_MB}\"}
],
\"volumes\": [
{
\"containerPath\": \"/data\",
\"volumeName\": \"${APP_NAME}-data\"
}
],
\"ports\": [],
\"containerHttpPort\": 80,
\"notExposeAsWebApp\": false,
\"forceSsl\": false,
\"websocketSupport\": true,
\"captainDefinitionRelativeFilePath\": \"./captain-definition\",
\"description\": \"Son of Anton by Mahmoud Aglan\"
}" \
"${CAPROVER_URL}/api/v2/user/apps/appDefinitions/update")
CONFIG_STATUS=$(echo "$CONFIG_RESP" | jq -r '.status')
if [[ "$CONFIG_STATUS" == "100" ]]; then
ok "Environment variables set (8 vars)"
ok "Persistent volume: ${APP_NAME}-data -> /data"
ok "Container port: 80, WebSocket: enabled"
else
echo "$CONFIG_RESP" | jq .
die "Config failed: $(echo "$CONFIG_RESP" | jq -r '.description')"
fi
# ── Clone + tar ────────────────────────────────────
step "Clone repo & build deploy package"
git clone --depth 1 "http://gitlab.caprover.al-arcade.com/root/son-of-anton.git" "$WORK_DIR/repo" 2>&1 | while read -r l; do info "$l"; done
ok "Cloned"
cd "$WORK_DIR/repo"
tar -cf "$WORK_DIR/deploy.tar" \
--exclude='.git' \
--exclude='node_modules' \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='.env' \
--exclude='deploy.sh' \
--exclude='soadeploy.sh' \
--exclude='fix-deploy.sh' \
.
cd /tmp
TAR_SIZE=$(du -h "$WORK_DIR/deploy.tar" | cut -f1)
ok "Deploy package: ${TAR_SIZE}"
# ── Deploy ─────────────────────────────────────────
step "Deploy to CapRover (this takes 5-10 min)"
echo -e " ${YELLOW}Building Docker image: React frontend + Python backend + ChromaDB${NC}"
echo -e " ${YELLOW}First build is slow. Be patient...${NC}"
echo ""
DEPLOY_RESP=$(curl -s --max-time 900 \
-X POST \
-H "x-captain-auth: ${CAPTAIN_TOKEN}" \
-F "sourceFile=@${WORK_DIR}/deploy.tar" \
"${CAPROVER_URL}/api/v2/user/apps/appData/${APP_NAME}")
DEPLOY_STATUS=$(echo "$DEPLOY_RESP" | jq -r '.status')
if [[ "$DEPLOY_STATUS" == "100" ]]; then
ok "DEPLOY SUCCESSFUL!"
else
DEPLOY_DESC=$(echo "$DEPLOY_RESP" | jq -r '.description')
echo ""
fail "Deploy response: ${DEPLOY_DESC}"
echo ""
warn "Check build logs: CapRover Dashboard > ${APP_NAME} > Deployment"
warn "Or run: docker service logs srv-captain--${APP_NAME} --tail 200"
echo ""
echo -e " ${YELLOW}Continue with HTTPS setup anyway? (y/n)${NC}"
read -r choice
[[ "$choice" == "y" || "$choice" == "Y" ]] || die "Stopped."
fi
# ── Wait for container ─────────────────────────────
step "Waiting for container to start"
for i in $(seq 20 -1 1); do
printf "\r ⏳ %2ds " "$i"
sleep 1
done
printf "\r \r"
ok "Wait complete"
# ── HTTPS ──────────────────────────────────────────
step "Enable HTTPS"
SSL_RESP=$(curl -s --max-time 60 \
-X POST \
-H "Content-Type: application/json" \
-H "x-captain-auth: ${CAPTAIN_TOKEN}" \
-d "{\"appName\":\"${APP_NAME}\"}" \
"${CAPROVER_URL}/api/v2/user/apps/appDefinitions/enablebasedomainssl")
SSL_STATUS=$(echo "$SSL_RESP" | jq -r '.status')
if [[ "$SSL_STATUS" == "100" ]]; then
ok "HTTPS certificate issued"
# Force SSL
FORCE_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "x-captain-auth: ${CAPTAIN_TOKEN}" \
-d "{
\"appName\": \"${APP_NAME}\",
\"instanceCount\": 1,
\"envVars\": [
{\"key\":\"BEDROCK_API_KEY\",\"value\":\"${BEDROCK_API_KEY}\"},
{\"key\":\"JWT_SECRET\",\"value\":\"${JWT_SECRET}\"},
{\"key\":\"SUPERADMIN_PASSWORD\",\"value\":\"${SUPERADMIN_PASSWORD}\"},
{\"key\":\"AWS_REGION\",\"value\":\"${AWS_REGION}\"},
{\"key\":\"PRIMARY_MODEL\",\"value\":\"${PRIMARY_MODEL}\"},
{\"key\":\"FAST_MODEL\",\"value\":\"${FAST_MODEL}\"},
{\"key\":\"DEFAULT_QUOTA\",\"value\":\"${DEFAULT_QUOTA}\"},
{\"key\":\"MAX_UPLOAD_MB\",\"value\":\"${MAX_UPLOAD_MB}\"}
],
\"volumes\": [{\"containerPath\":\"/data\",\"volumeName\":\"${APP_NAME}-data\"}],
\"ports\": [],
\"containerHttpPort\": 80,
\"notExposeAsWebApp\": false,
\"forceSsl\": true,
\"websocketSupport\": true,
\"captainDefinitionRelativeFilePath\": \"./captain-definition\",
\"description\": \"Son of Anton by Mahmoud Aglan\"
}" \
"${CAPROVER_URL}/api/v2/user/apps/appDefinitions/update")
FORCE_STATUS=$(echo "$FORCE_RESP" | jq -r '.status')
[[ "$FORCE_STATUS" == "100" ]] && ok "Force HTTPS enabled" || warn "Force-HTTPS failed (HTTPS still works)"
else
SSL_DESC=$(echo "$SSL_RESP" | jq -r '.description')
warn "HTTPS skipped: ${SSL_DESC}"
warn "Enable manually from CapRover dashboard later"
fi
# ── Detect URL ─────────────────────────────────────
step "Detect App URL"
SYS_RESP=$(curl -s -H "x-captain-auth: ${CAPTAIN_TOKEN}" "${CAPROVER_URL}/api/v2/user/system/info")
ROOT_DOMAIN=$(echo "$SYS_RESP" | jq -r '.data.rootDomain // empty')
if [[ -n "$ROOT_DOMAIN" ]]; then
APP_URL="https://${APP_NAME}.${ROOT_DOMAIN}"
ok "URL: ${APP_URL}"
else
APP_URL="https://${APP_NAME}.YOUR-DOMAIN.com"
warn "Could not detect domain — check CapRover dashboard"
fi
# ── Save credentials ──────────────────────────────
CRED_FILE="$HOME/son-of-anton-credentials.txt"
cat > "$CRED_FILE" <<EOF
═══════════════════════════════════════════════
SON OF ANTON — Credentials
Generated: $(date)
═══════════════════════════════════════════════
URL: ${APP_URL}
Username: superadmin
Password: ${SUPERADMIN_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
BEDROCK_API_KEY: ${BEDROCK_API_KEY}
DO NOT SHARE THIS FILE.
═══════════════════════════════════════════════
EOF
chmod 600 "$CRED_FILE"
ok "Credentials saved: ${CRED_FILE}"
# ── Done! ──────────────────────────────────────────
echo ""
echo ""
echo -e "${GREEN} ╔══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN} ║ ║${NC}"
echo -e "${GREEN} ║ 🔥 SON OF ANTON IS LIVE! 🔥 ║${NC}"
echo -e "${GREEN} ║ ║${NC}"
echo -e "${GREEN} ╚══════════════════════════════════════════════════╝${NC}"
echo ""
echo -e " ${WHITE}${BOLD}URL: ${CYAN}${APP_URL}${NC}"
echo -e " ${WHITE}${BOLD}Username: ${CYAN}superadmin${NC}"
echo -e " ${WHITE}${BOLD}Password: ${CYAN}${SUPERADMIN_PASSWORD}${NC}"
echo ""
echo -e " ${WHITE}Creds file: ${YELLOW}cat ~/son-of-anton-credentials.txt${NC}"
echo -e " ${WHITE}Build logs: ${YELLOW}docker service logs srv-captain--${APP_NAME} --tail 100${NC}"
echo ""
echo -e " ${MAGENTA}Created by Mahmoud Aglan — AL-Arcade${NC}"
echo ""
...@@ -5,162 +5,117 @@ function headers(token) { ...@@ -5,162 +5,117 @@ function headers(token) {
if (token) h["Authorization"] = `Bearer ${token}`; if (token) h["Authorization"] = `Bearer ${token}`;
return h; return h;
} }
function authHeader(token) { return token ? { Authorization: `Bearer ${token}` } : {}; }
function authHeader(token) { function extractError(err, d) { let m = err.detail || err.message || d; if (Array.isArray(m)) return m.map(x => x.msg || JSON.stringify(x)).join(", "); if (typeof m === "object") return m.message || JSON.stringify(m); return String(m); }
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function request(method, path, token, body) { async function request(method, path, token, body) {
const opts = { method, headers: headers(token) }; const opts = { method, headers: headers(token) };
if (body) opts.body = JSON.stringify(body); if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts); const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) { if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "Request failed")); }
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || err.message || "Request failed");
}
return res.json(); return res.json();
} }
export const login = (username, password) => // Auth
request("POST", "/auth/login", null, { username, password }); export const login = (u, p) => request("POST", "/auth/login", null, { username: u, password: p });
export const register = (u, e, p) => request("POST", "/auth/register", null, { username: u, email: e, password: p });
export const register = (username, email, password) => export const getMe = (t) => request("GET", "/auth/me", t);
request("POST", "/auth/register", null, { username, email, password });
// Chats
export const getMe = (token) => request("GET", "/auth/me", token); export const listChats = (t) => request("GET", "/chats", t);
export const createChat = (t, d = {}) => request("POST", "/chats", t, d);
export const getPublicConfig = () => request("GET", "/auth/config", null); export const updateChat = (t, id, d) => request("PUT", `/chats/${id}`, t, d);
export const renameChat = (t, id, title) => updateChat(t, id, { title });
export const listChats = (token) => request("GET", "/chats", token); export const deleteChat = (t, id) => request("DELETE", `/chats/${id}`, t);
export const getMessages = (t, id) => request("GET", `/chats/${id}/messages`, t);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data); export const checkGenerating = (t, id) => request("GET", `/chats/${id}/generating`, t);
export const refreshRepoContext = (t, id) => request("POST", `/chats/${id}/refresh-repo`, t);
export const updateChat = (token, chatId, data) => export const commitFromChat = (t, id, d) => request("POST", `/chats/${id}/commit`, t, d);
request("PUT", `/chats/${chatId}`, token, data);
// Streaming
export const renameChat = (token, chatId, title) =>
updateChat(token, chatId, { title });
export const deleteChat = (token, chatId) =>
request("DELETE", `/chats/${chatId}`, token);
export const getMessages = (token, chatId) =>
request("GET", `/chats/${chatId}/messages`, token);
export async function* streamMessage(token, chatId, body, signal) { export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { const res = await fetch(`${BASE}/chats/${chatId}/messages`, { method: "POST", headers: headers(token), body: JSON.stringify(body), signal });
method: "POST", headers: headers(token), if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "Stream failed")); }
body: JSON.stringify(body), signal, const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = "";
}); while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split("\n\n"); buffer = parts.pop() || ""; for (const part of parts) { const line = part.trim(); if (line.startsWith("data: ")) { try { yield JSON.parse(line.slice(6)); } catch { } } } }
if (!res.ok) { if (buffer.trim().startsWith("data: ")) { try { yield JSON.parse(buffer.trim().slice(6)); } catch { } }
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || "Stream failed");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
const line = part.trim();
if (line.startsWith("data: ")) {
try { yield JSON.parse(line.slice(6)); } catch { }
}
}
}
if (buffer.trim().startsWith("data: ")) {
try { yield JSON.parse(buffer.trim().slice(6)); } catch { }
}
}
export async function uploadAttachments(token, chatId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/chats/${chatId}/attachments`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json();
} }
export function getAttachmentUrl(attachmentId) { // Attachments
return `${BASE}/attachments/${attachmentId}/file`; export async function uploadAttachments(t, chatId, files) { const form = new FormData(); for (const f of files) form.append("files", f); const res = await fetch(`${BASE}/chats/${chatId}/attachments`, { method: "POST", headers: authHeader(t), body: form }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); } return res.json(); }
export function getAttachmentUrl(id) { return `${BASE}/attachments/${id}/file`; }
export const deleteAttachment = (t, id) => request("DELETE", `/attachments/${id}`, t);
// Knowledge
export const listKnowledgeBases = (t) => request("GET", "/knowledge", t);
export const createKnowledgeBase = (t, n, d = "") => request("POST", "/knowledge", t, { name: n, description: d });
export const getKnowledgeBase = (t, id) => request("GET", `/knowledge/${id}`, t);
export const updateKnowledgeBase = (t, id, d) => request("PUT", `/knowledge/${id}`, t, d);
export const deleteKnowledgeBase = (t, id) => request("DELETE", `/knowledge/${id}`, t);
export const listKnowledgeDocuments = (t, id) => request("GET", `/knowledge/${id}/documents`, t);
export const deleteKnowledgeDocument = (t, kbId, docId) => request("DELETE", `/knowledge/${kbId}/documents/${docId}`, t);
export async function uploadDocuments(t, kbId, files) { const form = new FormData(); for (const f of files) form.append("files", f); const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, { method: "POST", headers: authHeader(t), body: form }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "Upload failed")); } return res.json(); }
export const uploadDocument = (t, kbId, f) => uploadDocuments(t, kbId, [f]);
// Admin
export const adminStats = (t) => request("GET", "/admin/stats", t);
export const adminListUsers = (t) => request("GET", "/admin/users", t);
export const adminCreateUser = (t, d) => request("POST", "/admin/users", t, d);
export const adminUpdateUser = (t, id, d) => request("PUT", `/admin/users/${id}`, t, d);
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 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) { 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);
} }
export const deleteAttachment = (token, attachmentId) => export async function exportDocx(token, markdown, title) {
request("DELETE", `/attachments/${attachmentId}`, token); const res = await fetch(`${BASE}/export/docx`, { method: "POST", headers: headers(token), body: JSON.stringify({ markdown, title }) });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(extractError(err, "DOCX export failed")); }
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token); 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";
export const createKnowledgeBase = (token, name, description = "") => a.download = `${safe}.docx`; a.click(); URL.revokeObjectURL(url);
request("POST", "/knowledge", token, { name, description });
export const getKnowledgeBase = (token, kbId) =>
request("GET", `/knowledge/${kbId}`, token);
export const deleteKnowledgeBase = (token, kbId) =>
request("DELETE", `/knowledge/${kbId}`, token);
export async function uploadDocuments(token, kbId, files) {
const form = new FormData();
for (const file of files) form.append("files", file);
const res = await fetch(`${BASE}/knowledge/${kbId}/upload`, {
method: "POST", headers: authHeader(token), body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
return res.json();
} }
export const uploadDocument = (token, kbId, file) => // Utilities
uploadDocuments(token, kbId, [file]); 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; }
export const adminStats = (token) => request("GET", "/admin/stats", token);
// GitLab
export const adminListUsers = (token) => request("GET", "/admin/users", token); export const gitlabGetSettings = (t) => request("GET", "/gitlab/settings", t);
export const gitlabUpdateSettings = (t, d) => request("PUT", "/gitlab/settings", t, d);
export const adminCreateUser = (token, data) => export const gitlabTestConnection = (t) => request("POST", "/gitlab/test-connection", t);
request("POST", "/admin/users", token, data); 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 adminUpdateUser = (token, userId, data) => export const gitlabListRepos = (t) => request("GET", "/gitlab/repos", t);
request("PUT", `/admin/users/${userId}`, token, data); 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 adminDeleteUser = (token, userId) => export const gitlabGetTree = (t, id, p, r) => request("GET", `/gitlab/repos/${id}/tree?path=${encodeURIComponent(p || "")}&ref=${encodeURIComponent(r || "")}`, t);
request("DELETE", `/admin/users/${userId}`, token); 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 adminListChats = (token) => request("GET", "/admin/chats", token); 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 adminGetSettings = (token) => request("GET", "/admin/settings", token); 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 adminUpdateSettings = (token, data) => export const gitlabReanalyzeRepo = (t, id) => request("POST", `/gitlab/repos/${id}/analyze`, t);
request("PUT", "/admin/settings", token, data); 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 async function downloadZip(token, markdown) { export const gitlabCreateAction = (t, d) => request("POST", "/gitlab/actions", t, d);
const res = await fetch(`${BASE}/files/download-zip`, { export const gitlabApproveAction = (t, id) => request("POST", `/gitlab/actions/${id}/approve`, t);
method: "POST", headers: headers(token), export const gitlabRejectAction = (t, id) => request("POST", `/gitlab/actions/${id}/reject`, t);
body: JSON.stringify({ markdown }), \ No newline at end of file
});
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;
a.download = "son-of-anton-code.zip";
a.click();
URL.revokeObjectURL(url);
} else {
const data = await res.json();
if (data.error) throw new Error(data.error);
}
}
\ No newline at end of file
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useApp } from "../store";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import { import {
adminStats, adminListUsers, adminCreateUser, adminUpdateUser, adminStats, adminListUsers, adminCreateUser, adminUpdateUser, adminDeleteUser,
adminDeleteUser, adminListChats, adminGetSettings, adminUpdateSettings, adminGetUserPermissions, adminUpdateUserPermissions,
adminGetDefaultPermissions, adminUpdateDefaultPermissions, adminApplyDefaults,
} from "../api"; } from "../api";
import { import {
ArrowLeft, Users, MessageSquare, Database, Zap, Plus, Trash2, Edit, ArrowLeft, Users, MessageSquare, Database, Zap,
Shield, ShieldCheck, ShieldX, RefreshCw, ToggleLeft, ToggleRight, UserPlus, Trash2, Shield, ShieldOff, Save, X,
Settings, UserPlus, Settings2, Check, Globe, Paintbrush, BookOpen, GitBranch,
Paperclip, Presentation, FileOutput, Cpu, Gauge, Lock,
Loader2, RotateCcw, Copy,
} from "lucide-react"; } 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() { export default function AdminPage() {
const { state } = useApp(); const { state } = useApp();
const navigate = useNavigate(); const navigate = useNavigate();
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [chats, setChats] = useState([]);
const [tab, setTab] = useState("stats");
const [showCreate, setShowCreate] = useState(false); 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 [editingUser, setEditingUser] = useState(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [appSettings, setAppSettings] = useState({ allow_registration: true });
const [settingsLoading, setSettingsLoading] = useState(false); // 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 () => { const load = useCallback(async () => {
try { try {
const [s, u, c] = await Promise.all([ const [s, u] = await Promise.all([adminStats(state.token), adminListUsers(state.token)]);
adminStats(state.token), setStats(s); setUsers(u);
adminListUsers(state.token), } catch (err) { setError(err.message); }
adminListChats(state.token),
]);
setStats(s);
setUsers(u);
setChats(c);
} catch (err) {
setError(err.message);
}
}, [state.token]); }, [state.token]);
const loadSettings = useCallback(async () => { useEffect(() => { load(); }, [load]);
try {
const s = await adminGetSettings(state.token); async function handleCreate(e) {
setAppSettings(s); e.preventDefault();
} catch { } try { await adminCreateUser(state.token, newUser); setShowCreate(false); setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 }); load(); } catch (err) { setError(err.message); }
}, [state.token]); }
useEffect(() => { load(); loadSettings(); }, [load, loadSettings]); async function handleSaveEdit(userId) {
try { await adminUpdateUser(state.token, userId, editData); setEditId(null); load(); } catch (err) { setError(err.message); }
}
async function handleCreateUser() { async function handleDelete(userId, username) {
try { if (!confirm(`Delete user "${username}"? This is permanent.`)) return;
await adminCreateUser(state.token, newUser); try { await adminDeleteUser(state.token, userId); load(); } catch (err) { setError(err.message); }
setShowCreate(false);
setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
load();
} catch (err) { setError(err.message); }
} }
async function handleUpdateUser(userId, data) { // ═══ Permissions ═══
async function openPerms(user) {
setPermsUser(user); setPermsLoading(true); setPermsData(null);
try { try {
await adminUpdateUser(state.token, userId, data); const data = user.id === "__defaults__"
setEditingUser(null); ? await adminGetDefaultPermissions(state.token)
load(); : await adminGetUserPermissions(state.token, user.id);
} catch (err) { setError(err.message); } setPermsData(data);
} catch (err) { setError(err.message); setPermsUser(null); }
setPermsLoading(false);
} }
async function handleDeleteUser(userId) { async function savePerms() {
if (!confirm("Delete this user and all their data?")) return; if (!permsUser || !permsData) return;
setPermsSaving(true);
try { try {
await adminDeleteUser(state.token, userId); if (permsUser.id === "__defaults__") {
load(); await adminUpdateDefaultPermissions(state.token, permsData);
} else {
await adminUpdateUserPermissions(state.token, permsUser.id, permsData);
}
setPermsUser(null); setPermsData(null);
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
setPermsSaving(false);
} }
async function toggleRegistration() { async function handleApplyDefaults() {
setSettingsLoading(true); if (!confirm("Apply default permissions to ALL regular users? This will overwrite their current permissions.")) return;
setApplyingDefaults(true);
try { try {
const res = await adminUpdateSettings(state.token, { const res = await adminApplyDefaults(state.token);
allow_registration: !appSettings.allow_registration, alert(`✅ Applied to ${res.users_updated} users`);
});
setAppSettings(res);
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
setSettingsLoading(false); setApplyingDefaults(false);
}
function updatePerm(key, value) {
setPermsData(prev => ({ ...prev, [key]: value }));
}
function formatNum(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(0) + "K";
return String(n);
} }
if (state.user?.role !== "superadmin") { if (state.user?.role !== "superadmin") {
return ( return <div className="h-full flex items-center justify-center"><p className="text-anton-danger text-lg">⛔ Access Denied</p></div>;
<div className="h-dvh flex items-center justify-center bg-anton-bg">
<div className="text-center">
<ShieldX size={48} className="text-red-500 mx-auto mb-4" />
<h2 className="text-xl text-white font-bold">Access Denied</h2>
<p className="text-anton-muted mt-2">Superadmin access required.</p>
<button onClick={() => navigate("/")} className="mt-4 text-anton-accent hover:underline">← Back to chat</button>
</div>
</div>
);
} }
return ( return (
<div className="h-dvh flex flex-col bg-anton-bg"> <div className="h-full overflow-y-auto bg-anton-bg p-4 sm:p-6">
{/* Header */} <div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
<div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3"> {/* Header */}
<button onClick={() => navigate("/")} className="text-anton-muted hover:text-white"><ArrowLeft size={20} /></button> <div className="flex items-center gap-4">
<ShieldCheck size={20} className="text-anton-accent" /> <button onClick={() => navigate("/")} className="p-2 rounded-lg bg-anton-surface border border-anton-border hover:border-anton-accent transition"><ArrowLeft size={20} /></button>
<h1 className="text-white font-semibold">Admin Dashboard</h1> <div>
<button onClick={() => { load(); loadSettings(); }} className="ml-auto text-anton-muted hover:text-white"><RefreshCw size={16} /></button> <h1 className="text-2xl font-bold text-white flex items-center gap-2"><Shield size={24} className="text-anton-accent" /> Admin Panel</h1>
</div> <p className="text-anton-muted text-sm">Users, permissions & cost control</p>
</div>
{error && (
<div className="mx-4 mt-2 bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 text-red-400 text-sm">
{error} <button onClick={() => setError("")} className="ml-2 underline">dismiss</button>
</div> </div>
)}
{/* Tabs */} {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>)}
<div className="flex gap-1 px-4 pt-3">
{[
{ id: "stats", label: "Stats", icon: Zap },
{ id: "settings", label: "Settings", icon: Settings },
{ id: "users", label: "Users", icon: Users },
{ id: "chats", label: "Chats", icon: MessageSquare },
].map((t) => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm transition ${tab === t.id ? "bg-anton-accent text-white" : "text-anton-muted hover:text-white hover:bg-anton-card"}`}>
<t.icon size={14} />{t.label}
</button>
))}
</div>
{/* Content */} {/* Stats */}
<div className="flex-1 overflow-y-auto p-4"> {stats && (
{/* ── STATS TAB ── */} <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{tab === "stats" && stats && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[ {[
{ label: "Users", value: stats.total_users, icon: Users, color: "text-blue-400" }, { label: "Users", value: stats.total_users, icon: Users, color: "text-blue-400" },
{ label: "Active", value: stats.active_users, icon: ShieldCheck, color: "text-green-400" }, { label: "Chats", value: stats.total_chats, icon: MessageSquare, color: "text-green-400" },
{ label: "Chats", value: stats.total_chats, icon: MessageSquare, color: "text-purple-400" }, { label: "Messages", value: formatNum(stats.total_messages), icon: Zap, color: "text-anton-accent" },
{ label: "Messages", value: stats.total_messages, icon: MessageSquare, color: "text-yellow-400" }, { label: "Tokens Used", value: formatNum(stats.total_tokens_used), icon: Database, color: "text-purple-400" },
{ label: "Tokens Used", value: (stats.total_tokens_used || 0).toLocaleString(), icon: Zap, color: "text-red-400" }, ].map((s) => (
{ label: "Knowledge Bases", value: stats.total_knowledge_bases, icon: Database, color: "text-cyan-400" }, <div key={s.label} className="bg-anton-surface border border-anton-border rounded-xl p-4">
].map((s, i) => ( <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 key={i} className="bg-anton-card border border-anton-border rounded-xl p-4"> <p className="text-2xl font-bold text-white">{s.value}</p>
<div className="flex items-center gap-2 mb-2">
<s.icon size={16} className={s.color} />
<span className="text-xs text-anton-muted">{s.label}</span>
</div>
<div className="text-2xl font-bold text-white">{s.value}</div>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* ── SETTINGS TAB ── */} {/* User Management */}
{tab === "settings" && ( <div className="bg-anton-surface border border-anton-border rounded-xl overflow-hidden">
<div className="space-y-4 max-w-lg"> <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 flex items-center gap-2"> <h2 className="text-lg font-semibold text-white">Users</h2>
<Settings size={18} className="text-anton-accent" /> App Settings <div className="flex items-center gap-2">
</h2> <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
<div className="bg-anton-card border border-anton-border rounded-xl p-5"> </button>
<div className="flex items-center justify-between"> <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">
<div> {applyingDefaults ? <Loader2 size={14} className="animate-spin" /> : <Copy size={14} />} Apply to All
<h3 className="text-white font-medium flex items-center gap-2"> </button>
<UserPlus size={16} className="text-blue-400" /> <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">
User Registration {showCreate ? <X size={14} /> : <UserPlus size={14} />} {showCreate ? "Cancel" : "New User"}
</h3> </button>
<p className="text-anton-muted text-sm mt-1">
{appSettings.allow_registration
? "Anyone can create an account on the login page."
: "Registration is disabled. Only admins can create users."}
</p>
</div>
<button
onClick={toggleRegistration}
disabled={settingsLoading}
className={`shrink-0 transition ${settingsLoading ? "opacity-50" : "hover:opacity-80"}`}
title={appSettings.allow_registration ? "Click to disable" : "Click to enable"}
>
{appSettings.allow_registration ? (
<ToggleRight size={40} className="text-green-400" />
) : (
<ToggleLeft size={40} className="text-anton-muted" />
)}
</button>
</div>
</div> </div>
</div> </div>
)}
{/* ── USERS TAB ── */} {showCreate && (
{tab === "users" && ( <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">
<div className="space-y-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" />
<div className="flex items-center justify-between"> <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" />
<h2 className="text-lg font-semibold text-white">Users ({users.length})</h2> <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" />
<button onClick={() => setShowCreate(!showCreate)} <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">
className="flex items-center gap-1.5 bg-anton-accent text-white px-3 py-1.5 rounded-lg text-sm hover:opacity-80"> <option value="user">User</option><option value="admin">Admin</option><option value="superadmin">Superadmin</option>
<Plus size={14} /> Create User </select>
</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" />
</div> <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>
)}
{showCreate && ( <div className="overflow-x-auto">
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3 animate-fade-in"> <table className="w-full text-sm">
<h3 className="text-sm font-semibold text-white">New User</h3> <thead><tr className="text-left text-anton-muted border-b border-anton-border">
<div className="grid grid-cols-2 gap-3"> <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>
<input placeholder="Username" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} </tr></thead>
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" /> <tbody>
<input placeholder="Email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} {users.map((u) => (
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" /> <tr key={u.id} className="border-b border-anton-border/50 hover:bg-anton-card/50 transition">
<input placeholder="Password" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} <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>
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" /> <td className="px-5 py-3">
<select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })} {editId === u.id ? (
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={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="user">user</option><option value="admin">admin</option><option value="superadmin">superadmin</option>
<option value="admin">Admin</option> </select>
</select> ) : (
</div> <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>
<div className="flex gap-2"> )}
<button onClick={handleCreateUser} className="bg-anton-accent text-white px-4 py-1.5 rounded-lg text-sm hover:opacity-80">Create</button> </td>
<button onClick={() => setShowCreate(false)} className="text-anton-muted text-sm hover:text-white">Cancel</button> <td className="px-5 py-3 text-anton-muted">
</div> {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" : "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></>
) : (
<>
{u.role !== "superadmin" && (
<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>
</td>
</tr>
))}
</tbody>
</table>
</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> </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>
<div className="space-y-2"> {permsLoading ? (
{users.map((u) => ( <div className="flex items-center justify-center py-20"><Loader2 size={24} className="text-anton-accent animate-spin" /></div>
<div key={u.id} className="bg-anton-card border border-anton-border rounded-xl p-4 flex items-center gap-4"> ) : permsData ? (
<div className="flex-1 min-w-0"> <div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto">
<div className="flex items-center gap-2"> {/* Feature Access */}
<span className="text-white font-medium">{u.username}</span> <div>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${u.role === "superadmin" ? "bg-red-500/20 text-red-400" : u.role === "admin" ? "bg-yellow-500/20 text-yellow-400" : "bg-blue-500/20 text-blue-400"}`}> <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>
{u.role} <div className="space-y-2">
</span> {FEATURE_DEFS.map(f => {
{!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">disabled</span>} const Icon = f.icon;
</div> return (
<div className="text-xs text-anton-muted mt-0.5">{u.email} · {u.chat_count} chats · {(u.tokens_used_this_month || 0).toLocaleString()} tokens used</div> <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>
<div className="flex items-center gap-1"> </div>
{u.role !== "superadmin" && (
<> {/* Model Access */}
<button onClick={() => handleUpdateUser(u.id, { is_active: !u.is_active })} <div>
className={`p-1.5 rounded-lg transition ${u.is_active ? "text-green-400 hover:bg-green-500/10" : "text-red-400 hover:bg-red-500/10"}`} <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>
title={u.is_active ? "Disable" : "Enable"}> <div className="space-y-2">
{u.is_active ? <ShieldCheck size={16} /> : <ShieldX size={16} />} <label className="flex items-center gap-3 bg-anton-card rounded-lg px-4 py-3 border border-anton-border cursor-pointer">
</button> <input type="checkbox" checked={permsData.allowed_models === "all"} onChange={e => updatePerm("allowed_models", e.target.checked ? "all" : MODELS.map(m => m.id).join(","))}
<button onClick={() => handleDeleteUser(u.id)} className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition" title="Delete"> className="w-4 h-4 rounded accent-anton-accent" />
<Trash2 size={16} /> <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>
</button> </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>
</div> </div>
))}
</div>
</div>
)}
{/* ── CHATS TAB ── */} {/* Limits */}
{tab === "chats" && ( <div>
<div className="space-y-2"> <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>
<h2 className="text-lg font-semibold text-white">Recent Chats ({chats.length})</h2> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{chats.map((c) => ( {LIMIT_DEFS.map(l => (
<div key={c.id} className="bg-anton-card border border-anton-border rounded-xl p-3 flex items-center gap-3"> <div key={l.key} className="bg-anton-card rounded-lg px-4 py-3 border border-anton-border">
<div className="flex-1 min-w-0"> <div className="flex justify-between items-center mb-1">
<div className="text-white text-sm truncate">{c.title}</div> <label className="text-xs text-anton-muted">{l.label}</label>
<div className="text-xs text-anton-muted">{c.username} · {c.message_count} msgs · {c.updated_at}</div> <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>
</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>
</div> )}
</div> </div>
); );
} }
\ No newline at end of file
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { login, register, getPublicConfig } from "../api"; import { login, register } from "../api";
import { Flame, Eye, EyeOff, Loader2 } from "lucide-react"; import { Flame, Eye, EyeOff, Loader2 } from "lucide-react";
export default function LoginPage() { export default function LoginPage() {
...@@ -12,119 +12,110 @@ export default function LoginPage() { ...@@ -12,119 +12,110 @@ export default function LoginPage() {
const [showPw, setShowPw] = useState(false); const [showPw, setShowPw] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [allowRegistration, setAllowRegistration] = useState(true);
const [configLoaded, setConfigLoaded] = useState(false);
useEffect(() => {
(async () => {
try {
const cfg = await getPublicConfig();
setAllowRegistration(cfg.allow_registration);
} catch {
// If config fetch fails, default to allowing registration
setAllowRegistration(true);
}
setConfigLoaded(true);
})();
}, []);
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
let res; const res = isRegister
if (isRegister) { ? await register(username, email, password)
if (!email) { setError("Email is required"); setLoading(false); return; } : await login(username, password);
res = await register(username, email, password); dispatch({ type: "LOGIN", token: res.token, user: res.user });
} else {
res = await login(username, password);
}
dispatch({ type: "SET_TOKEN", token: res.token });
dispatch({ type: "SET_USER", user: res.user });
} catch (err) { } catch (err) {
setError(err.message); setError(err.message || "Authentication failed");
} finally {
setLoading(false);
} }
setLoading(false);
}
if (!configLoaded) {
return (
<div className="h-dvh flex items-center justify-center bg-anton-bg">
<Loader2 className="animate-spin text-anton-accent" size={32} />
</div>
);
} }
return ( return (
<div className="h-dvh flex items-center justify-center bg-anton-bg px-4"> <div className="h-full h-dvh flex items-center justify-center bg-anton-bg px-4 safe-top safe-bottom">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8"> {/* Logo */}
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20 mb-4"> <div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-anton-accent to-red-600 flex items-center justify-center shadow-lg shadow-anton-accent/20">
<Flame size={32} className="text-white" /> <Flame size={32} className="text-white" />
</div> </div>
<h1 className="text-2xl font-bold text-white">Son of Anton</h1> <h1 className="text-2xl font-bold text-white">Son of Anton</h1>
<p className="text-anton-muted text-sm mt-1">Avatar of All Elements of Code</p> <p className="text-anton-muted text-sm mt-1">Avatar of All Elements of Code</p>
</div> </div>
<form onSubmit={handleSubmit} className="bg-anton-card border border-anton-border rounded-2xl p-6 space-y-4"> {/* Form */}
<h2 className="text-lg font-semibold text-white text-center"> <form onSubmit={handleSubmit} className="space-y-4">
{isRegister ? "Create Account" : "Sign In"}
</h2>
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 text-red-400 text-sm">
{error}
</div>
)}
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">Username</label> <label className="text-xs text-anton-muted mb-1.5 block">Username</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required autoFocus <input
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent" /> type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white focus:outline-none focus:border-anton-accent transition"
placeholder="Enter username"
required
autoComplete="username"
autoCapitalize="off"
/>
</div> </div>
{isRegister && ( {isRegister && (
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">Email</label> <label className="text-xs text-anton-muted mb-1.5 block">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required <input
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent" /> type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 text-white focus:outline-none focus:border-anton-accent transition"
placeholder="your@email.com"
required
autoComplete="email"
/>
</div> </div>
)} )}
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">Password</label> <label className="text-xs text-anton-muted mb-1.5 block">Password</label>
<div className="relative"> <div className="relative">
<input type={showPw ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} required <input
className="w-full bg-anton-bg border border-anton-border rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-anton-accent pr-10" /> type={showPw ? "text" : "password"}
<button type="button" onClick={() => setShowPw(!showPw)} value={password}
className="absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white"> onChange={(e) => setPassword(e.target.value)}
{showPw ? <EyeOff size={16} /> : <Eye size={16} />} className="w-full bg-anton-card border border-anton-border rounded-xl px-4 py-3 pr-12 text-white focus:outline-none focus:border-anton-accent transition"
placeholder="••••••••"
required
autoComplete={isRegister ? "new-password" : "current-password"}
/>
<button
type="button"
onClick={() => setShowPw(!showPw)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white transition p-1"
>
{showPw ? <EyeOff size={18} /> : <Eye size={18} />}
</button> </button>
</div> </div>
</div> </div>
<button type="submit" disabled={loading} {error && (
className="w-full bg-anton-accent text-white rounded-lg py-2.5 text-sm font-medium hover:opacity-90 transition disabled:opacity-50 flex items-center justify-center gap-2"> <div className="bg-anton-danger/10 border border-anton-danger/30 text-anton-danger text-sm rounded-lg px-3 py-2.5">
{loading && <Loader2 size={16} className="animate-spin" />} {error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3.5 bg-anton-accent text-white rounded-xl font-semibold hover:opacity-90 transition disabled:opacity-50 active:scale-[0.98] flex items-center justify-center gap-2"
>
{loading && <Loader2 size={18} className="animate-spin" />}
{isRegister ? "Create Account" : "Sign In"} {isRegister ? "Create Account" : "Sign In"}
</button> </button>
{allowRegistration && ( <button
<p className="text-center text-sm text-anton-muted"> type="button"
{isRegister ? "Already have an account?" : "Don't have an account?"}{" "} onClick={() => { setIsRegister(!isRegister); setError(""); }}
<button type="button" onClick={() => { setIsRegister(!isRegister); setError(""); }} className="w-full text-center text-sm text-anton-muted hover:text-white transition py-2"
className="text-anton-accent hover:underline"> >
{isRegister ? "Sign In" : "Register"} {isRegister ? "Already have an account? Sign in" : "Need an account? Register"}
</button> </button>
</p>
)}
{!allowRegistration && !isRegister && (
<p className="text-center text-xs text-anton-muted">
Registration is disabled. Contact your administrator.
</p>
)}
</form> </form>
</div> </div>
</div> </div>
......
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