Complete admin dashboard with user management, config and settings tabs
This commit is contained in:
parent
d31a132808
commit
80318a8d43
43
frontend/package-lock.json
generated
43
frontend/package-lock.json
generated
@ -24,6 +24,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.6",
|
"next": "16.0.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
@ -4863,6 +4864,33 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.23.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
|
||||||
|
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.23.23",
|
||||||
|
"motion-utils": "^12.23.6",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@ -6227,6 +6255,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.23.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||||
|
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.23.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.23.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||||
|
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.23.24",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.6",
|
"next": "16.0.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
|
|||||||
131
frontend/src/app/admin/login/page.tsx
Normal file
131
frontend/src/app/admin/login/page.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, Suspense } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslationStore } from "@/lib/store";
|
||||||
|
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
|
function AdminLoginContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { setAdminToken } = useTranslationStore();
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/admin/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.detail || "Mot de passe incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setAdminToken(data.access_token);
|
||||||
|
|
||||||
|
const redirect = searchParams.get("redirect") || "/admin";
|
||||||
|
router.push(redirect);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Erreur de connexion");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-600/20 rounded-2xl mb-4">
|
||||||
|
<Shield className="w-8 h-8 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Administration</h1>
|
||||||
|
<p className="text-gray-400 mt-2">Connexion requise</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 mb-6 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 text-sm mb-2">Mot de passe administrateur</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full pl-12 pr-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !password}
|
||||||
|
className="w-full py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-600/50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
Connexion...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
Accéder au panneau admin
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-gray-500 text-sm mt-6">
|
||||||
|
Accès réservé aux administrateurs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
|
||||||
|
<div className="text-white text-xl">Chargement...</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<AdminLoginContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
107
main.py
107
main.py
@ -1005,6 +1005,113 @@ async def get_tracked_files(is_admin: bool = Depends(require_admin)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/users")
|
||||||
|
async def get_admin_users(is_admin: bool = Depends(require_admin)):
|
||||||
|
"""Get all users with their usage stats (requires admin auth)"""
|
||||||
|
from services.auth_service import load_users
|
||||||
|
from models.subscription import PLANS
|
||||||
|
|
||||||
|
users_data = load_users()
|
||||||
|
users_list = []
|
||||||
|
|
||||||
|
for user_id, user_data in users_data.items():
|
||||||
|
plan = user_data.get("plan", "free")
|
||||||
|
plan_info = PLANS.get(plan, PLANS["free"])
|
||||||
|
|
||||||
|
users_list.append({
|
||||||
|
"id": user_id,
|
||||||
|
"email": user_data.get("email", ""),
|
||||||
|
"name": user_data.get("name", ""),
|
||||||
|
"plan": plan,
|
||||||
|
"subscription_status": user_data.get("subscription_status", "active"),
|
||||||
|
"docs_translated_this_month": user_data.get("docs_translated_this_month", 0),
|
||||||
|
"pages_translated_this_month": user_data.get("pages_translated_this_month", 0),
|
||||||
|
"extra_credits": user_data.get("extra_credits", 0),
|
||||||
|
"created_at": user_data.get("created_at", ""),
|
||||||
|
"plan_limits": {
|
||||||
|
"docs_per_month": plan_info.get("docs_per_month", 0),
|
||||||
|
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by created_at descending (newest first)
|
||||||
|
users_list.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": len(users_list),
|
||||||
|
"users": users_list
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/stats")
|
||||||
|
async def get_admin_stats(is_admin: bool = Depends(require_admin)):
|
||||||
|
"""Get comprehensive admin statistics (requires admin auth)"""
|
||||||
|
from services.auth_service import load_users
|
||||||
|
from models.subscription import PLANS
|
||||||
|
|
||||||
|
users_data = load_users()
|
||||||
|
|
||||||
|
# Calculate stats
|
||||||
|
total_users = len(users_data)
|
||||||
|
plan_distribution = {}
|
||||||
|
total_docs_translated = 0
|
||||||
|
total_pages_translated = 0
|
||||||
|
active_users = 0 # Users who translated something this month
|
||||||
|
|
||||||
|
for user_data in users_data.values():
|
||||||
|
plan = user_data.get("plan", "free")
|
||||||
|
plan_distribution[plan] = plan_distribution.get(plan, 0) + 1
|
||||||
|
|
||||||
|
docs = user_data.get("docs_translated_this_month", 0)
|
||||||
|
pages = user_data.get("pages_translated_this_month", 0)
|
||||||
|
total_docs_translated += docs
|
||||||
|
total_pages_translated += pages
|
||||||
|
|
||||||
|
if docs > 0:
|
||||||
|
active_users += 1
|
||||||
|
|
||||||
|
# Get cache stats
|
||||||
|
cache_stats = _translation_cache.get_stats()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users": {
|
||||||
|
"total": total_users,
|
||||||
|
"active_this_month": active_users,
|
||||||
|
"by_plan": plan_distribution
|
||||||
|
},
|
||||||
|
"translations": {
|
||||||
|
"docs_this_month": total_docs_translated,
|
||||||
|
"pages_this_month": total_pages_translated
|
||||||
|
},
|
||||||
|
"cache": cache_stats,
|
||||||
|
"config": {
|
||||||
|
"translation_service": config.TRANSLATION_SERVICE,
|
||||||
|
"max_file_size_mb": config.MAX_FILE_SIZE_MB,
|
||||||
|
"supported_extensions": list(config.SUPPORTED_EXTENSIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/config/provider")
|
||||||
|
async def update_default_provider(
|
||||||
|
provider: str = Form(...),
|
||||||
|
is_admin: bool = Depends(require_admin)
|
||||||
|
):
|
||||||
|
"""Update the default translation provider (requires admin auth)"""
|
||||||
|
valid_providers = ["google", "openrouter", "ollama", "deepl", "libre", "openai"]
|
||||||
|
if provider not in valid_providers:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid provider. Must be one of: {valid_providers}")
|
||||||
|
|
||||||
|
# Update config (in production, this would persist to database/env)
|
||||||
|
config.TRANSLATION_SERVICE = provider
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": f"Default provider updated to {provider}",
|
||||||
|
"provider": provider
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
Loading…
x
Reference in New Issue
Block a user