Commit 705bdcba authored by Administrator's avatar Administrator

Update 5 files via Son of Anton

parent e3643210
"""
Superadmin routes: user management, stats, permissions, app settings — v4.2.0
"""
from pydantic import BaseModel
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
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.auth import (
require_superadmin, hash_password, get_user_permissions,
ensure_user_permissions, get_default_permissions_template,
)
from backend.config import PERMISSION_FIELDS, DEFAULT_PERMISSIONS, SUPERADMIN_PERMISSIONS, AVAILABLE_MODELS
router = APIRouter()
class UpdateUserBody(BaseModel):
email: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
quota_tokens_monthly: Optional[int] = None
password: Optional[str] = None
class CreateUserBody(BaseModel):
username: str
email: str
password: str
role: str = "user"
quota_tokens_monthly: int = 2_000_000
class PermissionsBody(BaseModel):
can_use_web_search: Optional[bool] = None
can_use_ui_design: Optional[bool] = None
can_use_knowledge_base: Optional[bool] = None
can_use_gitlab: Optional[bool] = None
can_use_attachments: Optional[bool] = None
can_export_pptx: Optional[bool] = None
can_export_docx: Optional[bool] = None
allowed_models: Optional[str] = None
max_tokens_cap: Optional[int] = None
max_reasoning_budget: Optional[int] = None
max_chats: Optional[int] = None
max_messages_per_day: Optional[int] = None
max_knowledge_bases: Optional[int] = None
max_documents_per_kb: Optional[int] = None
max_attachment_size_mb: Optional[int] = None
max_attachments_per_message: Optional[int] = None
class AppSettingsBody(BaseModel):
allow_registration: Optional[bool] = None
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
# REGISTRATION TOGGLE (append to end of file) # Stats & Users
# ═══════════════════════════════════════════════════ # ═══════════════════════════════════════════════════
from backend.models import AppSettings # add this import at top if not already there @router.get("/stats")
def get_stats(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
return {
"total_users": db.query(User).count(),
"active_users": db.query(User).filter(User.is_active == True).count(),
"total_chats": db.query(Chat).count(),
"total_messages": db.query(Message).count(),
"total_tokens_used": db.query(func.sum(User.tokens_used_this_month)).scalar() or 0,
"total_knowledge_bases": db.query(KnowledgeBase).count(),
}
@router.get("/users")
def list_users(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
users = db.query(User).order_by(User.created_at.desc()).all()
result = []
for u in users:
chat_count = db.query(Chat).filter(Chat.user_id == u.id).count()
result.append({
"id": u.id,
"username": u.username,
"email": u.email,
"role": u.role,
"is_active": u.is_active,
"quota_tokens_monthly": u.quota_tokens_monthly,
"tokens_used_this_month": u.tokens_used_this_month,
"chat_count": chat_count,
"created_at": str(u.created_at),
})
return result
@router.post("/users")
def create_user(body: CreateUserBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
if db.query(User).filter(
(User.username == body.username) | (User.email == body.email)
).first():
raise HTTPException(409, "Username or email taken")
user = User(
username=body.username,
email=body.email,
password_hash=hash_password(body.password),
role=body.role,
quota_tokens_monthly=body.quota_tokens_monthly,
)
db.add(user)
db.commit()
db.refresh(user)
ensure_user_permissions(user.id, db)
return {"id": user.id, "username": user.username}
@router.put("/users/{user_id}")
def update_user(user_id: str, body: UpdateUserBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404)
if body.email is not None:
user.email = body.email
if body.role is not None:
user.role = body.role
if body.is_active is not None:
user.is_active = body.is_active
if body.quota_tokens_monthly is not None:
user.quota_tokens_monthly = body.quota_tokens_monthly
if body.password:
user.password_hash = hash_password(body.password)
db.commit()
return {"ok": True}
@router.delete("/users/{user_id}")
def delete_user(user_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404)
if user.role == "superadmin":
raise HTTPException(400, "Cannot delete superadmin")
db.delete(user)
db.commit()
return {"ok": True}
@router.get("/registration") @router.get("/chats")
def get_registration_setting(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)): def list_all_chats(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
chats = db.query(Chat).order_by(Chat.updated_at.desc()).limit(200).all()
result = []
for c in chats:
user = db.query(User).filter(User.id == c.user_id).first()
msg_count = db.query(Message).filter(Message.chat_id == c.id).count()
result.append({
"id": c.id,
"title": c.title,
"username": user.username if user else "?",
"message_count": msg_count,
"updated_at": str(c.updated_at),
})
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() settings = db.query(AppSettings).first()
if not settings: if not settings:
settings = AppSettings(allow_registration=True) settings = AppSettings(allow_registration=True)
db.add(settings) db.add(settings)
db.commit() db.commit()
db.refresh(settings) db.refresh(settings)
return {"allow_registration": settings.allow_registration} return {
"allow_registration": settings.allow_registration,
}
@router.put("/registration") @router.put("/settings")
def set_registration_setting(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)): def update_app_settings(body: AppSettingsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
settings = db.query(AppSettings).first() settings = db.query(AppSettings).first()
if not settings: if not settings:
settings = AppSettings(allow_registration=True) settings = AppSettings(allow_registration=True)
db.add(settings) db.add(settings)
if body.allow_registration is not None:
settings.allow_registration = body.allow_registration
db.commit() db.commit()
db.refresh(settings) db.refresh(settings)
# Toggle return {
settings.allow_registration = not settings.allow_registration "allow_registration": settings.allow_registration,
}
# ═══════════════════════════════════════════════════
# PERMISSIONS MANAGEMENT
# ═══════════════════════════════════════════════════
@router.get("/models")
def list_available_models(admin: User = Depends(require_superadmin)):
return AVAILABLE_MODELS
@router.get("/permissions/defaults")
def get_defaults(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
return get_default_permissions_template(db)
@router.put("/permissions/defaults")
def update_defaults(body: PermissionsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
template = db.query(UserPermissions).filter(UserPermissions.user_id == "__defaults__").first()
if not template:
template = UserPermissions(user_id="__defaults__")
db.add(template)
_apply_permissions_body(template, body)
db.commit()
return get_default_permissions_template(db)
@router.post("/permissions/apply-defaults")
def apply_defaults_to_all(admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
"""Apply default permissions template to ALL non-superadmin users."""
template = get_default_permissions_template(db)
users = db.query(User).filter(User.role != "superadmin").all()
count = 0
for user in users:
perms = db.query(UserPermissions).filter(UserPermissions.user_id == user.id).first()
if not perms:
perms = UserPermissions(user_id=user.id)
db.add(perms)
for field in PERMISSION_FIELDS:
if hasattr(perms, field):
setattr(perms, field, template.get(field))
count += 1
db.commit()
return {"ok": True, "users_updated": count}
@router.get("/users/{user_id}/permissions")
def get_user_perms(user_id: str, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
return get_user_permissions(user_id, db)
@router.put("/users/{user_id}/permissions")
def update_user_perms(user_id: str, body: PermissionsBody, admin: User = Depends(require_superadmin), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "User not found")
if user.role == "superadmin":
raise HTTPException(400, "Cannot modify superadmin permissions — they always have full access")
ensure_user_permissions(user_id, db)
perms = db.query(UserPermissions).filter(UserPermissions.user_id == user_id).first()
_apply_permissions_body(perms, body)
db.commit() db.commit()
return {"allow_registration": settings.allow_registration} return get_user_permissions(user_id, db)
\ No newline at end of file
def _apply_permissions_body(perms: UserPermissions, body: PermissionsBody):
"""Apply non-None fields from the body to a permissions object."""
data = body.dict(exclude_none=True)
for field, value in data.items():
if hasattr(perms, field):
setattr(perms, field, value)
\ No newline at end of file
""" """
Authentication routes: register, login, profile — with permissions + registration toggle. Authentication routes: register, login, profile — with permissions and registration toggle.
""" """
from pydantic import BaseModel from pydantic import BaseModel
...@@ -28,23 +28,22 @@ class LoginBody(BaseModel): ...@@ -28,23 +28,22 @@ class LoginBody(BaseModel):
password: str password: str
@router.get("/config") class ProfileOut(BaseModel):
def auth_config(db: Session = Depends(get_db)): pass
"""Public endpoint — no auth needed. Returns registration status."""
settings = db.query(AppSettings).first()
return { class Config(BaseModel):
"allow_registration": settings.allow_registration if settings else True, 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 # Check if registration is enabled
app_settings = db.query(AppSettings).first() settings = db.query(AppSettings).first()
if app_settings and not app_settings.allow_registration: if settings and not settings.allow_registration:
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN,
"Registration is currently disabled. Contact an administrator.", "Registration is currently disabled. Contact your administrator.",
) )
if db.query(User).filter( if db.query(User).filter(
...@@ -63,6 +62,7 @@ def register(body: RegisterBody, db: Session = Depends(get_db)): ...@@ -63,6 +62,7 @@ def register(body: RegisterBody, db: Session = Depends(get_db)):
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)
token = create_token(user.id, user.role) token = create_token(user.id, user.role)
...@@ -88,6 +88,16 @@ def me(user: User = Depends(get_current_user), db: Session = Depends(get_db)): ...@@ -88,6 +88,16 @@ 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,
......
// ── Registration toggle (append to end of file) ── const BASE = "/api";
export const getAuthConfig = () => function headers(token) {
fetch(`${BASE}/auth/config`).then((r) => r.json()); const h = { "Content-Type": "application/json" };
if (token) h["Authorization"] = `Bearer ${token}`;
return h;
}
export const getRegistrationSetting = (token) => function authHeader(token) {
request("GET", "/admin/registration", token); return token ? { Authorization: `Bearer ${token}` } : {};
}
export const toggleRegistration = (token) => async function request(method, path, token, body) {
request("PUT", "/admin/registration", token); const opts = { method, headers: headers(token) };
\ No newline at end of file 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");
}
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);
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();
}
export function getAttachmentUrl(attachmentId) {
return `${BASE}/attachments/${attachmentId}/file`;
}
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 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
import React, { useState, useEffect, useCallback } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useApp } from "../store"; import { useApp } from "../store";
import { useNavigate } from "react-router-dom";
import { import {
adminStats, adminListUsers, adminCreateUser, adminUpdateUser, adminDeleteUser, adminStats, adminListUsers, adminCreateUser, adminUpdateUser,
adminGetUserPermissions, adminUpdateUserPermissions, adminDeleteUser, adminListChats, adminGetSettings, adminUpdateSettings,
adminGetDefaultPermissions, adminUpdateDefaultPermissions, adminApplyDefaults,
} from "../api"; } from "../api";
import { import {
ArrowLeft, Users, MessageSquare, Database, Zap, ArrowLeft, Users, MessageSquare, Database, Zap, Plus, Trash2, Edit,
UserPlus, Trash2, Shield, ShieldOff, Save, X, Shield, ShieldCheck, ShieldX, RefreshCw, ToggleLeft, ToggleRight,
Settings2, Check, Globe, Paintbrush, BookOpen, GitBranch, Settings, UserPlus,
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 });
// Permissions modal const [settingsLoading, setSettingsLoading] = useState(false);
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] = await Promise.all([adminStats(state.token), adminListUsers(state.token)]); const [s, u, c] = await Promise.all([
setStats(s); setUsers(u); adminStats(state.token),
} catch (err) { setError(err.message); } adminListUsers(state.token),
}, [state.token]); adminListChats(state.token),
]);
useEffect(() => { load(); }, [load]); setStats(s);
setUsers(u);
async function handleCreate(e) { setChats(c);
e.preventDefault(); } catch (err) {
try { await adminCreateUser(state.token, newUser); setShowCreate(false); setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 }); load(); } catch (err) { setError(err.message); } setError(err.message);
} }
}, [state.token]);
async function handleSaveEdit(userId) { const loadSettings = useCallback(async () => {
try { await adminUpdateUser(state.token, userId, editData); setEditId(null); load(); } catch (err) { setError(err.message); } try {
} const s = await adminGetSettings(state.token);
setAppSettings(s);
} catch { }
}, [state.token]);
async function handleDelete(userId, username) { useEffect(() => { load(); loadSettings(); }, [load, loadSettings]);
if (!confirm(`Delete user "${username}"? This is permanent.`)) return;
try { await adminDeleteUser(state.token, userId); load(); } catch (err) { setError(err.message); }
}
// ═══ Permissions ═══ async function handleCreateUser() {
async function openPerms(user) {
setPermsUser(user); setPermsLoading(true); setPermsData(null);
try { try {
const data = user.id === "__defaults__" await adminCreateUser(state.token, newUser);
? await adminGetDefaultPermissions(state.token) setShowCreate(false);
: await adminGetUserPermissions(state.token, user.id); setNewUser({ username: "", email: "", password: "", role: "user", quota_tokens_monthly: 2000000 });
setPermsData(data); load();
} catch (err) { setError(err.message); setPermsUser(null); } } catch (err) { setError(err.message); }
setPermsLoading(false);
} }
async function savePerms() { async function handleUpdateUser(userId, data) {
if (!permsUser || !permsData) return;
setPermsSaving(true);
try { try {
if (permsUser.id === "__defaults__") { await adminUpdateUser(state.token, userId, data);
await adminUpdateDefaultPermissions(state.token, permsData); setEditingUser(null);
} else { load();
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 handleApplyDefaults() { async function handleDeleteUser(userId) {
if (!confirm("Apply default permissions to ALL regular users? This will overwrite their current permissions.")) return; if (!confirm("Delete this user and all their data?")) return;
setApplyingDefaults(true);
try { try {
const res = await adminApplyDefaults(state.token); await adminDeleteUser(state.token, userId);
alert(`✅ Applied to ${res.users_updated} users`); load();
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
setApplyingDefaults(false);
}
function updatePerm(key, value) {
setPermsData(prev => ({ ...prev, [key]: value }));
} }
function formatNum(n) { async function toggleRegistration() {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; setSettingsLoading(true);
if (n >= 1_000) return (n / 1_000).toFixed(0) + "K"; try {
return String(n); const res = await adminUpdateSettings(state.token, {
allow_registration: !appSettings.allow_registration,
});
setAppSettings(res);
} catch (err) { setError(err.message); }
setSettingsLoading(false);
} }
if (state.user?.role !== "superadmin") { if (state.user?.role !== "superadmin") {
return <div className="h-full flex items-center justify-center"><p className="text-anton-danger text-lg">⛔ Access Denied</p></div>; return (
<div className="h-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-full overflow-y-auto bg-anton-bg p-4 sm:p-6"> <div className="h-dvh flex flex-col bg-anton-bg">
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="border-b border-anton-border bg-anton-surface px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate("/")} className="p-2 rounded-lg bg-anton-surface border border-anton-border hover:border-anton-accent transition"><ArrowLeft size={20} /></button> <button onClick={() => navigate("/")} className="text-anton-muted hover:text-white"><ArrowLeft size={20} /></button>
<div> <ShieldCheck size={20} className="text-anton-accent" />
<h1 className="text-2xl font-bold text-white flex items-center gap-2"><Shield size={24} className="text-anton-accent" /> Admin Panel</h1> <h1 className="text-white font-semibold">Admin Dashboard</h1>
<p className="text-anton-muted text-sm">Users, permissions & cost control</p> <button onClick={() => { load(); loadSettings(); }} className="ml-auto text-anton-muted hover:text-white"><RefreshCw size={16} /></button>
</div> </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>
)}
{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>)} {/* 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>
{/* Stats */} {/* Content */}
{stats && ( <div className="flex-1 overflow-y-auto p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> {/* ── STATS TAB ── */}
{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: "Chats", value: stats.total_chats, icon: MessageSquare, color: "text-green-400" }, { label: "Active", value: stats.active_users, icon: ShieldCheck, color: "text-green-400" },
{ label: "Messages", value: formatNum(stats.total_messages), icon: Zap, color: "text-anton-accent" }, { label: "Chats", value: stats.total_chats, icon: MessageSquare, color: "text-purple-400" },
{ label: "Tokens Used", value: formatNum(stats.total_tokens_used), icon: Database, color: "text-purple-400" }, { label: "Messages", value: stats.total_messages, icon: MessageSquare, color: "text-yellow-400" },
].map((s) => ( { label: "Tokens Used", value: (stats.total_tokens_used || 0).toLocaleString(), icon: Zap, color: "text-red-400" },
<div key={s.label} className="bg-anton-surface border border-anton-border rounded-xl p-4"> { label: "Knowledge Bases", value: stats.total_knowledge_bases, icon: Database, color: "text-cyan-400" },
<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> ].map((s, i) => (
<p className="text-2xl font-bold text-white">{s.value}</p> <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>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* User Management */} {/* ── SETTINGS TAB ── */}
<div className="bg-anton-surface border border-anton-border rounded-xl overflow-hidden"> {tab === "settings" && (
<div className="px-5 py-4 border-b border-anton-border flex items-center justify-between flex-wrap gap-2"> <div className="space-y-4 max-w-lg">
<h2 className="text-lg font-semibold text-white">Users</h2> <h2 className="text-lg font-semibold text-white flex items-center gap-2">
<div className="flex items-center gap-2"> <Settings size={18} className="text-anton-accent" /> App Settings
<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"> </h2>
<Lock size={14} /> Defaults
</button>
<button onClick={handleApplyDefaults} disabled={applyingDefaults} className="flex items-center gap-1.5 px-3 py-1.5 bg-anton-card border border-anton-border text-anton-muted rounded-lg text-sm hover:text-white transition disabled:opacity-50">
{applyingDefaults ? <Loader2 size={14} className="animate-spin" /> : <Copy size={14} />} Apply to All
</button>
<button onClick={() => setShowCreate(!showCreate)} className="flex items-center gap-1.5 px-3 py-1.5 bg-anton-accent text-white rounded-lg text-sm font-medium hover:opacity-90 transition">
{showCreate ? <X size={14} /> : <UserPlus size={14} />} {showCreate ? "Cancel" : "New User"}
</button>
</div>
</div>
{showCreate && (
<form onSubmit={handleCreate} className="px-5 py-4 border-b border-anton-border bg-anton-card grid grid-cols-1 md:grid-cols-3 gap-3">
<input placeholder="Username" required value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Email" type="email" required value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<input placeholder="Password" required value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent" />
<select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })} className="bg-anton-bg border border-anton-border rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-anton-accent">
<option value="user">User</option><option value="admin">Admin</option><option value="superadmin">Superadmin</option>
</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>
)}
<div className="overflow-x-auto"> <div className="bg-anton-card border border-anton-border rounded-xl p-5">
<table className="w-full text-sm"> <div className="flex items-center justify-between">
<thead><tr className="text-left text-anton-muted border-b border-anton-border"> <div>
<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> <h3 className="text-white font-medium flex items-center gap-2">
</tr></thead> <UserPlus size={16} className="text-blue-400" />
<tbody> User Registration
{users.map((u) => ( </h3>
<tr key={u.id} className="border-b border-anton-border/50 hover:bg-anton-card/50 transition"> <p className="text-anton-muted text-sm mt-1">
<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> {appSettings.allow_registration
<td className="px-5 py-3"> ? "Anyone can create an account on the login page."
{editId === u.id ? ( : "Registration is disabled. Only admins can create users."}
<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"> </p>
<option value="user">user</option><option value="admin">admin</option><option value="superadmin">superadmin</option> </div>
</select> <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" />
) : ( ) : (
<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> <ToggleLeft size={40} className="text-anton-muted" />
)} )}
</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> </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> </div>
</div> </div>
)}
{/* ═══ PERMISSIONS MODAL ═══ */} {/* ── USERS TAB ── */}
{permsUser && ( {tab === "users" && (
<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="space-y-3">
<div className="bg-anton-surface border border-anton-border rounded-2xl w-full max-w-2xl my-8 shadow-2xl" onClick={e => e.stopPropagation()}> <div className="flex items-center justify-between">
{/* Modal Header */} <h2 className="text-lg font-semibold text-white">Users ({users.length})</h2>
<div className="px-6 py-4 border-b border-anton-border flex items-center justify-between"> <button onClick={() => setShowCreate(!showCreate)}
<div> className="flex items-center gap-1.5 bg-anton-accent text-white px-3 py-1.5 rounded-lg text-sm hover:opacity-80">
<h2 className="text-lg font-bold text-white flex items-center gap-2"> <Plus size={14} /> Create User
<Settings2 size={20} className="text-purple-400" /> </button>
{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>
{permsLoading ? ( {showCreate && (
<div className="flex items-center justify-center py-20"><Loader2 size={24} className="text-anton-accent animate-spin" /></div> <div className="bg-anton-card border border-anton-border rounded-xl p-4 space-y-3 animate-fade-in">
) : permsData ? ( <h3 className="text-sm font-semibold text-white">New User</h3>
<div className="p-6 space-y-6 max-h-[70vh] overflow-y-auto"> <div className="grid grid-cols-2 gap-3">
{/* Feature Access */} <input placeholder="Username" value={newUser.username} onChange={(e) => setNewUser({ ...newUser, username: e.target.value })}
<div> 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" />
<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> <input placeholder="Email" value={newUser.email} onChange={(e) => setNewUser({ ...newUser, email: e.target.value })}
<div className="space-y-2"> 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" />
{FEATURE_DEFS.map(f => { <input placeholder="Password" type="password" value={newUser.password} onChange={(e) => setNewUser({ ...newUser, password: e.target.value })}
const Icon = f.icon; 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" />
return ( <select value={newUser.role} onChange={(e) => setNewUser({ ...newUser, role: e.target.value })}
<div key={f.key} className="flex items-center justify-between bg-anton-card rounded-lg px-4 py-3 border border-anton-border"> 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 gap-3"> <option value="user">User</option>
<Icon size={16} className={f.color} /> <option value="admin">Admin</option>
<div> </select>
<p className="text-sm text-white font-medium">{f.label}</p>
<p className="text-[10px] text-anton-muted">{f.desc}</p>
</div>
</div> </div>
<Toggle value={!!permsData[f.key]} onChange={v => updatePerm(f.key, v)} /> <div className="flex gap-2">
</div> <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>
</div> </div>
)}
{/* Model Access */}
<div>
<h3 className="text-sm font-semibold text-white mb-3 flex items-center gap-2"><Cpu size={14} className="text-cyan-400" /> Model Access</h3>
<div className="space-y-2"> <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"> {users.map((u) => (
<input type="checkbox" checked={permsData.allowed_models === "all"} onChange={e => updatePerm("allowed_models", e.target.checked ? "all" : MODELS.map(m => m.id).join(","))} <div key={u.id} className="bg-anton-card border border-anton-border rounded-xl p-4 flex items-center gap-4">
className="w-4 h-4 rounded accent-anton-accent" /> <div className="flex-1 min-w-0">
<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> <div className="flex items-center gap-2">
</label> <span className="text-white font-medium">{u.username}</span>
{permsData.allowed_models !== "all" && ( <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"}`}>
<div className="pl-4 space-y-1.5"> {u.role}
{MODELS.map(m => { </span>
const list = (permsData.allowed_models || "").split(",").map(s => s.trim()).filter(Boolean); {!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-500/20 text-gray-400">disabled</span>}
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 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> </div>
<div className="flex items-center gap-1">
{/* Limits */} {u.role !== "superadmin" && (
<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> <button onClick={() => handleUpdateUser(u.id, { is_active: !u.is_active })}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> 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"}`}
{LIMIT_DEFS.map(l => ( title={u.is_active ? "Disable" : "Enable"}>
<div key={l.key} className="bg-anton-card rounded-lg px-4 py-3 border border-anton-border"> {u.is_active ? <ShieldCheck size={16} /> : <ShieldX size={16} />}
<div className="flex justify-between items-center mb-1"> </button>
<label className="text-xs text-anton-muted">{l.label}</label> <button onClick={() => handleDeleteUser(u.id)} className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition" title="Delete">
<span className="text-xs text-white font-mono">{(permsData[l.key] || 0) === 0 && l.desc?.includes("unlimited") ? "∞" : (permsData[l.key] || 0).toLocaleString()}</span> <Trash2 size={16} />
</button>
</>
)}
</div> </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>
</div> )}
) : null}
{/* Modal Footer */} {/* ── CHATS TAB ── */}
{permsData && ( {tab === "chats" && (
<div className="px-6 py-4 border-t border-anton-border flex items-center justify-between"> <div className="space-y-2">
<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> <h2 className="text-lg font-semibold text-white">Recent Chats ({chats.length})</h2>
<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"> {chats.map((c) => (
{permsSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} Save Permissions <div key={c.id} className="bg-anton-card border border-anton-border rounded-xl p-3 flex items-center gap-3">
</button> <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>
</div> </div>
)}
</div> </div>
))}
</div> </div>
)} )}
</div> </div>
</div>
); );
} }
\ No newline at end of file
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useApp } from "../store"; import { useApp } from "../store";
import { login, register, getAuthConfig } from "../api"; import { login, register, getPublicConfig } from "../api";
import { Flame, LogIn, UserPlus, AlertCircle } from "lucide-react"; import { Flame, Eye, EyeOff, Loader2 } from "lucide-react";
export default function LoginPage() { export default function LoginPage() {
const { dispatch } = useApp(); const { dispatch } = useApp();
...@@ -9,21 +9,23 @@ export default function LoginPage() { ...@@ -9,21 +9,23 @@ export default function LoginPage() {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
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 [allowRegistration, setAllowRegistration] = useState(true);
const [configLoaded, setConfigLoaded] = useState(false); const [configLoaded, setConfigLoaded] = useState(false);
useEffect(() => { useEffect(() => {
getAuthConfig() (async () => {
.then((data) => { try {
setAllowRegistration(data.allow_registration !== false); const cfg = await getPublicConfig();
setConfigLoaded(true); setAllowRegistration(cfg.allow_registration);
}) } catch {
.catch(() => { // If config fetch fails, default to allowing registration
setAllowRegistration(true); setAllowRegistration(true);
}
setConfigLoaded(true); setConfigLoaded(true);
}); })();
}, []); }, []);
async function handleSubmit(e) { async function handleSubmit(e) {
...@@ -31,26 +33,31 @@ export default function LoginPage() { ...@@ -31,26 +33,31 @@ export default function LoginPage() {
setError(""); setError("");
setLoading(true); setLoading(true);
try { try {
let result; let res;
if (isRegister) { if (isRegister) {
if (!allowRegistration) { if (!email) { setError("Email is required"); setLoading(false); return; }
setError("Registration is disabled."); res = await register(username, email, password);
setLoading(false);
return;
}
result = await register(username, email, password);
} else { } else {
result = await login(username, password); res = await login(username, password);
} }
dispatch({ type: "LOGIN", token: result.token, user: result.user }); dispatch({ type: "SET_TOKEN", token: res.token });
dispatch({ type: "SET_USER", user: res.user });
} catch (err) { } catch (err) {
setError(err.message || "Something went wrong"); setError(err.message);
} }
setLoading(false); setLoading(false);
} }
if (!configLoaded) {
return ( return (
<div className="h-dvh flex items-center justify-center bg-anton-bg p-4"> <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="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8"> <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"> <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">
...@@ -60,85 +67,65 @@ export default function LoginPage() { ...@@ -60,85 +67,65 @@ export default function LoginPage() {
<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>
<div className="bg-anton-card border border-anton-border rounded-2xl p-6"> <form onSubmit={handleSubmit} className="bg-anton-card border border-anton-border rounded-2xl p-6 space-y-4">
{/* Tab buttons — only show Register tab if registration is allowed */} <h2 className="text-lg font-semibold text-white text-center">
<div className="flex gap-2 mb-6"> {isRegister ? "Create Account" : "Sign In"}
<button </h2>
onClick={() => { setIsRegister(false); setError(""); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
!isRegister ? "bg-anton-accent text-white" : "text-anton-muted hover:text-white"
}`}
>
<LogIn size={14} className="inline mr-1.5" />
Login
</button>
{allowRegistration && (
<button
onClick={() => { setIsRegister(true); setError(""); }}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
isRegister ? "bg-anton-accent text-white" : "text-anton-muted hover:text-white"
}`}
>
<UserPlus size={14} className="inline mr-1.5" />
Register
</button>
)}
</div>
{error && ( {error && (
<div className="mb-4 bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 flex items-start gap-2"> <div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 text-red-400 text-sm">
<AlertCircle size={14} className="text-red-400 mt-0.5 shrink-0" /> {error}
<span className="text-red-400 text-xs">{error}</span>
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="text-xs text-anton-muted mb-1 block">Username</label> <label className="text-xs text-anton-muted mb-1 block">Username</label>
<input <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required autoFocus
type="text" value={username} 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" />
onChange={(e) => setUsername(e.target.value)}
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 transition"
required autoComplete="username"
/>
</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 block">Email</label>
<input <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required
type="email" value={email} 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" />
onChange={(e) => setEmail(e.target.value)}
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 transition"
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 block">Password</label>
<input <div className="relative">
type="password" value={password} <input type={showPw ? "text" : "password"} value={password} onChange={(e) => setPassword(e.target.value)} required
onChange={(e) => setPassword(e.target.value)} 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" />
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 transition" <button type="button" onClick={() => setShowPw(!showPw)}
required autoComplete={isRegister ? "new-password" : "current-password"} className="absolute right-3 top-1/2 -translate-y-1/2 text-anton-muted hover:text-white">
/> {showPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div> </div>
<button <button type="submit" disabled={loading}
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">
className="w-full bg-anton-accent text-white py-2.5 rounded-lg font-medium hover:opacity-90 transition disabled:opacity-50" {loading && <Loader2 size={16} className="animate-spin" />}
> {isRegister ? "Create Account" : "Sign In"}
{loading ? "..." : isRegister ? "Create Account" : "Sign In"}
</button> </button>
</form>
{!allowRegistration && configLoaded && !isRegister && ( {allowRegistration && (
<p className="text-[11px] text-anton-muted text-center mt-4"> <p className="text-center text-sm text-anton-muted">
Registration is currently disabled by the administrator. {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> </p>
)} )}
</div> </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