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():
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")
# ── 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"]:
if table_name not in existing_tables:
print(f" Creating {table_name} table")
......
......@@ -54,6 +54,7 @@ class UserPermissions(Base):
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)
......@@ -62,8 +63,10 @@ class UserPermissions(Base):
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)
......@@ -78,18 +81,6 @@ class UserPermissions(Base):
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):
__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
......@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import func
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 (
require_superadmin, hash_password, get_user_permissions,
ensure_user_permissions, get_default_permissions_template,
......@@ -55,10 +55,6 @@ class PermissionsBody(BaseModel):
max_attachments_per_message: Optional[int] = None
class AppSettingsBody(BaseModel):
allow_registration: Optional[bool] = None
# ═══════════════════════════════════════════════════
# Stats & Users
# ═══════════════════════════════════════════════════
......@@ -111,6 +107,7 @@ 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}
......@@ -163,38 +160,6 @@ def list_all_chats(admin: User = Depends(require_superadmin), db: Session = Depe
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
# ═══════════════════════════════════════════════════
......
"""
Authentication routes: register, login, profile — with permissions and registration toggle.
Authentication routes: register, login, profile — with permissions.
"""
from pydantic import BaseModel
......@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, AppSettings
from backend.models import User
from backend.auth import (
hash_password, verify_password, create_token, get_current_user,
get_user_permissions, ensure_user_permissions,
......@@ -28,24 +28,8 @@ class LoginBody(BaseModel):
password: str
class ProfileOut(BaseModel):
pass
class Config(BaseModel):
pass
@router.post("/register")
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(
(User.username == body.username) | (User.email == body.email)
).first():
......@@ -88,16 +72,6 @@ def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
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:
d = {
"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) {
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
function authHeader(token) {
return token ? { Authorization: `Bearer ${token}` } : {};
}
function authHeader(token) { return token ? { Authorization: `Bearer ${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); }
async function request(method, path, token, body) {
const opts = { method, headers: headers(token) };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || err.message || "Request failed");
}
if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "Request failed")); }
return res.json();
}
export const login = (username, password) =>
request("POST", "/auth/login", null, { username, password });
export const register = (username, email, password) =>
request("POST", "/auth/register", null, { username, email, password });
export const getMe = (token) => request("GET", "/auth/me", token);
export const getPublicConfig = () => request("GET", "/auth/config", null);
export const listChats = (token) => request("GET", "/chats", token);
export const createChat = (token, data = {}) => request("POST", "/chats", token, data);
export const updateChat = (token, chatId, data) =>
request("PUT", `/chats/${chatId}`, token, data);
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);
// Auth
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 getMe = (t) => request("GET", "/auth/me", t);
// Chats
export const listChats = (t) => request("GET", "/chats", t);
export const createChat = (t, d = {}) => request("POST", "/chats", t, d);
export const updateChat = (t, id, d) => request("PUT", `/chats/${id}`, t, d);
export const renameChat = (t, id, title) => updateChat(t, id, { title });
export const deleteChat = (t, id) => request("DELETE", `/chats/${id}`, t);
export const getMessages = (t, id) => request("GET", `/chats/${id}/messages`, t);
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 commitFromChat = (t, id, d) => request("POST", `/chats/${id}/commit`, t, d);
// Streaming
export async function* streamMessage(token, chatId, body, signal) {
const res = await fetch(`${BASE}/chats/${chatId}/messages`, {
method: "POST", headers: headers(token),
body: JSON.stringify(body), signal,
});
if (!res.ok) {
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();
const res = await fetch(`${BASE}/chats/${chatId}/messages`, { method: "POST", headers: headers(token), body: JSON.stringify(body), signal });
if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(extractError(err, "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 function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`;
// Attachments
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) =>
request("DELETE", `/attachments/${attachmentId}`, token);
export const listKnowledgeBases = (token) => request("GET", "/knowledge", token);
export const createKnowledgeBase = (token, name, description = "") =>
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 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) { 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);
}
export const uploadDocument = (token, kbId, file) =>
uploadDocuments(token, kbId, [file]);
export const adminStats = (token) => request("GET", "/admin/stats", token);
export const adminListUsers = (token) => request("GET", "/admin/users", token);
export const adminCreateUser = (token, data) =>
request("POST", "/admin/users", token, data);
export const adminUpdateUser = (token, userId, data) =>
request("PUT", `/admin/users/${userId}`, token, data);
export const adminDeleteUser = (token, userId) =>
request("DELETE", `/admin/users/${userId}`, token);
export const adminListChats = (token) => request("GET", "/admin/chats", token);
export const adminGetSettings = (token) => request("GET", "/admin/settings", token);
export const adminUpdateSettings = (token, data) =>
request("PUT", "/admin/settings", token, data);
export async function downloadZip(token, markdown) {
const res = await fetch(`${BASE}/files/download-zip`, {
method: "POST", headers: headers(token),
body: JSON.stringify({ markdown }),
});
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
// 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
import React, { useState, useEffect, useCallback } from "react";
import { useApp } from "../store";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store";
import {
adminStats, adminListUsers, adminCreateUser, adminUpdateUser,
adminDeleteUser, adminListChats, adminGetSettings, adminUpdateSettings,
adminStats, adminListUsers, adminCreateUser, adminUpdateUser, adminDeleteUser,
adminGetUserPermissions, adminUpdateUserPermissions,
adminGetDefaultPermissions, adminUpdateDefaultPermissions, adminApplyDefaults,
} from "../api";
import {
ArrowLeft, Users, MessageSquare, Database, Zap, Plus, Trash2, Edit,
Shield, ShieldCheck, ShieldX, RefreshCw, ToggleLeft, ToggleRight,
Settings, UserPlus,
ArrowLeft, Users, MessageSquare, Database, Zap,
UserPlus, Trash2, Shield, ShieldOff, Save, X,
Settings2, Check, Globe, Paintbrush, BookOpen, GitBranch,
Paperclip, Presentation, FileOutput, Cpu, Gauge, Lock,
Loader2, RotateCcw, Copy,
} from "lucide-react";
const MODELS = [
{ id: "eu.anthropic.claude-opus-4-6-v1", label: "Opus 4.6", tier: "💰 Expensive" },
{ id: "eu.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Haiku 4.5", tier: "⚡ Cheap" },
];
const FEATURE_DEFS = [
{ key: "can_use_web_search", label: "Web Search", icon: Globe, color: "text-green-400", desc: "SerpAPI calls — costs money per search" },
{ key: "can_use_ui_design", label: "UI Design Mode", icon: Paintbrush, color: "text-blue-400", desc: "Generates full HTML — uses more tokens" },
{ key: "can_use_knowledge_base", label: "Knowledge Bases", icon: BookOpen, color: "text-emerald-400", desc: "RAG document upload & query" },
{ key: "can_use_gitlab", label: "GitLab Access", icon: GitBranch, color: "text-orange-400", desc: "Repository linking & commits" },
{ key: "can_use_attachments", label: "File Attachments", icon: Paperclip, color: "text-cyan-400", desc: "Upload images, videos, documents" },
{ key: "can_export_pptx", label: "Export PPTX", icon: Presentation, color: "text-amber-400", desc: "Download as PowerPoint" },
{ key: "can_export_docx", label: "Export DOCX", icon: FileOutput, color: "text-indigo-400", desc: "Download as Word document" },
];
const LIMIT_DEFS = [
{ key: "max_tokens_cap", label: "Max Output Tokens", min: 256, max: 65536, step: 256, desc: "Maximum output tokens user can set" },
{ key: "max_reasoning_budget", label: "Max Reasoning Budget", min: 0, max: 32000, step: 500, desc: "0 = reasoning disabled" },
{ key: "max_chats", label: "Max Chats", min: 0, max: 1000, step: 1, desc: "0 = unlimited" },
{ key: "max_messages_per_day", label: "Messages / Day", min: 0, max: 10000, step: 10, desc: "0 = unlimited" },
{ key: "max_knowledge_bases", label: "Max Knowledge Bases", min: 0, max: 100, step: 1, desc: "0 = unlimited" },
{ key: "max_documents_per_kb", label: "Max Docs per KB", min: 0, max: 500, step: 5, desc: "0 = unlimited" },
{ key: "max_attachment_size_mb", label: "Attachment Size (MB)", min: 1, max: 100, step: 1, desc: "Per-file upload limit" },
{ key: "max_attachments_per_message", label: "Attachments / Message", min: 1, max: 50, step: 1, desc: "Files per single message" },
];
function Toggle({ value, onChange }) {
return (
<button onClick={() => onChange(!value)} className={`w-10 h-5 rounded-full transition-colors relative ${value ? "bg-green-500" : "bg-anton-border"}`}>
<div className={`w-4 h-4 rounded-full bg-white shadow absolute top-0.5 transition-transform ${value ? "translate-x-5.5 left-0.5" : "left-0.5"}`} style={{ transform: value ? "translateX(20px)" : "translateX(0)" }} />
</button>
);
}
export default function AdminPage() {
const { state } = useApp();
const navigate = useNavigate();
const [stats, setStats] = useState(null);
const [users, setUsers] = useState([]);
const [chats, setChats] = useState([]);
const [tab, setTab] = useState("stats");
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 [editingUser, setEditingUser] = useState(null);
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 () => {
try {
const [s, u, c] = await Promise.all([
adminStats(state.token),
adminListUsers(state.token),
adminListChats(state.token),
]);
setStats(s);
setUsers(u);
setChats(c);
} catch (err) {
setError(err.message);
}
const [s, u] = await Promise.all([adminStats(state.token), adminListUsers(state.token)]);
setStats(s); setUsers(u);
} catch (err) { setError(err.message); }
}, [state.token]);
const loadSettings = useCallback(async () => {
try {
const s = await adminGetSettings(state.token);
setAppSettings(s);
} catch { }
}, [state.token]);
useEffect(() => { load(); }, [load]);
async function handleCreate(e) {
e.preventDefault();
try { await adminCreateUser(state.token, newUser); setShowCreate(false); setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 }); load(); } catch (err) { setError(err.message); }
}
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() {
try {
await adminCreateUser(state.token, newUser);
setShowCreate(false);
setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
load();
} catch (err) { setError(err.message); }
async function handleDelete(userId, username) {
if (!confirm(`Delete user "${username}"? This is permanent.`)) return;
try { await adminDeleteUser(state.token, userId); load(); } catch (err) { setError(err.message); }
}
async function handleUpdateUser(userId, data) {
// ═══ Permissions ═══
async function openPerms(user) {
setPermsUser(user); setPermsLoading(true); setPermsData(null);
try {
await adminUpdateUser(state.token, userId, data);
setEditingUser(null);
load();
} catch (err) { setError(err.message); }
const data = user.id === "__defaults__"
? await adminGetDefaultPermissions(state.token)
: await adminGetUserPermissions(state.token, user.id);
setPermsData(data);
} catch (err) { setError(err.message); setPermsUser(null); }
setPermsLoading(false);
}
async function handleDeleteUser(userId) {
if (!confirm("Delete this user and all their data?")) return;
async function savePerms() {
if (!permsUser || !permsData) return;
setPermsSaving(true);
try {
await adminDeleteUser(state.token, userId);
load();
if (permsUser.id === "__defaults__") {
await adminUpdateDefaultPermissions(state.token, permsData);
} else {
await adminUpdateUserPermissions(state.token, permsUser.id, permsData);
}
setPermsUser(null); setPermsData(null);
} catch (err) { setError(err.message); }
setPermsSaving(false);
}
async function toggleRegistration() {
setSettingsLoading(true);
async function handleApplyDefaults() {
if (!confirm("Apply default permissions to ALL regular users? This will overwrite their current permissions.")) return;
setApplyingDefaults(true);
try {
const res = await adminUpdateSettings(state.token, {
allow_registration: !appSettings.allow_registration,
});
setAppSettings(res);
const res = await adminApplyDefaults(state.token);
alert(`✅ Applied to ${res.users_updated} users`);
} 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") {
return (
<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 <div className="h-full flex items-center justify-center"><p className="text-anton-danger text-lg">⛔ Access Denied</p></div>;
}
return (
<div className="h-dvh flex flex-col bg-anton-bg">
{/* Header */}
<div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate("/")} className="text-anton-muted hover:text-white"><ArrowLeft size={20} /></button>
<ShieldCheck size={20} className="text-anton-accent" />
<h1 className="text-white font-semibold">Admin Dashboard</h1>
<button onClick={() => { load(); loadSettings(); }} className="ml-auto text-anton-muted hover:text-white"><RefreshCw size={16} /></button>
</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 className="h-full overflow-y-auto bg-anton-bg p-4 sm:p-6">
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={() => navigate("/")} className="p-2 rounded-lg bg-anton-surface border border-anton-border hover:border-anton-accent transition"><ArrowLeft size={20} /></button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2"><Shield size={24} className="text-anton-accent" /> Admin Panel</h1>
<p className="text-anton-muted text-sm">Users, permissions & cost control</p>
</div>
</div>
)}
{/* Tabs */}
<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>
{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>)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{/* ── STATS TAB ── */}
{tab === "stats" && stats && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ 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-purple-400" },
{ label: "Messages", value: stats.total_messages, icon: MessageSquare, color: "text-yellow-400" },
{ label: "Tokens Used", value: (stats.total_tokens_used || 0).toLocaleString(), icon: Zap, color: "text-red-400" },
{ label: "Knowledge Bases", value: stats.total_knowledge_bases, icon: Database, color: "text-cyan-400" },
].map((s, i) => (
<div key={i} className="bg-anton-card border border-anton-border rounded-xl p-4">
<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>
{ label: "Chats", value: stats.total_chats, icon: MessageSquare, color: "text-green-400" },
{ label: "Messages", value: formatNum(stats.total_messages), icon: Zap, color: "text-anton-accent" },
{ label: "Tokens Used", value: formatNum(stats.total_tokens_used), icon: Database, color: "text-purple-400" },
].map((s) => (
<div key={s.label} className="bg-anton-surface border border-anton-border rounded-xl p-4">
<div className="flex items-center gap-2 mb-1"><s.icon size={16} className={s.color} /><span className="text-anton-muted text-sm">{s.label}</span></div>
<p className="text-2xl font-bold text-white">{s.value}</p>
</div>
))}
</div>
)}
{/* ── SETTINGS TAB ── */}
{tab === "settings" && (
<div className="space-y-4 max-w-lg">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Settings size={18} className="text-anton-accent" /> App Settings
</h2>
<div className="bg-anton-card border border-anton-border rounded-xl p-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-white font-medium flex items-center gap-2">
<UserPlus size={16} className="text-blue-400" />
User Registration
</h3>
<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>
{/* User Management */}
<div className="bg-anton-surface border border-anton-border rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-anton-border flex items-center justify-between flex-wrap gap-2">
<h2 className="text-lg font-semibold text-white">Users</h2>
<div className="flex items-center gap-2">
<button onClick={() => openPerms({ id: "__defaults__", username: "Default Template" })} className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-500/20 text-purple-400 border border-purple-500/30 rounded-lg text-sm font-medium hover:bg-purple-500/30 transition" title="Edit default permissions for new users">
<Lock size={14} /> Defaults
</button>
<button onClick={handleApplyDefaults} disabled={applyingDefaults} className="flex items-center gap-1.5 px-3 py-1.5 bg-anton-card border border-anton-border text-anton-muted rounded-lg text-sm hover:text-white transition disabled:opacity-50">
{applyingDefaults ? <Loader2 size={14} className="animate-spin" /> : <Copy size={14} />} Apply to All
</button>
<button onClick={() => setShowCreate(!showCreate)} className="flex items-center gap-1.5 px-3 py-1.5 bg-anton-accent text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
{showCreate ? <X size={14} /> : <UserPlus size={14} />} {showCreate ? "Cancel" : "New User"}
</button>
</div>
</div>
)}
{/* ── USERS TAB ── */}
{tab === "users" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Users ({users.length})</h2>
<button onClick={() => setShowCreate(!showCreate)}
className="flex items-center gap-1.5 bg-anton-accent text-white px-3 py-1.5 rounded-lg text-sm hover:opacity-80">
<Plus size={14} /> Create User
</button>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="px-5 py-4 border-b border-anton-border bg-anton-card grid grid-cols-1 md:grid-cols-3 gap-3">
<input placeholder="Username" required value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Email" type="email" required value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Password" required value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="user">User</option><option value="admin">Admin</option><option value="superadmin">Superadmin</option>
</select>
<input placeholder="Monthly quota" type="number" value={newUser.quota_tokens_monthly} onChange={(e) => setNewUser({ ...newUser, quota_tokens_monthly: Number(e.target.value) })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<button type="submit" className="bg-anton-success text-white rounded-lg px-3 py-2 text-sm font-medium hover:opacity-90">Create</button>
</form>
)}
{showCreate && (
<div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3 animate-fade-in">
<h3 className="text-sm font-semibold text-white">New User</h3>
<div className="grid grid-cols-2 gap-3">
<input placeholder="Username" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Password" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<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>
<button onClick={() => setShowCreate(false)} className="text-anton-muted text-sm hover:text-white">Cancel</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-anton-muted border-b border-anton-border">
<th className="px-5 py-3">User</th><th className="px-5 py-3">Role</th><th className="px-5 py-3">Quota</th><th className="px-5 py-3">Used</th><th className="px-5 py-3">Chats</th><th className="px-5 py-3">Status</th><th className="px-5 py-3">Actions</th>
</tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-anton-border/50 hover:bg-anton-card/50 transition">
<td className="px-5 py-3"><div className="text-white font-medium">{u.username}</div><div className="text-anton-muted text-xs">{u.email}</div></td>
<td className="px-5 py-3">
{editId === u.id ? (
<select value={editData.role ?? u.role} onChange={(e) => setEditData({ ...editData, role: e.target.value })} className="bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs">
<option value="user">user</option><option value="admin">admin</option><option value="superadmin">superadmin</option>
</select>
) : (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${u.role === "superadmin" ? "bg-anton-accent/20 text-anton-accent" : u.role === "admin" ? "bg-blue-500/20 text-blue-400" : "bg-anton-border text-anton-muted"}`}>{u.role}</span>
)}
</td>
<td className="px-5 py-3 text-anton-muted">
{editId === u.id ? <input type="number" value={editData.quota_tokens_monthly ?? u.quota_tokens_monthly} onChange={(e) => setEditData({ ...editData, quota_tokens_monthly: Number(e.target.value) })} className="bg-anton-bg border border-anton-border rounded px-2 py-1 text-white text-xs w-28" /> : formatNum(u.quota_tokens_monthly)}
</td>
<td className="px-5 py-3 text-anton-muted">{formatNum(u.tokens_used_this_month)}</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>
)}
<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">
{users.map((u) => (
<div key={u.id} className="bg-anton-card border border-anton-border rounded-xl p-4 flex items-center gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-white font-medium">{u.username}</span>
<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"}`}>
{u.role}
</span>
{!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">disabled</span>}
</div>
<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>
{permsLoading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="text-anton-accent animate-spin" /></div>
) : permsData ? (
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto">
{/* Feature Access */}
<div>
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2"><Lock size={14} className="text-anton-accent" /> Feature Access</h3>
<div className="space-y-2">
{FEATURE_DEFS.map(f => {
const Icon = f.icon;
return (
<div key={f.key} className="flex items-center justify-between bg-anton-card rounded-lg px-4 py-3 border border-anton-border">
<div className="flex items-center gap-3">
<Icon size={16} className={f.color} />
<div>
<p className="text-sm text-white font-medium">{f.label}</p>
<p className="text-[10px] text-anton-muted">{f.desc}</p>
</div>
</div>
<Toggle value={!!permsData[f.key]} onChange={v => updatePerm(f.key, v)} />
</div>
);
})}
</div>
<div className="flex items-center gap-1">
{u.role !== "superadmin" && (
<>
<button onClick={() => handleUpdateUser(u.id, { is_active: !u.is_active })}
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"}`}
title={u.is_active ? "Disable" : "Enable"}>
{u.is_active ? <ShieldCheck size={16} /> : <ShieldX size={16} />}
</button>
<button onClick={() => handleDeleteUser(u.id)} className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition" title="Delete">
<Trash2 size={16} />
</button>
</>
</div>
{/* Model Access */}
<div>
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2"><Cpu size={14} className="text-cyan-400" /> Model Access</h3>
<div className="space-y-2">
<label className="flex items-center gap-3 bg-anton-card rounded-lg px-4 py-3 border border-anton-border cursor-pointer">
<input type="checkbox" checked={permsData.allowed_models === "all"} onChange={e => updatePerm("allowed_models", e.target.checked ? "all" : MODELS.map(m => m.id).join(","))}
className="w-4 h-4 rounded accent-anton-accent" />
<div><p className="text-sm text-white font-medium">All Models</p><p className="text-[10px] text-anton-muted">Access to every available model</p></div>
</label>
{permsData.allowed_models !== "all" && (
<div className="pl-4 space-y-1.5">
{MODELS.map(m => {
const list = (permsData.allowed_models || "").split(",").map(s => s.trim()).filter(Boolean);
const checked = list.includes(m.id);
return (
<label key={m.id} className="flex items-center gap-3 bg-anton-bg rounded-lg px-3 py-2 border border-anton-border/50 cursor-pointer">
<input type="checkbox" checked={checked} onChange={e => {
let next = checked ? list.filter(x => x !== m.id) : [...list, m.id];
if (!next.length) next = [m.id]; // Can't have zero models
updatePerm("allowed_models", next.join(","));
}} className="w-4 h-4 rounded accent-anton-accent" />
<div><span className="text-sm text-white">{m.label}</span><span className="text-[10px] text-anton-muted ml-2">{m.tier}</span></div>
</label>
);
})}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{/* ── CHATS TAB ── */}
{tab === "chats" && (
<div className="space-y-2">
<h2 className="text-lg font-semibold text-white">Recent Chats ({chats.length})</h2>
{chats.map((c) => (
<div key={c.id} className="bg-anton-card border border-anton-border rounded-xl p-3 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="text-white text-sm truncate">{c.title}</div>
<div className="text-xs text-anton-muted">{c.username} · {c.message_count} msgs · {c.updated_at}</div>
{/* Limits */}
<div>
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2"><Gauge size={14} className="text-amber-400" /> Usage Limits</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{LIMIT_DEFS.map(l => (
<div key={l.key} className="bg-anton-card rounded-lg px-4 py-3 border border-anton-border">
<div className="flex justify-between items-center mb-1">
<label className="text-xs text-anton-muted">{l.label}</label>
<span className="text-xs text-white font-mono">{(permsData[l.key] || 0) === 0 && l.desc?.includes("unlimited") ? "∞" : (permsData[l.key] || 0).toLocaleString()}</span>
</div>
<input type="number" min={l.min} max={l.max} step={l.step} value={permsData[l.key] || 0}
onChange={e => updatePerm(l.key, Math.min(Math.max(Number(e.target.value) || 0, l.min), l.max))}
className="w-full bg-anton-bg border border-anton-border rounded px-2.5 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-anton-accent"
/>
<p className="text-[9px] text-anton-muted mt-1">{l.desc}</p>
</div>
))}
</div>
</div>
</div>
))}
) : null}
{/* Modal Footer */}
{permsData && (
<div className="px-6 py-4 border-t border-anton-border flex items-center justify-between">
<button onClick={() => { setPermsUser(null); setPermsData(null); }} className="px-4 py-2 rounded-lg border border-anton-border text-anton-muted hover:text-white transition text-sm">Cancel</button>
<button onClick={savePerms} disabled={permsSaving} className="flex items-center gap-2 px-5 py-2 bg-anton-accent text-white rounded-lg text-sm font-semibold hover:opacity-80 transition disabled:opacity-50">
{permsSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Save Permissions
</button>
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
);
}
\ No newline at end of file
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import { useApp } from "../store";
import { login, register, getPublicConfig } from "../api";
import { login, register } from "../api";
import { Flame, Eye, EyeOff, Loader2 } from "lucide-react";
export default function LoginPage() {
......@@ -12,119 +12,110 @@ export default function LoginPage() {
const [showPw, setShowPw] = useState(false);
const [error, setError] = useState("");
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) {
e.preventDefault();
setError("");
setLoading(true);
try {
let res;
if (isRegister) {
if (!email) { setError("Email is required"); setLoading(false); return; }
res = await register(username, email, password);
} else {
res = await login(username, password);
}
dispatch({ type: "SET_TOKEN", token: res.token });
dispatch({ type: "SET_USER", user: res.user });
const res = isRegister
? await register(username, email, password)
: await login(username, password);
dispatch({ type: "LOGIN", token: res.token, user: res.user });
} 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 (
<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="flex flex-col items-center mb-8">
<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">
{/* Logo */}
<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" />
</div>
<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>
</div>
<form onSubmit={handleSubmit} className="bg-anton-card border border-anton-border rounded-2xl p-6 space-y-4">
<h2 className="text-lg font-semibold text-white text-center">
{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>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-anton-muted mb-1 block">Username</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required autoFocus
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" />
<label className="text-xs text-anton-muted mb-1.5 block">Username</label>
<input
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>
{isRegister && (
<div>
<label className="text-xs text-anton-muted mb-1 block">Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
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" />
<label className="text-xs text-anton-muted mb-1.5 block">Email</label>
<input
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>
<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">
<input type={showPw ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} required
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" />
<button type="button" onClick={() => setShowPw(!showPw)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white">
{showPw ? <EyeOff size={16} /> : <Eye size={16} />}
<input
type={showPw ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
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>
</div>
</div>
<button type="submit" disabled={loading}
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">
{loading && <Loader2 size={16} className="animate-spin" />}
{error && (
<div className="bg-anton-danger/10 border border-anton-danger/30 text-anton-danger text-sm rounded-lg px-3 py-2.5">
{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"}
</button>
{allowRegistration && (
<p className="text-center text-sm text-anton-muted">
{isRegister ? "Already have an account?" : "Don't have an account?"}{" "}
<button type="button" onClick={() => { setIsRegister(!isRegister); setError(""); }}
className="text-anton-accent hover:underline">
{isRegister ? "Sign In" : "Register"}
</button>
</p>
)}
{!allowRegistration && !isRegister && (
<p className="text-center text-xs text-anton-muted">
Registration is disabled. Contact your administrator.
</p>
)}
<button
type="button"
onClick={() => { setIsRegister(!isRegister); setError(""); }}
className="w-full text-center text-sm text-anton-muted hover:text-white transition py-2"
>
{isRegister ? "Already have an account? Sign in" : "Need an account? Register"}
</button>
</form>
</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