feat: add XAMLÉ Platform Admin super-admin interface
Browse filesWhen selectedOrgId === 'xamle-admin-org', the admin switches to a full
platform management dashboard instead of the normal org layout.
Backend (apps/api):
- New /v1/super-admin/* routes (SUPER_ADMIN/ADMIN role required):
platform stats, organizations CRUD + suspend + impersonate,
cross-org users + role change, WhatsApp numbers, billing
transactions + manual credits, monitoring health, AI agentic command
- Fixed SQL injection in analytics text-to-SQL (UNION/DROP/comment blocking)
- Removed JWT secret fallback — crashes on startup if JWT_SECRET is missing
- Added ORG_ADMIN role guard to all /v1/admin/* routes
Frontend (apps/admin):
- SuperAdminRouter: nested routes under /platform/*
- SuperAdminLayout: dark sidebar (slate-950/violet), 7 nav items
- PlatformDashboard: 6 KPI cards, system health, active alerts
- OrganizationsManager: searchable table, suspend/edit drawer, create modal
- UsersManager: cross-org table with inline role-change
- WhatsAppNumbers: all registered numbers across orgs
- MonitoringAlerts: DB/Redis/queue health, expiring tokens, low balances
- BillingManager: transaction history, manual credit top-up modal
- AIInsights: NL chat with suggestion chips and confirmation flow
- apps/admin/src/App.tsx +6 -1
- apps/admin/src/pages/super-admin/AIInsights.tsx +195 -0
- apps/admin/src/pages/super-admin/BillingManager.tsx +158 -0
- apps/admin/src/pages/super-admin/MonitoringAlerts.tsx +119 -0
- apps/admin/src/pages/super-admin/OrganizationsManager.tsx +278 -0
- apps/admin/src/pages/super-admin/PlatformDashboard.tsx +118 -0
- apps/admin/src/pages/super-admin/SuperAdminLayout.tsx +97 -0
- apps/admin/src/pages/super-admin/SuperAdminRouter.tsx +27 -0
- apps/admin/src/pages/super-admin/UsersManager.tsx +132 -0
- apps/admin/src/pages/super-admin/WhatsAppNumbers.tsx +73 -0
- apps/api/src/app.ts +6 -1
- apps/api/src/routes/admin.ts +7 -0
- apps/api/src/routes/analytics.ts +6 -1
- apps/api/src/routes/super-admin.ts +528 -0
|
@@ -31,6 +31,7 @@ import { useTenant } from '@/lib/tenant';
|
|
| 31 |
import { api } from '@/lib/api';
|
| 32 |
|
| 33 |
import MainLayout from '@/components/layouts/MainLayout';
|
|
|
|
| 34 |
import { logError } from '@/lib/logger';
|
| 35 |
|
| 36 |
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
@@ -41,7 +42,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
| 41 |
|
| 42 |
function AppShell() {
|
| 43 |
const { token, user } = useAuth();
|
| 44 |
-
const { setSelectedOrgId, currentOrg } = useTenant();
|
| 45 |
const [orgs, setOrgs] = React.useState<any[]>([]);
|
| 46 |
|
| 47 |
const isSuperAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
|
|
@@ -62,6 +63,10 @@ function AppShell() {
|
|
| 62 |
|
| 63 |
const isCrmActive = !!currentOrg?.isCrmActive;
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return (
|
| 66 |
<MainLayout isSuperAdmin={isSuperAdmin} orgs={orgs}>
|
| 67 |
<Routes>
|
|
|
|
| 31 |
import { api } from '@/lib/api';
|
| 32 |
|
| 33 |
import MainLayout from '@/components/layouts/MainLayout';
|
| 34 |
+
import SuperAdminRouter from '@/pages/super-admin/SuperAdminRouter';
|
| 35 |
import { logError } from '@/lib/logger';
|
| 36 |
|
| 37 |
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|
|
|
| 42 |
|
| 43 |
function AppShell() {
|
| 44 |
const { token, user } = useAuth();
|
| 45 |
+
const { selectedOrgId, setSelectedOrgId, currentOrg } = useTenant();
|
| 46 |
const [orgs, setOrgs] = React.useState<any[]>([]);
|
| 47 |
|
| 48 |
const isSuperAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'ADMIN';
|
|
|
|
| 63 |
|
| 64 |
const isCrmActive = !!currentOrg?.isCrmActive;
|
| 65 |
|
| 66 |
+
if (selectedOrgId === 'xamle-admin-org') {
|
| 67 |
+
return <SuperAdminRouter />;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
return (
|
| 71 |
<MainLayout isSuperAdmin={isSuperAdmin} orgs={orgs}>
|
| 72 |
<Routes>
|
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '@/lib/auth';
|
| 3 |
+
import { api } from '@/lib/api';
|
| 4 |
+
import { useToast } from '@/hooks/useToast';
|
| 5 |
+
import { Bot, Send, Loader2, Check, X } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
const SUGGESTIONS = [
|
| 8 |
+
'Montre moi les organisations avec un solde faible',
|
| 9 |
+
'Quelles sont les statistiques de la plateforme ?',
|
| 10 |
+
'Liste les alertes actives',
|
| 11 |
+
'Affiche les 10 dernières organisations',
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
interface Msg {
|
| 15 |
+
role: 'user' | 'assistant';
|
| 16 |
+
content: string;
|
| 17 |
+
pending?: { action: string; params: Record<string, unknown>; summary: string };
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function AIInsights() {
|
| 21 |
+
const { token } = useAuth();
|
| 22 |
+
const toast = useToast();
|
| 23 |
+
const [messages, setMessages] = useState<Msg[]>([{
|
| 24 |
+
role: 'assistant',
|
| 25 |
+
content: 'Bonjour ! Je suis votre assistant IA pour la gestion de la plateforme XAMLÉ. Posez-moi une question ou donnez-moi une instruction.',
|
| 26 |
+
}]);
|
| 27 |
+
const [input, setInput] = useState('');
|
| 28 |
+
const [loading, setLoading] = useState(false);
|
| 29 |
+
const endRef = useRef<HTMLDivElement>(null);
|
| 30 |
+
|
| 31 |
+
useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
|
| 32 |
+
|
| 33 |
+
async function send(command?: string) {
|
| 34 |
+
const text = command || input.trim();
|
| 35 |
+
if (!text || !token) return;
|
| 36 |
+
setInput('');
|
| 37 |
+
setMessages(prev => [...prev, { role: 'user', content: text }]);
|
| 38 |
+
setLoading(true);
|
| 39 |
+
|
| 40 |
+
try {
|
| 41 |
+
const res = await api.post('/v1/super-admin/ai/command', { command: text }, token);
|
| 42 |
+
|
| 43 |
+
if (res.status === 'pending_confirmation') {
|
| 44 |
+
setMessages(prev => [...prev, {
|
| 45 |
+
role: 'assistant',
|
| 46 |
+
content: res.summary,
|
| 47 |
+
pending: { action: res.action, params: res.params, summary: res.summary },
|
| 48 |
+
}]);
|
| 49 |
+
} else {
|
| 50 |
+
const resultText = formatResult(res.result);
|
| 51 |
+
setMessages(prev => [...prev, { role: 'assistant', content: resultText }]);
|
| 52 |
+
}
|
| 53 |
+
} catch {
|
| 54 |
+
toast.error('Erreur AI');
|
| 55 |
+
setMessages(prev => [...prev, { role: 'assistant', content: 'Désolé, une erreur est survenue.' }]);
|
| 56 |
+
} finally {
|
| 57 |
+
setLoading(false);
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
async function confirm(msg: Msg, confirmed: boolean) {
|
| 62 |
+
if (!msg.pending || !token) return;
|
| 63 |
+
setMessages(prev => prev.map(m => m === msg ? { ...m, pending: undefined } : m));
|
| 64 |
+
|
| 65 |
+
if (!confirmed) {
|
| 66 |
+
setMessages(prev => [...prev, { role: 'assistant', content: 'Action annulée.' }]);
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
setLoading(true);
|
| 71 |
+
try {
|
| 72 |
+
const res = await api.post('/v1/super-admin/ai/command', {
|
| 73 |
+
command: msg.pending.summary,
|
| 74 |
+
confirm: true,
|
| 75 |
+
pendingAction: { action: msg.pending.action, params: msg.pending.params },
|
| 76 |
+
}, token);
|
| 77 |
+
setMessages(prev => [...prev, { role: 'assistant', content: formatResult(res.result) }]);
|
| 78 |
+
} catch {
|
| 79 |
+
toast.error('Erreur exécution');
|
| 80 |
+
setMessages(prev => [...prev, { role: 'assistant', content: "Erreur lors de l'exécution." }]);
|
| 81 |
+
} finally {
|
| 82 |
+
setLoading(false);
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
function formatResult(result: unknown): string {
|
| 87 |
+
if (!result) return 'Action effectuée.';
|
| 88 |
+
if (typeof result === 'string') return result;
|
| 89 |
+
if (Array.isArray(result)) {
|
| 90 |
+
if (result.length === 0) return 'Aucun résultat trouvé.';
|
| 91 |
+
return result.map((r: any) => {
|
| 92 |
+
if (r.name) return `• **${r.name}** — ${Object.entries(r).filter(([k]) => k !== 'name' && k !== 'id').map(([k, v]) => `${k}: ${v}`).join(', ')}`;
|
| 93 |
+
return JSON.stringify(r);
|
| 94 |
+
}).join('\n');
|
| 95 |
+
}
|
| 96 |
+
if (typeof result === 'object' && result !== null) {
|
| 97 |
+
return Object.entries(result).map(([k, v]) => `**${k}**: ${v}`).join('\n');
|
| 98 |
+
}
|
| 99 |
+
return String(result);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function renderContent(content: string) {
|
| 103 |
+
return content.split('\n').map((line, i) => {
|
| 104 |
+
const boldLine = line.replace(/\*\*(.+?)\*\*/g, (_, m) => `<strong>${m}</strong>`);
|
| 105 |
+
return <p key={i} className="text-sm" dangerouslySetInnerHTML={{ __html: boldLine }} />;
|
| 106 |
+
});
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return (
|
| 110 |
+
<div className="flex flex-col h-[calc(100vh-8rem)]">
|
| 111 |
+
<div className="mb-4">
|
| 112 |
+
<h1 className="text-xl font-bold text-white">AI Insights</h1>
|
| 113 |
+
<p className="text-sm text-slate-400 mt-0.5">Commandes en langage naturel pour gérer la plateforme</p>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{/* Suggestions */}
|
| 117 |
+
<div className="flex flex-wrap gap-2 mb-4">
|
| 118 |
+
{SUGGESTIONS.map(s => (
|
| 119 |
+
<button
|
| 120 |
+
key={s}
|
| 121 |
+
onClick={() => send(s)}
|
| 122 |
+
className="text-xs px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-full transition-colors border border-slate-700"
|
| 123 |
+
>
|
| 124 |
+
{s}
|
| 125 |
+
</button>
|
| 126 |
+
))}
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
{/* Chat messages */}
|
| 130 |
+
<div className="flex-1 overflow-y-auto space-y-4 bg-slate-900 rounded-xl border border-slate-800 p-4 mb-4">
|
| 131 |
+
{messages.map((msg, i) => (
|
| 132 |
+
<div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
|
| 133 |
+
{msg.role === 'assistant' && (
|
| 134 |
+
<div className="w-7 h-7 rounded-full bg-violet-600 flex items-center justify-center shrink-0">
|
| 135 |
+
<Bot className="w-4 h-4 text-white" />
|
| 136 |
+
</div>
|
| 137 |
+
)}
|
| 138 |
+
<div className={`max-w-[80%] space-y-1 ${msg.role === 'user' ? 'items-end flex flex-col' : ''}`}>
|
| 139 |
+
<div className={`rounded-xl px-4 py-2.5 ${msg.role === 'user' ? 'bg-violet-600 text-white' : 'bg-slate-800 text-slate-200'}`}>
|
| 140 |
+
{renderContent(msg.content)}
|
| 141 |
+
</div>
|
| 142 |
+
{msg.pending && (
|
| 143 |
+
<div className="flex gap-2 mt-1">
|
| 144 |
+
<button
|
| 145 |
+
onClick={() => confirm(msg, true)}
|
| 146 |
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-medium rounded-lg transition-colors"
|
| 147 |
+
>
|
| 148 |
+
<Check className="w-3 h-3" />
|
| 149 |
+
Confirmer
|
| 150 |
+
</button>
|
| 151 |
+
<button
|
| 152 |
+
onClick={() => confirm(msg, false)}
|
| 153 |
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 text-xs font-medium rounded-lg transition-colors"
|
| 154 |
+
>
|
| 155 |
+
<X className="w-3 h-3" />
|
| 156 |
+
Annuler
|
| 157 |
+
</button>
|
| 158 |
+
</div>
|
| 159 |
+
)}
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
))}
|
| 163 |
+
{loading && (
|
| 164 |
+
<div className="flex gap-3">
|
| 165 |
+
<div className="w-7 h-7 rounded-full bg-violet-600 flex items-center justify-center shrink-0">
|
| 166 |
+
<Bot className="w-4 h-4 text-white" />
|
| 167 |
+
</div>
|
| 168 |
+
<div className="bg-slate-800 rounded-xl px-4 py-2.5">
|
| 169 |
+
<Loader2 className="w-4 h-4 text-slate-400 animate-spin" />
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
)}
|
| 173 |
+
<div ref={endRef} />
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{/* Input */}
|
| 177 |
+
<form onSubmit={(e) => { e.preventDefault(); send(); }} className="flex gap-2">
|
| 178 |
+
<input
|
| 179 |
+
value={input}
|
| 180 |
+
onChange={e => setInput(e.target.value)}
|
| 181 |
+
placeholder="Tapez une commande... ex: 'Ajoute 500 crédits à l'org XAMLÉ'"
|
| 182 |
+
disabled={loading}
|
| 183 |
+
className="flex-1 bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500 disabled:opacity-50"
|
| 184 |
+
/>
|
| 185 |
+
<button
|
| 186 |
+
type="submit"
|
| 187 |
+
disabled={loading || !input.trim()}
|
| 188 |
+
className="px-4 py-3 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white rounded-xl transition-colors"
|
| 189 |
+
>
|
| 190 |
+
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
| 191 |
+
</button>
|
| 192 |
+
</form>
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '@/lib/auth';
|
| 3 |
+
import { api } from '@/lib/api';
|
| 4 |
+
import { useToast } from '@/hooks/useToast';
|
| 5 |
+
import { CreditCard, Plus, X } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export default function BillingManager() {
|
| 8 |
+
const { token } = useAuth();
|
| 9 |
+
const toast = useToast();
|
| 10 |
+
const [transactions, setTransactions] = useState<any[]>([]);
|
| 11 |
+
const [total, setTotal] = useState(0);
|
| 12 |
+
const [page, setPage] = useState(1);
|
| 13 |
+
const [loading, setLoading] = useState(true);
|
| 14 |
+
const [showAddCredits, setShowAddCredits] = useState(false);
|
| 15 |
+
const [creditOrgId, setCreditOrgId] = useState('');
|
| 16 |
+
const [creditAmount, setCreditAmount] = useState('');
|
| 17 |
+
const [creditDesc, setCreditDesc] = useState('');
|
| 18 |
+
const [saving, setSaving] = useState(false);
|
| 19 |
+
|
| 20 |
+
const LIMIT = 20;
|
| 21 |
+
|
| 22 |
+
async function load() {
|
| 23 |
+
if (!token) return;
|
| 24 |
+
setLoading(true);
|
| 25 |
+
try {
|
| 26 |
+
const data = await api.get(`/v1/super-admin/billing/transactions?page=${page}&limit=${LIMIT}`, token);
|
| 27 |
+
setTransactions(data.data ?? []);
|
| 28 |
+
setTotal(data.total ?? 0);
|
| 29 |
+
} catch { toast.error('Erreur chargement transactions'); }
|
| 30 |
+
finally { setLoading(false); }
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
useEffect(() => { load(); }, [page, token]);
|
| 34 |
+
|
| 35 |
+
async function handleAddCredits() {
|
| 36 |
+
if (!creditOrgId || !creditAmount) return;
|
| 37 |
+
setSaving(true);
|
| 38 |
+
try {
|
| 39 |
+
const result = await api.post('/v1/super-admin/billing/credits', {
|
| 40 |
+
orgId: creditOrgId,
|
| 41 |
+
amount: parseInt(creditAmount),
|
| 42 |
+
description: creditDesc || undefined,
|
| 43 |
+
}, token);
|
| 44 |
+
toast.success(`${creditAmount} crédits ajoutés. Nouveau solde: ${result.newBalance}`);
|
| 45 |
+
setShowAddCredits(false);
|
| 46 |
+
setCreditOrgId(''); setCreditAmount(''); setCreditDesc('');
|
| 47 |
+
load();
|
| 48 |
+
} catch { toast.error('Erreur ajout de crédits'); }
|
| 49 |
+
finally { setSaving(false); }
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const TYPE_COLORS: Record<string, string> = {
|
| 53 |
+
TOP_UP_MANUAL: 'text-emerald-400',
|
| 54 |
+
TOP_UP_PAYMENT: 'text-emerald-400',
|
| 55 |
+
DEBIT_AI: 'text-red-400',
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
return (
|
| 59 |
+
<div className="space-y-4">
|
| 60 |
+
<div className="flex items-center justify-between">
|
| 61 |
+
<div>
|
| 62 |
+
<h1 className="text-xl font-bold text-white">Billing</h1>
|
| 63 |
+
<p className="text-sm text-slate-400 mt-0.5">{total} transactions</p>
|
| 64 |
+
</div>
|
| 65 |
+
<button
|
| 66 |
+
onClick={() => setShowAddCredits(true)}
|
| 67 |
+
className="flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
| 68 |
+
>
|
| 69 |
+
<Plus className="w-4 h-4" />
|
| 70 |
+
Ajouter crédits
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
| 75 |
+
{loading ? (
|
| 76 |
+
<div className="p-8 text-center text-slate-500 animate-pulse">Chargement...</div>
|
| 77 |
+
) : transactions.length === 0 ? (
|
| 78 |
+
<div className="p-12 flex flex-col items-center gap-3 text-slate-500">
|
| 79 |
+
<CreditCard className="w-8 h-8" />
|
| 80 |
+
<p>Aucune transaction</p>
|
| 81 |
+
</div>
|
| 82 |
+
) : (
|
| 83 |
+
<div className="overflow-x-auto">
|
| 84 |
+
<table className="w-full text-sm">
|
| 85 |
+
<thead>
|
| 86 |
+
<tr className="border-b border-slate-800">
|
| 87 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Date</th>
|
| 88 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Organisation</th>
|
| 89 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Type</th>
|
| 90 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Description</th>
|
| 91 |
+
<th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Montant</th>
|
| 92 |
+
<th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Solde après</th>
|
| 93 |
+
</tr>
|
| 94 |
+
</thead>
|
| 95 |
+
<tbody className="divide-y divide-slate-800">
|
| 96 |
+
{transactions.map(t => (
|
| 97 |
+
<tr key={t.id} className="hover:bg-slate-800/50 transition-colors">
|
| 98 |
+
<td className="px-4 py-3 text-xs text-slate-400">{new Date(t.createdAt).toLocaleDateString('fr-FR')}</td>
|
| 99 |
+
<td className="px-4 py-3 text-slate-300">{t.organization?.name || '—'}</td>
|
| 100 |
+
<td className="px-4 py-3">
|
| 101 |
+
<span className={`text-xs font-medium ${TYPE_COLORS[t.type] ?? 'text-slate-300'}`}>{t.type}</span>
|
| 102 |
+
</td>
|
| 103 |
+
<td className="px-4 py-3 text-xs text-slate-400 max-w-xs truncate">{t.description || '—'}</td>
|
| 104 |
+
<td className={`px-4 py-3 text-right font-medium text-sm ${t.amount > 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
| 105 |
+
{t.amount > 0 ? '+' : ''}{t.amount.toLocaleString()}
|
| 106 |
+
</td>
|
| 107 |
+
<td className="px-4 py-3 text-right text-slate-300">{t.balanceAfter?.toLocaleString()}</td>
|
| 108 |
+
</tr>
|
| 109 |
+
))}
|
| 110 |
+
</tbody>
|
| 111 |
+
</table>
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{total > LIMIT && (
|
| 117 |
+
<div className="flex items-center justify-between text-sm text-slate-400">
|
| 118 |
+
<span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)} sur {total}</span>
|
| 119 |
+
<div className="flex gap-2">
|
| 120 |
+
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Précédent</button>
|
| 121 |
+
<button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Suivant</button>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
)}
|
| 125 |
+
|
| 126 |
+
{showAddCredits && (
|
| 127 |
+
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
|
| 128 |
+
<div className="bg-slate-900 border border-slate-800 rounded-xl p-6 w-96 space-y-4">
|
| 129 |
+
<div className="flex items-center justify-between">
|
| 130 |
+
<h2 className="text-base font-bold text-white">Ajouter des crédits</h2>
|
| 131 |
+
<button onClick={() => setShowAddCredits(false)}><X className="w-4 h-4 text-slate-400" /></button>
|
| 132 |
+
</div>
|
| 133 |
+
<div className="space-y-3">
|
| 134 |
+
<div>
|
| 135 |
+
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">ID de l'organisation</label>
|
| 136 |
+
<input value={creditOrgId} onChange={e => setCreditOrgId(e.target.value)} placeholder="org-uuid..." className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500" />
|
| 137 |
+
</div>
|
| 138 |
+
<div>
|
| 139 |
+
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Montant (crédits)</label>
|
| 140 |
+
<input type="number" value={creditAmount} onChange={e => setCreditAmount(e.target.value)} placeholder="100" className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500" />
|
| 141 |
+
</div>
|
| 142 |
+
<div>
|
| 143 |
+
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Description (optionnel)</label>
|
| 144 |
+
<input value={creditDesc} onChange={e => setCreditDesc(e.target.value)} placeholder="Rechargement manuel..." className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500" />
|
| 145 |
+
</div>
|
| 146 |
+
</div>
|
| 147 |
+
<div className="flex gap-2">
|
| 148 |
+
<button onClick={() => setShowAddCredits(false)} className="flex-1 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">Annuler</button>
|
| 149 |
+
<button onClick={handleAddCredits} disabled={saving || !creditOrgId || !creditAmount} className="flex-1 py-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors">
|
| 150 |
+
{saving ? 'Ajout...' : 'Ajouter'}
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
);
|
| 158 |
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '@/lib/auth';
|
| 3 |
+
import { api } from '@/lib/api';
|
| 4 |
+
import { useToast } from '@/hooks/useToast';
|
| 5 |
+
import { RefreshCw, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export default function MonitoringAlerts() {
|
| 8 |
+
const { token } = useAuth();
|
| 9 |
+
const toast = useToast();
|
| 10 |
+
const [health, setHealth] = useState<any>(null);
|
| 11 |
+
const [loading, setLoading] = useState(true);
|
| 12 |
+
|
| 13 |
+
async function load() {
|
| 14 |
+
if (!token) return;
|
| 15 |
+
setLoading(true);
|
| 16 |
+
try {
|
| 17 |
+
const data = await api.get('/v1/super-admin/monitoring/health', token);
|
| 18 |
+
setHealth(data);
|
| 19 |
+
} catch { toast.error('Erreur chargement monitoring'); }
|
| 20 |
+
finally { setLoading(false); }
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
useEffect(() => { load(); }, [token]);
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="space-y-6">
|
| 27 |
+
<div className="flex items-center justify-between">
|
| 28 |
+
<div>
|
| 29 |
+
<h1 className="text-xl font-bold text-white">Monitoring & Alertes</h1>
|
| 30 |
+
<p className="text-sm text-slate-400 mt-0.5">État en temps réel du système</p>
|
| 31 |
+
</div>
|
| 32 |
+
<button onClick={load} className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">
|
| 33 |
+
<RefreshCw className="w-3.5 h-3.5" />
|
| 34 |
+
Actualiser
|
| 35 |
+
</button>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
{loading ? (
|
| 39 |
+
<div className="animate-pulse space-y-4">
|
| 40 |
+
{[1, 2, 3].map(i => <div key={i} className="h-24 bg-slate-900 rounded-xl" />)}
|
| 41 |
+
</div>
|
| 42 |
+
) : (
|
| 43 |
+
<>
|
| 44 |
+
{/* System health */}
|
| 45 |
+
<div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
|
| 46 |
+
<h2 className="text-sm font-semibold text-white mb-4">Santé système</h2>
|
| 47 |
+
<div className="grid grid-cols-3 gap-4">
|
| 48 |
+
{[
|
| 49 |
+
{ label: 'Base de données', ok: health?.db?.ok },
|
| 50 |
+
{ label: 'Redis / Cache', ok: health?.redis?.ok },
|
| 51 |
+
{ label: 'Queue jobs', ok: (health?.queue?.failed ?? 0) === 0, detail: health?.queue?.failed > 0 ? `${health.queue.failed} échoués` : `${health?.queue?.waiting ?? 0} en attente` },
|
| 52 |
+
].map(({ label, ok, detail }) => (
|
| 53 |
+
<div key={label} className={`p-4 rounded-lg border ${ok ? 'bg-emerald-950/30 border-emerald-900/50' : 'bg-red-950/30 border-red-900/50'}`}>
|
| 54 |
+
<div className="flex items-center gap-2 mb-1">
|
| 55 |
+
{ok ? <CheckCircle className="w-4 h-4 text-emerald-400" /> : <XCircle className="w-4 h-4 text-red-400" />}
|
| 56 |
+
<span className="text-sm font-medium text-white">{label}</span>
|
| 57 |
+
</div>
|
| 58 |
+
{detail && <div className="text-xs text-slate-400 ml-6">{detail}</div>}
|
| 59 |
+
</div>
|
| 60 |
+
))}
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
{/* Token expiry alerts */}
|
| 65 |
+
<div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
|
| 66 |
+
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
| 67 |
+
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
| 68 |
+
Tokens WhatsApp expirants
|
| 69 |
+
</h2>
|
| 70 |
+
{!health?.tokenExpiries?.length ? (
|
| 71 |
+
<p className="text-sm text-slate-500">Aucun token en risque d'expiration</p>
|
| 72 |
+
) : (
|
| 73 |
+
<div className="overflow-x-auto">
|
| 74 |
+
<table className="w-full text-sm">
|
| 75 |
+
<thead>
|
| 76 |
+
<tr className="border-b border-slate-800">
|
| 77 |
+
<th className="text-left pb-2 text-xs font-medium text-slate-400">Organisation</th>
|
| 78 |
+
<th className="text-left pb-2 text-xs font-medium text-slate-400">Émis il y a</th>
|
| 79 |
+
</tr>
|
| 80 |
+
</thead>
|
| 81 |
+
<tbody className="divide-y divide-slate-800/50">
|
| 82 |
+
{health.tokenExpiries.map((t: any) => (
|
| 83 |
+
<tr key={t.orgId}>
|
| 84 |
+
<td className="py-2 text-white">{t.orgName}</td>
|
| 85 |
+
<td className={`py-2 text-sm font-medium ${(t.daysOld ?? 0) > 55 ? 'text-red-400' : 'text-amber-400'}`}>
|
| 86 |
+
{t.daysOld ?? '?'} jours
|
| 87 |
+
</td>
|
| 88 |
+
</tr>
|
| 89 |
+
))}
|
| 90 |
+
</tbody>
|
| 91 |
+
</table>
|
| 92 |
+
</div>
|
| 93 |
+
)}
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
{/* Low balance alerts */}
|
| 97 |
+
<div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
|
| 98 |
+
<h2 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
| 99 |
+
<AlertTriangle className="w-4 h-4 text-red-400" />
|
| 100 |
+
Soldes faibles (< 100 crédits)
|
| 101 |
+
</h2>
|
| 102 |
+
{!health?.lowBalanceOrgs?.length ? (
|
| 103 |
+
<p className="text-sm text-slate-500">Aucune organisation avec solde faible</p>
|
| 104 |
+
) : (
|
| 105 |
+
<div className="space-y-2">
|
| 106 |
+
{health.lowBalanceOrgs.map((o: any) => (
|
| 107 |
+
<div key={o.id} className="flex items-center justify-between bg-slate-800 rounded-lg px-3 py-2">
|
| 108 |
+
<span className="text-sm text-white">{o.name}</span>
|
| 109 |
+
<span className="text-sm font-medium text-red-400">{o.walletBalance} crédits</span>
|
| 110 |
+
</div>
|
| 111 |
+
))}
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</>
|
| 116 |
+
)}
|
| 117 |
+
</div>
|
| 118 |
+
);
|
| 119 |
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '@/lib/auth';
|
| 3 |
+
import { api } from '@/lib/api';
|
| 4 |
+
import { useToast } from '@/hooks/useToast';
|
| 5 |
+
import { Search, Plus, Pencil, Ban, Check, X } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
const PLAN_COLORS: Record<string, string> = {
|
| 8 |
+
STARTER: 'bg-slate-700 text-slate-300',
|
| 9 |
+
GROWTH: 'bg-blue-900/60 text-blue-300',
|
| 10 |
+
SCALE: 'bg-violet-900/60 text-violet-300',
|
| 11 |
+
ENTERPRISE: 'bg-amber-900/60 text-amber-300',
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
function StatusBadge({ isHardStopped, status }: { isHardStopped: boolean; status: string }) {
|
| 15 |
+
if (isHardStopped) return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/60 text-red-300">Suspendu</span>;
|
| 16 |
+
if (status === 'TRIAL') return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-amber-900/60 text-amber-300">Trial</span>;
|
| 17 |
+
return <span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-900/60 text-emerald-300">Actif</span>;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function OrganizationsManager() {
|
| 21 |
+
const { token } = useAuth();
|
| 22 |
+
const toast = useToast();
|
| 23 |
+
const [orgs, setOrgs] = useState<any[]>([]);
|
| 24 |
+
const [total, setTotal] = useState(0);
|
| 25 |
+
const [page, setPage] = useState(1);
|
| 26 |
+
const [search, setSearch] = useState('');
|
| 27 |
+
const [loading, setLoading] = useState(true);
|
| 28 |
+
const [editOrg, setEditOrg] = useState<any>(null);
|
| 29 |
+
const [showCreate, setShowCreate] = useState(false);
|
| 30 |
+
const [newOrgName, setNewOrgName] = useState('');
|
| 31 |
+
const [saving, setSaving] = useState(false);
|
| 32 |
+
|
| 33 |
+
const LIMIT = 20;
|
| 34 |
+
|
| 35 |
+
async function load() {
|
| 36 |
+
if (!token) return;
|
| 37 |
+
setLoading(true);
|
| 38 |
+
try {
|
| 39 |
+
const params = new URLSearchParams({ page: String(page), limit: String(LIMIT) });
|
| 40 |
+
if (search) params.set('search', search);
|
| 41 |
+
const data = await api.get(`/v1/super-admin/organizations?${params}`, token);
|
| 42 |
+
setOrgs(data.data ?? []);
|
| 43 |
+
setTotal(data.total ?? 0);
|
| 44 |
+
} catch { toast.error('Erreur chargement organisations'); }
|
| 45 |
+
finally { setLoading(false); }
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
useEffect(() => { load(); }, [page, token]);
|
| 49 |
+
|
| 50 |
+
async function handleSearch(e: React.FormEvent) {
|
| 51 |
+
e.preventDefault();
|
| 52 |
+
setPage(1);
|
| 53 |
+
load();
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
async function handleSuspend(org: any) {
|
| 57 |
+
try {
|
| 58 |
+
await api.post(`/v1/super-admin/organizations/${org.id}/suspend`, { suspend: !org.isHardStopped }, token);
|
| 59 |
+
toast.success(org.isHardStopped ? 'Organisation réactivée' : 'Organisation suspendue');
|
| 60 |
+
load();
|
| 61 |
+
} catch { toast.error('Erreur modification statut'); }
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async function handleSaveEdit() {
|
| 65 |
+
if (!editOrg) return;
|
| 66 |
+
setSaving(true);
|
| 67 |
+
try {
|
| 68 |
+
await api.patch(`/v1/super-admin/organizations/${editOrg.id}`, {
|
| 69 |
+
subscriptionPlan: editOrg.subscriptionPlan,
|
| 70 |
+
aiCreditsLimit: editOrg.aiCreditsLimit,
|
| 71 |
+
isCrmActive: editOrg.isCrmActive,
|
| 72 |
+
isEdTechActive: editOrg.isEdTechActive,
|
| 73 |
+
}, token);
|
| 74 |
+
toast.success('Organisation mise à jour');
|
| 75 |
+
setEditOrg(null);
|
| 76 |
+
load();
|
| 77 |
+
} catch { toast.error('Erreur mise à jour'); }
|
| 78 |
+
finally { setSaving(false); }
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async function handleCreate() {
|
| 82 |
+
if (!newOrgName.trim()) return;
|
| 83 |
+
setSaving(true);
|
| 84 |
+
try {
|
| 85 |
+
await api.post('/v1/super-admin/organizations', { name: newOrgName }, token);
|
| 86 |
+
toast.success('Organisation créée');
|
| 87 |
+
setShowCreate(false);
|
| 88 |
+
setNewOrgName('');
|
| 89 |
+
load();
|
| 90 |
+
} catch { toast.error('Erreur création'); }
|
| 91 |
+
finally { setSaving(false); }
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
return (
|
| 95 |
+
<div className="space-y-4">
|
| 96 |
+
<div className="flex items-center justify-between">
|
| 97 |
+
<div>
|
| 98 |
+
<h1 className="text-xl font-bold text-white">Organisations</h1>
|
| 99 |
+
<p className="text-sm text-slate-400 mt-0.5">{total} organisations au total</p>
|
| 100 |
+
</div>
|
| 101 |
+
<button
|
| 102 |
+
onClick={() => setShowCreate(true)}
|
| 103 |
+
className="flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
|
| 104 |
+
>
|
| 105 |
+
<Plus className="w-4 h-4" />
|
| 106 |
+
Nouvelle organisation
|
| 107 |
+
</button>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
{/* Search */}
|
| 111 |
+
<form onSubmit={handleSearch} className="flex gap-2">
|
| 112 |
+
<div className="relative flex-1">
|
| 113 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
| 114 |
+
<input
|
| 115 |
+
value={search}
|
| 116 |
+
onChange={e => setSearch(e.target.value)}
|
| 117 |
+
placeholder="Rechercher une organisation..."
|
| 118 |
+
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
|
| 119 |
+
/>
|
| 120 |
+
</div>
|
| 121 |
+
<button type="submit" className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition-colors">Rechercher</button>
|
| 122 |
+
</form>
|
| 123 |
+
|
| 124 |
+
{/* Table */}
|
| 125 |
+
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
| 126 |
+
{loading ? (
|
| 127 |
+
<div className="p-8 text-center text-slate-500 animate-pulse">Chargement...</div>
|
| 128 |
+
) : orgs.length === 0 ? (
|
| 129 |
+
<div className="p-8 text-center text-slate-500">Aucune organisation trouvée</div>
|
| 130 |
+
) : (
|
| 131 |
+
<div className="overflow-x-auto">
|
| 132 |
+
<table className="w-full text-sm">
|
| 133 |
+
<thead>
|
| 134 |
+
<tr className="border-b border-slate-800">
|
| 135 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Nom</th>
|
| 136 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Plan</th>
|
| 137 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Statut</th>
|
| 138 |
+
<th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Utilisateurs</th>
|
| 139 |
+
<th className="text-right px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Crédits</th>
|
| 140 |
+
<th className="px-4 py-3" />
|
| 141 |
+
</tr>
|
| 142 |
+
</thead>
|
| 143 |
+
<tbody className="divide-y divide-slate-800">
|
| 144 |
+
{orgs.map(org => (
|
| 145 |
+
<tr key={org.id} className="hover:bg-slate-800/50 transition-colors">
|
| 146 |
+
<td className="px-4 py-3">
|
| 147 |
+
<div className="font-medium text-white">{org.name}</div>
|
| 148 |
+
<div className="text-xs text-slate-500 font-mono">{org.id.slice(0, 8)}…</div>
|
| 149 |
+
</td>
|
| 150 |
+
<td className="px-4 py-3">
|
| 151 |
+
<span className={`text-xs px-2 py-0.5 rounded-full ${PLAN_COLORS[org.subscriptionPlan] ?? 'bg-slate-700 text-slate-300'}`}>
|
| 152 |
+
{org.subscriptionPlan}
|
| 153 |
+
</span>
|
| 154 |
+
</td>
|
| 155 |
+
<td className="px-4 py-3">
|
| 156 |
+
<StatusBadge isHardStopped={org.isHardStopped} status={org.subscriptionStatus ?? 'ACTIVE'} />
|
| 157 |
+
</td>
|
| 158 |
+
<td className="px-4 py-3 text-right text-slate-300">{org.userCount}</td>
|
| 159 |
+
<td className="px-4 py-3 text-right text-slate-300">{org.walletBalance?.toLocaleString()}</td>
|
| 160 |
+
<td className="px-4 py-3">
|
| 161 |
+
<div className="flex items-center justify-end gap-1">
|
| 162 |
+
<button
|
| 163 |
+
onClick={() => setEditOrg({ ...org })}
|
| 164 |
+
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
|
| 165 |
+
title="Modifier"
|
| 166 |
+
>
|
| 167 |
+
<Pencil className="w-3.5 h-3.5" />
|
| 168 |
+
</button>
|
| 169 |
+
<button
|
| 170 |
+
onClick={() => handleSuspend(org)}
|
| 171 |
+
className={`p-1.5 rounded transition-colors ${org.isHardStopped ? 'text-emerald-400 hover:text-emerald-300 hover:bg-slate-700' : 'text-red-400 hover:text-red-300 hover:bg-slate-700'}`}
|
| 172 |
+
title={org.isHardStopped ? 'Réactiver' : 'Suspendre'}
|
| 173 |
+
>
|
| 174 |
+
{org.isHardStopped ? <Check className="w-3.5 h-3.5" /> : <Ban className="w-3.5 h-3.5" />}
|
| 175 |
+
</button>
|
| 176 |
+
</div>
|
| 177 |
+
</td>
|
| 178 |
+
</tr>
|
| 179 |
+
))}
|
| 180 |
+
</tbody>
|
| 181 |
+
</table>
|
| 182 |
+
</div>
|
| 183 |
+
)}
|
| 184 |
+
</div>
|
| 185 |
+
|
| 186 |
+
{/* Pagination */}
|
| 187 |
+
{total > LIMIT && (
|
| 188 |
+
<div className="flex items-center justify-between text-sm text-slate-400">
|
| 189 |
+
<span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)} sur {total}</span>
|
| 190 |
+
<div className="flex gap-2">
|
| 191 |
+
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Précédent</button>
|
| 192 |
+
<button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Suivant</button>
|
| 193 |
+
</div>
|
| 194 |
+
</div>
|
| 195 |
+
)}
|
| 196 |
+
|
| 197 |
+
{/* Edit drawer */}
|
| 198 |
+
{editOrg && (
|
| 199 |
+
<div className="fixed inset-0 bg-black/60 z-50 flex justify-end">
|
| 200 |
+
<div className="w-96 bg-slate-900 border-l border-slate-800 h-full overflow-y-auto p-6 space-y-5">
|
| 201 |
+
<div className="flex items-center justify-between">
|
| 202 |
+
<h2 className="text-base font-bold text-white">{editOrg.name}</h2>
|
| 203 |
+
<button onClick={() => setEditOrg(null)} className="p-1.5 text-slate-400 hover:text-white"><X className="w-4 h-4" /></button>
|
| 204 |
+
</div>
|
| 205 |
+
<div className="space-y-4">
|
| 206 |
+
<div>
|
| 207 |
+
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Plan</label>
|
| 208 |
+
<select
|
| 209 |
+
value={editOrg.subscriptionPlan}
|
| 210 |
+
onChange={e => setEditOrg({ ...editOrg, subscriptionPlan: e.target.value })}
|
| 211 |
+
className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500"
|
| 212 |
+
>
|
| 213 |
+
{['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE'].map(p => <option key={p} value={p}>{p}</option>)}
|
| 214 |
+
</select>
|
| 215 |
+
</div>
|
| 216 |
+
<div>
|
| 217 |
+
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Limite crédits AI</label>
|
| 218 |
+
<input
|
| 219 |
+
type="number"
|
| 220 |
+
value={editOrg.aiCreditsLimit}
|
| 221 |
+
onChange={e => setEditOrg({ ...editOrg, aiCreditsLimit: parseInt(e.target.value) })}
|
| 222 |
+
className="mt-1.5 w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-violet-500"
|
| 223 |
+
/>
|
| 224 |
+
</div>
|
| 225 |
+
<div className="flex items-center justify-between">
|
| 226 |
+
<span className="text-sm text-slate-300">CRM actif</span>
|
| 227 |
+
<button
|
| 228 |
+
onClick={() => setEditOrg({ ...editOrg, isCrmActive: !editOrg.isCrmActive })}
|
| 229 |
+
className={`w-10 h-5 rounded-full transition-colors relative ${editOrg.isCrmActive ? 'bg-violet-600' : 'bg-slate-700'}`}
|
| 230 |
+
>
|
| 231 |
+
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-all ${editOrg.isCrmActive ? 'left-5' : 'left-0.5'}`} />
|
| 232 |
+
</button>
|
| 233 |
+
</div>
|
| 234 |
+
<div className="flex items-center justify-between">
|
| 235 |
+
<span className="text-sm text-slate-300">EdTech actif</span>
|
| 236 |
+
<button
|
| 237 |
+
onClick={() => setEditOrg({ ...editOrg, isEdTechActive: !editOrg.isEdTechActive })}
|
| 238 |
+
className={`w-10 h-5 rounded-full transition-colors relative ${editOrg.isEdTechActive ? 'bg-violet-600' : 'bg-slate-700'}`}
|
| 239 |
+
>
|
| 240 |
+
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full transition-all ${editOrg.isEdTechActive ? 'left-5' : 'left-0.5'}`} />
|
| 241 |
+
</button>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
<button
|
| 245 |
+
onClick={handleSaveEdit}
|
| 246 |
+
disabled={saving}
|
| 247 |
+
className="w-full bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white font-medium py-2 rounded-lg text-sm transition-colors"
|
| 248 |
+
>
|
| 249 |
+
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
|
| 250 |
+
</button>
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
)}
|
| 254 |
+
|
| 255 |
+
{/* Create modal */}
|
| 256 |
+
{showCreate && (
|
| 257 |
+
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
|
| 258 |
+
<div className="bg-slate-900 border border-slate-800 rounded-xl p-6 w-96 space-y-4">
|
| 259 |
+
<h2 className="text-base font-bold text-white">Nouvelle organisation</h2>
|
| 260 |
+
<input
|
| 261 |
+
autoFocus
|
| 262 |
+
value={newOrgName}
|
| 263 |
+
onChange={e => setNewOrgName(e.target.value)}
|
| 264 |
+
placeholder="Nom de l'organisation"
|
| 265 |
+
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
|
| 266 |
+
/>
|
| 267 |
+
<div className="flex gap-2">
|
| 268 |
+
<button onClick={() => setShowCreate(false)} className="flex-1 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">Annuler</button>
|
| 269 |
+
<button onClick={handleCreate} disabled={saving || !newOrgName.trim()} className="flex-1 py-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors">
|
| 270 |
+
{saving ? 'Création...' : 'Créer'}
|
| 271 |
+
</button>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
)}
|
| 276 |
+
</div>
|
| 277 |
+
);
|
| 278 |
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '@/lib/auth';
|
| 3 |
+
import { api } from '@/lib/api';
|
| 4 |
+
import { Building2, Users, MessageSquare, Layers, AlertTriangle, TrendingUp } from 'lucide-react';
|
| 5 |
+
|
| 6 |
+
interface Stats {
|
| 7 |
+
orgsCount: number;
|
| 8 |
+
activeOrgs: number;
|
| 9 |
+
usersCount: number;
|
| 10 |
+
messagesLast24h: number;
|
| 11 |
+
queueDepth: number;
|
| 12 |
+
queueFailed: number;
|
| 13 |
+
revenueThisMonth: number;
|
| 14 |
+
tokenExpiries?: any[];
|
| 15 |
+
lowBalanceOrgs?: any[];
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function KPICard({ title, value, icon: Icon, color, sub }: { title: string; value: any; icon: any; color: string; sub?: string }) {
|
| 19 |
+
return (
|
| 20 |
+
<div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
|
| 21 |
+
<div className="flex items-start justify-between">
|
| 22 |
+
<div>
|
| 23 |
+
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">{title}</p>
|
| 24 |
+
<p className="text-2xl font-bold text-white mt-1">{value?.toLocaleString() ?? '—'}</p>
|
| 25 |
+
{sub && <p className="text-xs text-slate-500 mt-0.5">{sub}</p>}
|
| 26 |
+
</div>
|
| 27 |
+
<div className={`p-2.5 rounded-lg ${color}`}>
|
| 28 |
+
<Icon className="w-4 h-4 text-white" />
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export default function PlatformDashboard() {
|
| 36 |
+
const { token } = useAuth();
|
| 37 |
+
const [stats, setStats] = useState<Stats | null>(null);
|
| 38 |
+
const [health, setHealth] = useState<any>(null);
|
| 39 |
+
const [loading, setLoading] = useState(true);
|
| 40 |
+
|
| 41 |
+
useEffect(() => {
|
| 42 |
+
if (!token) return;
|
| 43 |
+
Promise.all([
|
| 44 |
+
api.get('/v1/super-admin/platform/stats', token),
|
| 45 |
+
api.get('/v1/super-admin/monitoring/health', token),
|
| 46 |
+
])
|
| 47 |
+
.then(([s, h]) => { setStats(s); setHealth(h); })
|
| 48 |
+
.finally(() => setLoading(false));
|
| 49 |
+
}, [token]);
|
| 50 |
+
|
| 51 |
+
if (loading) return (
|
| 52 |
+
<div className="space-y-4 animate-pulse">
|
| 53 |
+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
| 54 |
+
{[...Array(6)].map((_, i) => <div key={i} className="h-28 bg-slate-900 rounded-xl" />)}
|
| 55 |
+
</div>
|
| 56 |
+
</div>
|
| 57 |
+
);
|
| 58 |
+
|
| 59 |
+
const alerts = [
|
| 60 |
+
...(health?.tokenExpiries?.map((t: any) => ({ type: 'token', msg: `Token expirant — ${t.orgName} (${t.daysOld}j)` })) ?? []),
|
| 61 |
+
...(health?.lowBalanceOrgs?.map((o: any) => ({ type: 'balance', msg: `Solde faible — ${o.orgName}: ${o.walletBalance} crédits` })) ?? []),
|
| 62 |
+
...(health?.queue?.failed > 0 ? [{ type: 'queue', msg: `${health.queue.failed} jobs échoués en queue` }] : []),
|
| 63 |
+
];
|
| 64 |
+
|
| 65 |
+
return (
|
| 66 |
+
<div className="space-y-6">
|
| 67 |
+
<div>
|
| 68 |
+
<h1 className="text-xl font-bold text-white">Platform Dashboard</h1>
|
| 69 |
+
<p className="text-sm text-slate-400 mt-0.5">Vue globale de toutes les organisations</p>
|
| 70 |
+
</div>
|
| 71 |
+
|
| 72 |
+
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
| 73 |
+
<KPICard title="Organisations" value={stats?.orgsCount} icon={Building2} color="bg-violet-600" sub={`${stats?.activeOrgs ?? 0} actives`} />
|
| 74 |
+
<KPICard title="Utilisateurs" value={stats?.usersCount} icon={Users} color="bg-blue-600" />
|
| 75 |
+
<KPICard title="Messages / 24h" value={stats?.messagesLast24h} icon={MessageSquare} color="bg-emerald-600" />
|
| 76 |
+
<KPICard title="Queue depth" value={stats?.queueDepth} icon={Layers} color={(stats?.queueDepth ?? 0) > 50 ? 'bg-amber-600' : 'bg-slate-700'} sub={stats?.queueFailed ? `${stats.queueFailed} échoués` : undefined} />
|
| 77 |
+
<KPICard title="Revenus / mois" value={`${(stats?.revenueThisMonth ?? 0).toLocaleString()} cr.`} icon={TrendingUp} color="bg-pink-600" />
|
| 78 |
+
<KPICard title="Alertes" value={alerts.length} icon={AlertTriangle} color={alerts.length > 0 ? 'bg-red-600' : 'bg-slate-700'} />
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
{/* System health */}
|
| 82 |
+
<div className="bg-slate-900 rounded-xl p-5 border border-slate-800">
|
| 83 |
+
<h2 className="text-sm font-semibold text-white mb-3">Santé système</h2>
|
| 84 |
+
<div className="flex flex-wrap gap-3">
|
| 85 |
+
{[
|
| 86 |
+
{ label: 'Base de données', ok: health?.db?.ok },
|
| 87 |
+
{ label: 'Redis', ok: health?.redis?.ok },
|
| 88 |
+
{ label: 'Queue', ok: (health?.queue?.failed ?? 0) === 0 },
|
| 89 |
+
].map(({ label, ok }) => (
|
| 90 |
+
<div key={label} className="flex items-center gap-2 bg-slate-800 rounded-lg px-3 py-2">
|
| 91 |
+
<div className={`w-2 h-2 rounded-full ${ok ? 'bg-green-400' : 'bg-red-400'}`} />
|
| 92 |
+
<span className="text-xs text-slate-300">{label}</span>
|
| 93 |
+
<span className={`text-xs font-medium ${ok ? 'text-green-400' : 'text-red-400'}`}>{ok ? 'OK' : 'KO'}</span>
|
| 94 |
+
</div>
|
| 95 |
+
))}
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
{/* Alerts */}
|
| 100 |
+
{alerts.length > 0 && (
|
| 101 |
+
<div className="bg-slate-900 rounded-xl p-5 border border-red-900/50">
|
| 102 |
+
<h2 className="text-sm font-semibold text-red-400 mb-3 flex items-center gap-2">
|
| 103 |
+
<AlertTriangle className="w-4 h-4" />
|
| 104 |
+
Alertes actives ({alerts.length})
|
| 105 |
+
</h2>
|
| 106 |
+
<div className="space-y-2">
|
| 107 |
+
{alerts.map((a, i) => (
|
| 108 |
+
<div key={i} className="flex items-center gap-2 text-sm text-slate-300 bg-slate-800 rounded-lg px-3 py-2">
|
| 109 |
+
<div className="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0" />
|
| 110 |
+
{a.msg}
|
| 111 |
+
</div>
|
| 112 |
+
))}
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
)}
|
| 116 |
+
</div>
|
| 117 |
+
);
|
| 118 |
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
| 2 |
+
import { useAuth } from '@/lib/auth';
|
| 3 |
+
import { useTenant } from '@/lib/tenant';
|
| 4 |
+
import {
|
| 5 |
+
LayoutDashboard, Building2, Users, Phone, Activity,
|
| 6 |
+
CreditCard, Bot, LogOut, ChevronLeft
|
| 7 |
+
} from 'lucide-react';
|
| 8 |
+
|
| 9 |
+
const NAV_ITEMS = [
|
| 10 |
+
{ to: '/platform/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
| 11 |
+
{ to: '/platform/organizations', icon: Building2, label: 'Organisations' },
|
| 12 |
+
{ to: '/platform/users', icon: Users, label: 'Utilisateurs' },
|
| 13 |
+
{ to: '/platform/whatsapp', icon: Phone, label: 'WhatsApp' },
|
| 14 |
+
{ to: '/platform/monitoring', icon: Activity, label: 'Monitoring' },
|
| 15 |
+
{ to: '/platform/billing', icon: CreditCard, label: 'Billing' },
|
| 16 |
+
{ to: '/platform/ai', icon: Bot, label: 'AI Insights' },
|
| 17 |
+
];
|
| 18 |
+
|
| 19 |
+
export default function SuperAdminLayout() {
|
| 20 |
+
const { user, logout } = useAuth();
|
| 21 |
+
const { setSelectedOrgId } = useTenant();
|
| 22 |
+
const navigate = useNavigate();
|
| 23 |
+
|
| 24 |
+
function handleExit() {
|
| 25 |
+
setSelectedOrgId(null);
|
| 26 |
+
navigate('/');
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
return (
|
| 30 |
+
<div className="flex h-screen bg-slate-950 text-white overflow-hidden">
|
| 31 |
+
{/* Sidebar */}
|
| 32 |
+
<aside className="w-60 shrink-0 bg-slate-900 border-r border-slate-800 flex flex-col">
|
| 33 |
+
<div className="px-4 py-5 border-b border-slate-800">
|
| 34 |
+
<div className="text-xs font-semibold text-violet-400 uppercase tracking-widest mb-0.5">XAMLÉ</div>
|
| 35 |
+
<div className="text-base font-bold text-white">Platform Admin</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-0.5">
|
| 39 |
+
{NAV_ITEMS.map(({ to, icon: Icon, label }) => (
|
| 40 |
+
<NavLink
|
| 41 |
+
key={to}
|
| 42 |
+
to={to}
|
| 43 |
+
className={({ isActive }) =>
|
| 44 |
+
`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
| 45 |
+
isActive
|
| 46 |
+
? 'bg-violet-600 text-white'
|
| 47 |
+
: 'text-slate-400 hover:text-white hover:bg-slate-800'
|
| 48 |
+
}`
|
| 49 |
+
}
|
| 50 |
+
>
|
| 51 |
+
<Icon className="w-4 h-4 shrink-0" />
|
| 52 |
+
{label}
|
| 53 |
+
</NavLink>
|
| 54 |
+
))}
|
| 55 |
+
</nav>
|
| 56 |
+
|
| 57 |
+
<div className="p-3 border-t border-slate-800 space-y-1">
|
| 58 |
+
<button
|
| 59 |
+
onClick={handleExit}
|
| 60 |
+
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
| 61 |
+
>
|
| 62 |
+
<ChevronLeft className="w-4 h-4" />
|
| 63 |
+
Quitter l'admin
|
| 64 |
+
</button>
|
| 65 |
+
<button
|
| 66 |
+
onClick={logout}
|
| 67 |
+
className="flex items-center gap-3 w-full px-3 py-2 rounded-lg text-sm text-red-400 hover:text-red-300 hover:bg-slate-800 transition-colors"
|
| 68 |
+
>
|
| 69 |
+
<LogOut className="w-4 h-4" />
|
| 70 |
+
Déconnexion
|
| 71 |
+
</button>
|
| 72 |
+
</div>
|
| 73 |
+
</aside>
|
| 74 |
+
|
| 75 |
+
{/* Main */}
|
| 76 |
+
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
| 77 |
+
{/* Top bar */}
|
| 78 |
+
<header className="h-14 shrink-0 bg-slate-900 border-b border-slate-800 flex items-center justify-between px-6">
|
| 79 |
+
<div className="text-sm text-slate-400">
|
| 80 |
+
Super-admin · <span className="text-white">{user?.email || user?.name || 'Admin'}</span>
|
| 81 |
+
</div>
|
| 82 |
+
<div className="flex items-center gap-2">
|
| 83 |
+
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
| 84 |
+
<span className="text-xs text-slate-400">Système actif</span>
|
| 85 |
+
</div>
|
| 86 |
+
</header>
|
| 87 |
+
|
| 88 |
+
{/* Page content */}
|
| 89 |
+
<main className="flex-1 overflow-y-auto bg-slate-950">
|
| 90 |
+
<div className="p-6 max-w-screen-xl mx-auto">
|
| 91 |
+
<Outlet />
|
| 92 |
+
</div>
|
| 93 |
+
</main>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
);
|
| 97 |
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Routes, Route, Navigate } from 'react-router-dom';
|
| 2 |
+
import SuperAdminLayout from './SuperAdminLayout';
|
| 3 |
+
import PlatformDashboard from './PlatformDashboard';
|
| 4 |
+
import OrganizationsManager from './OrganizationsManager';
|
| 5 |
+
import UsersManager from './UsersManager';
|
| 6 |
+
import WhatsAppNumbers from './WhatsAppNumbers';
|
| 7 |
+
import MonitoringAlerts from './MonitoringAlerts';
|
| 8 |
+
import BillingManager from './BillingManager';
|
| 9 |
+
import AIInsights from './AIInsights';
|
| 10 |
+
|
| 11 |
+
export default function SuperAdminRouter() {
|
| 12 |
+
return (
|
| 13 |
+
<Routes>
|
| 14 |
+
<Route path="/*" element={<SuperAdminLayout />}>
|
| 15 |
+
<Route index element={<Navigate to="/platform/dashboard" replace />} />
|
| 16 |
+
<Route path="platform/dashboard" element={<PlatformDashboard />} />
|
| 17 |
+
<Route path="platform/organizations" element={<OrganizationsManager />} />
|
| 18 |
+
<Route path="platform/users" element={<UsersManager />} />
|
| 19 |
+
<Route path="platform/whatsapp" element={<WhatsAppNumbers />} />
|
| 20 |
+
<Route path="platform/monitoring" element={<MonitoringAlerts />} />
|
| 21 |
+
<Route path="platform/billing" element={<BillingManager />} />
|
| 22 |
+
<Route path="platform/ai" element={<AIInsights />} />
|
| 23 |
+
<Route path="*" element={<Navigate to="/platform/dashboard" replace />} />
|
| 24 |
+
</Route>
|
| 25 |
+
</Routes>
|
| 26 |
+
);
|
| 27 |
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '@/lib/auth';
|
| 3 |
+
import { api } from '@/lib/api';
|
| 4 |
+
import { useToast } from '@/hooks/useToast';
|
| 5 |
+
import { Search } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
const ROLE_COLORS: Record<string, string> = {
|
| 8 |
+
STUDENT: 'bg-slate-700 text-slate-300',
|
| 9 |
+
ORG_MEMBER: 'bg-blue-900/60 text-blue-300',
|
| 10 |
+
ORG_ADMIN: 'bg-violet-900/60 text-violet-300',
|
| 11 |
+
ADMIN: 'bg-amber-900/60 text-amber-300',
|
| 12 |
+
SUPER_ADMIN: 'bg-red-900/60 text-red-300',
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
export default function UsersManager() {
|
| 16 |
+
const { token } = useAuth();
|
| 17 |
+
const toast = useToast();
|
| 18 |
+
const [users, setUsers] = useState<any[]>([]);
|
| 19 |
+
const [total, setTotal] = useState(0);
|
| 20 |
+
const [page, setPage] = useState(1);
|
| 21 |
+
const [search, setSearch] = useState('');
|
| 22 |
+
const [loading, setLoading] = useState(true);
|
| 23 |
+
|
| 24 |
+
const LIMIT = 20;
|
| 25 |
+
|
| 26 |
+
async function load() {
|
| 27 |
+
if (!token) return;
|
| 28 |
+
setLoading(true);
|
| 29 |
+
try {
|
| 30 |
+
const params = new URLSearchParams({ page: String(page), limit: String(LIMIT) });
|
| 31 |
+
if (search) params.set('search', search);
|
| 32 |
+
const data = await api.get(`/v1/super-admin/users?${params}`, token);
|
| 33 |
+
setUsers(data.data ?? []);
|
| 34 |
+
setTotal(data.total ?? 0);
|
| 35 |
+
} catch { toast.error('Erreur chargement utilisateurs'); }
|
| 36 |
+
finally { setLoading(false); }
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
useEffect(() => { load(); }, [page, token]);
|
| 40 |
+
|
| 41 |
+
async function handleSearch(e: React.FormEvent) {
|
| 42 |
+
e.preventDefault();
|
| 43 |
+
setPage(1);
|
| 44 |
+
load();
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
async function handleRoleChange(userId: string, role: string) {
|
| 48 |
+
try {
|
| 49 |
+
await api.patch(`/v1/super-admin/users/${userId}/role`, { role }, token);
|
| 50 |
+
toast.success('Rôle mis à jour');
|
| 51 |
+
load();
|
| 52 |
+
} catch { toast.error('Erreur changement de rôle'); }
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<div className="space-y-4">
|
| 57 |
+
<div>
|
| 58 |
+
<h1 className="text-xl font-bold text-white">Utilisateurs</h1>
|
| 59 |
+
<p className="text-sm text-slate-400 mt-0.5">{total} utilisateurs au total</p>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<form onSubmit={handleSearch} className="flex gap-2">
|
| 63 |
+
<div className="relative flex-1">
|
| 64 |
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
| 65 |
+
<input
|
| 66 |
+
value={search}
|
| 67 |
+
onChange={e => setSearch(e.target.value)}
|
| 68 |
+
placeholder="Rechercher par nom ou email..."
|
| 69 |
+
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-violet-500"
|
| 70 |
+
/>
|
| 71 |
+
</div>
|
| 72 |
+
<button type="submit" className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition-colors">Rechercher</button>
|
| 73 |
+
</form>
|
| 74 |
+
|
| 75 |
+
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
| 76 |
+
{loading ? (
|
| 77 |
+
<div className="p-8 text-center text-slate-500 animate-pulse">Chargement...</div>
|
| 78 |
+
) : users.length === 0 ? (
|
| 79 |
+
<div className="p-8 text-center text-slate-500">Aucun utilisateur trouvé</div>
|
| 80 |
+
) : (
|
| 81 |
+
<div className="overflow-x-auto">
|
| 82 |
+
<table className="w-full text-sm">
|
| 83 |
+
<thead>
|
| 84 |
+
<tr className="border-b border-slate-800">
|
| 85 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Utilisateur</th>
|
| 86 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Organisation</th>
|
| 87 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Rôle</th>
|
| 88 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Créé le</th>
|
| 89 |
+
<th className="px-4 py-3" />
|
| 90 |
+
</tr>
|
| 91 |
+
</thead>
|
| 92 |
+
<tbody className="divide-y divide-slate-800">
|
| 93 |
+
{users.map(user => (
|
| 94 |
+
<tr key={user.id} className="hover:bg-slate-800/50 transition-colors">
|
| 95 |
+
<td className="px-4 py-3">
|
| 96 |
+
<div className="font-medium text-white">{user.name || '—'}</div>
|
| 97 |
+
<div className="text-xs text-slate-500">{user.email || user.phone || '—'}</div>
|
| 98 |
+
</td>
|
| 99 |
+
<td className="px-4 py-3 text-slate-300 text-xs">{user.organization?.name || '—'}</td>
|
| 100 |
+
<td className="px-4 py-3">
|
| 101 |
+
<span className={`text-xs px-2 py-0.5 rounded-full ${ROLE_COLORS[user.role] ?? 'bg-slate-700 text-slate-300'}`}>{user.role}</span>
|
| 102 |
+
</td>
|
| 103 |
+
<td className="px-4 py-3 text-xs text-slate-400">{new Date(user.createdAt).toLocaleDateString('fr-FR')}</td>
|
| 104 |
+
<td className="px-4 py-3">
|
| 105 |
+
<select
|
| 106 |
+
defaultValue={user.role}
|
| 107 |
+
onChange={e => handleRoleChange(user.id, e.target.value)}
|
| 108 |
+
className="text-xs bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-300 focus:outline-none focus:border-violet-500"
|
| 109 |
+
>
|
| 110 |
+
{['STUDENT', 'ORG_MEMBER', 'ORG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].map(r => <option key={r} value={r}>{r}</option>)}
|
| 111 |
+
</select>
|
| 112 |
+
</td>
|
| 113 |
+
</tr>
|
| 114 |
+
))}
|
| 115 |
+
</tbody>
|
| 116 |
+
</table>
|
| 117 |
+
</div>
|
| 118 |
+
)}
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
{total > LIMIT && (
|
| 122 |
+
<div className="flex items-center justify-between text-sm text-slate-400">
|
| 123 |
+
<span>{((page - 1) * LIMIT) + 1}–{Math.min(page * LIMIT, total)} sur {total}</span>
|
| 124 |
+
<div className="flex gap-2">
|
| 125 |
+
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Précédent</button>
|
| 126 |
+
<button onClick={() => setPage(p => p + 1)} disabled={page * LIMIT >= total} className="px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-lg disabled:opacity-40 hover:bg-slate-800 transition-colors">Suivant</button>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useEffect } from 'react';
|
| 2 |
+
import { useAuth } from '@/lib/auth';
|
| 3 |
+
import { api } from '@/lib/api';
|
| 4 |
+
import { useToast } from '@/hooks/useToast';
|
| 5 |
+
import { Phone, RefreshCw } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
export default function WhatsAppNumbers() {
|
| 8 |
+
const { token } = useAuth();
|
| 9 |
+
const toast = useToast();
|
| 10 |
+
const [numbers, setNumbers] = useState<any[]>([]);
|
| 11 |
+
const [loading, setLoading] = useState(true);
|
| 12 |
+
|
| 13 |
+
async function load() {
|
| 14 |
+
if (!token) return;
|
| 15 |
+
setLoading(true);
|
| 16 |
+
try {
|
| 17 |
+
const data = await api.get('/v1/super-admin/whatsapp/numbers', token);
|
| 18 |
+
setNumbers(data.data ?? []);
|
| 19 |
+
} catch { toast.error('Erreur chargement numéros'); }
|
| 20 |
+
finally { setLoading(false); }
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
useEffect(() => { load(); }, [token]);
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="space-y-4">
|
| 27 |
+
<div className="flex items-center justify-between">
|
| 28 |
+
<div>
|
| 29 |
+
<h1 className="text-xl font-bold text-white">Numéros WhatsApp</h1>
|
| 30 |
+
<p className="text-sm text-slate-400 mt-0.5">{numbers.length} numéros enregistrés</p>
|
| 31 |
+
</div>
|
| 32 |
+
<button onClick={load} className="flex items-center gap-2 px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition-colors">
|
| 33 |
+
<RefreshCw className="w-3.5 h-3.5" />
|
| 34 |
+
Actualiser
|
| 35 |
+
</button>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div className="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
| 39 |
+
{loading ? (
|
| 40 |
+
<div className="p-8 text-center text-slate-500 animate-pulse">Chargement...</div>
|
| 41 |
+
) : numbers.length === 0 ? (
|
| 42 |
+
<div className="p-12 flex flex-col items-center gap-3 text-slate-500">
|
| 43 |
+
<Phone className="w-8 h-8" />
|
| 44 |
+
<p>Aucun numéro WhatsApp enregistré</p>
|
| 45 |
+
</div>
|
| 46 |
+
) : (
|
| 47 |
+
<div className="overflow-x-auto">
|
| 48 |
+
<table className="w-full text-sm">
|
| 49 |
+
<thead>
|
| 50 |
+
<tr className="border-b border-slate-800">
|
| 51 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Numéro</th>
|
| 52 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Organisation</th>
|
| 53 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">ID</th>
|
| 54 |
+
<th className="text-left px-4 py-3 text-xs font-medium text-slate-400 uppercase tracking-wider">Ajouté le</th>
|
| 55 |
+
</tr>
|
| 56 |
+
</thead>
|
| 57 |
+
<tbody className="divide-y divide-slate-800">
|
| 58 |
+
{numbers.map(n => (
|
| 59 |
+
<tr key={n.id} className="hover:bg-slate-800/50 transition-colors">
|
| 60 |
+
<td className="px-4 py-3 font-medium text-white">{n.displayPhone}</td>
|
| 61 |
+
<td className="px-4 py-3 text-slate-300">{n.organization?.name || '—'}</td>
|
| 62 |
+
<td className="px-4 py-3 text-xs font-mono text-slate-500">{n.id}</td>
|
| 63 |
+
<td className="px-4 py-3 text-xs text-slate-400">{new Date(n.createdAt).toLocaleDateString('fr-FR')}</td>
|
| 64 |
+
</tr>
|
| 65 |
+
))}
|
| 66 |
+
</tbody>
|
| 67 |
+
</table>
|
| 68 |
+
</div>
|
| 69 |
+
)}
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
);
|
| 73 |
+
}
|
|
@@ -17,6 +17,7 @@ import { notificationRoutes } from './routes/notifications';
|
|
| 17 |
import { authRoutes } from './routes/auth';
|
| 18 |
import campaignRoutes from './routes/campaigns';
|
| 19 |
import { internalRoutes } from './routes/internal';
|
|
|
|
| 20 |
import { setupErrorHandler } from './utils/errors';
|
| 21 |
import { injectTenantConfig } from './middleware/tenant';
|
| 22 |
import { validateApiKey } from './middleware/validateApiKey';
|
|
@@ -47,7 +48,10 @@ export async function buildApp() {
|
|
| 47 |
});
|
| 48 |
|
| 49 |
await server.register(jwt, {
|
| 50 |
-
secret:
|
|
|
|
|
|
|
|
|
|
| 51 |
});
|
| 52 |
|
| 53 |
await server.register(rateLimit, {
|
|
@@ -91,6 +95,7 @@ export async function buildApp() {
|
|
| 91 |
scope.register(billingRoutes, { prefix: '/v1/billing' });
|
| 92 |
scope.register(notificationRoutes, { prefix: '/v1/notifications' });
|
| 93 |
scope.register(campaignRoutes, { prefix: '/v1/organizations' });
|
|
|
|
| 94 |
});
|
| 95 |
|
| 96 |
server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
|
|
|
|
| 17 |
import { authRoutes } from './routes/auth';
|
| 18 |
import campaignRoutes from './routes/campaigns';
|
| 19 |
import { internalRoutes } from './routes/internal';
|
| 20 |
+
import { superAdminRoutes } from './routes/super-admin';
|
| 21 |
import { setupErrorHandler } from './utils/errors';
|
| 22 |
import { injectTenantConfig } from './middleware/tenant';
|
| 23 |
import { validateApiKey } from './middleware/validateApiKey';
|
|
|
|
| 48 |
});
|
| 49 |
|
| 50 |
await server.register(jwt, {
|
| 51 |
+
secret: (() => {
|
| 52 |
+
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET env var is required');
|
| 53 |
+
return process.env.JWT_SECRET;
|
| 54 |
+
})()
|
| 55 |
});
|
| 56 |
|
| 57 |
await server.register(rateLimit, {
|
|
|
|
| 95 |
scope.register(billingRoutes, { prefix: '/v1/billing' });
|
| 96 |
scope.register(notificationRoutes, { prefix: '/v1/notifications' });
|
| 97 |
scope.register(campaignRoutes, { prefix: '/v1/organizations' });
|
| 98 |
+
scope.register(superAdminRoutes, { prefix: '/v1/super-admin' });
|
| 99 |
});
|
| 100 |
|
| 101 |
server.register(whatsappRoutes, { prefix: '/v1/whatsapp' });
|
|
@@ -56,6 +56,13 @@ const PaginationSchema = z.object({
|
|
| 56 |
|
| 57 |
export async function adminRoutes(fastify: FastifyInstance) {
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
// ── Dashboard Stats ────────────────────────────────────────────────────────
|
| 60 |
fastify.get('/stats', async (req, reply) => {
|
| 61 |
const organizationId = req.organizationId;
|
|
|
|
| 56 |
|
| 57 |
export async function adminRoutes(fastify: FastifyInstance) {
|
| 58 |
|
| 59 |
+
fastify.addHook('preHandler', async (request, reply) => {
|
| 60 |
+
const role = (request as any).user?.role;
|
| 61 |
+
if (!role || !['ORG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(role)) {
|
| 62 |
+
return reply.code(403).send({ error: 'Admin access required' });
|
| 63 |
+
}
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
// ── Dashboard Stats ────────────────────────────────────────────────────────
|
| 67 |
fastify.get('/stats', async (req, reply) => {
|
| 68 |
const organizationId = req.organizationId;
|
|
@@ -241,12 +241,17 @@ Règles IMPÉRATIVES:
|
|
| 241 |
return reply.code(503).send({ error: 'AI service unavailable' });
|
| 242 |
}
|
| 243 |
|
| 244 |
-
// Safety: only allow SELECT — reject
|
| 245 |
const normalized = sql.replace(/\s+/g, ' ').trim().toUpperCase();
|
| 246 |
if (!normalized.startsWith('SELECT')) {
|
| 247 |
logger.warn({ sql }, '[TEXT-SQL] Non-SELECT query rejected');
|
| 248 |
return reply.code(400).send({ error: 'Only SELECT queries are allowed', generatedSql: sql });
|
| 249 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
// Inject correct organizationId (replace placeholder used by LLM)
|
| 252 |
const safeSql = sql
|
|
|
|
| 241 |
return reply.code(503).send({ error: 'AI service unavailable' });
|
| 242 |
}
|
| 243 |
|
| 244 |
+
// Safety: only allow simple SELECT — reject dangerous patterns
|
| 245 |
const normalized = sql.replace(/\s+/g, ' ').trim().toUpperCase();
|
| 246 |
if (!normalized.startsWith('SELECT')) {
|
| 247 |
logger.warn({ sql }, '[TEXT-SQL] Non-SELECT query rejected');
|
| 248 |
return reply.code(400).send({ error: 'Only SELECT queries are allowed', generatedSql: sql });
|
| 249 |
}
|
| 250 |
+
const DANGEROUS_PATTERNS = [/\bUNION\b/, /\bINSERT\b/, /\bUPDATE\b/, /\bDELETE\b/, /\bDROP\b/, /\bEXEC\b/, /\bEXECUTE\b/, /--/, /\/\*/, /;\s*SELECT/i];
|
| 251 |
+
if (DANGEROUS_PATTERNS.some(p => p.test(normalized))) {
|
| 252 |
+
logger.warn({ sql }, '[TEXT-SQL] Dangerous pattern detected — query rejected');
|
| 253 |
+
return reply.code(400).send({ error: 'Query contains disallowed patterns' });
|
| 254 |
+
}
|
| 255 |
|
| 256 |
// Inject correct organizationId (replace placeholder used by LLM)
|
| 257 |
const safeSql = sql
|
|
@@ -0,0 +1,528 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FastifyInstance } from 'fastify';
|
| 2 |
+
import { prisma } from '../services/prisma';
|
| 3 |
+
import { logger } from '../logger';
|
| 4 |
+
import { z } from 'zod';
|
| 5 |
+
import { redis } from '../lib/redis';
|
| 6 |
+
import { whatsappQueue } from '../services/queue';
|
| 7 |
+
|
| 8 |
+
export async function superAdminRoutes(fastify: FastifyInstance) {
|
| 9 |
+
|
| 10 |
+
fastify.addHook('preHandler', async (request: any, reply) => {
|
| 11 |
+
const role = request.user?.role;
|
| 12 |
+
if (role !== 'SUPER_ADMIN' && role !== 'ADMIN') {
|
| 13 |
+
return reply.code(403).send({ error: 'Super admin access required' });
|
| 14 |
+
}
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
// ── Platform Stats ────────────────────────────────────────────────────────
|
| 18 |
+
fastify.get('/platform/stats', async (_req, reply) => {
|
| 19 |
+
try {
|
| 20 |
+
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
| 21 |
+
const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
| 22 |
+
|
| 23 |
+
const [orgsCount, usersCount, messagesLast24h, revenueAgg, queueCounts] = await Promise.all([
|
| 24 |
+
prisma.organization.count(),
|
| 25 |
+
prisma.user.count({ where: { deletedAt: null } }),
|
| 26 |
+
prisma.message.count({ where: { createdAt: { gte: since24h } } }),
|
| 27 |
+
prisma.walletTransaction.aggregate({
|
| 28 |
+
where: { type: 'TOP_UP_MANUAL', createdAt: { gte: since30d } },
|
| 29 |
+
_sum: { amount: true }
|
| 30 |
+
}),
|
| 31 |
+
Promise.all([
|
| 32 |
+
whatsappQueue.getWaitingCount(),
|
| 33 |
+
whatsappQueue.getFailedCount(),
|
| 34 |
+
whatsappQueue.getActiveCount(),
|
| 35 |
+
]).catch(() => [0, 0, 0]),
|
| 36 |
+
]);
|
| 37 |
+
|
| 38 |
+
const activeOrgs = await prisma.organization.count({
|
| 39 |
+
where: { isHardStopped: false }
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
return {
|
| 43 |
+
orgsCount,
|
| 44 |
+
activeOrgs,
|
| 45 |
+
usersCount,
|
| 46 |
+
messagesLast24h,
|
| 47 |
+
queueDepth: Array.isArray(queueCounts) ? (queueCounts[0] + queueCounts[2]) : 0,
|
| 48 |
+
queueFailed: Array.isArray(queueCounts) ? queueCounts[1] : 0,
|
| 49 |
+
revenueThisMonth: revenueAgg._sum.amount ?? 0,
|
| 50 |
+
};
|
| 51 |
+
} catch (err) {
|
| 52 |
+
logger.error({ err }, '[SUPER_ADMIN] platform/stats failed');
|
| 53 |
+
return reply.code(500).send({ error: 'Failed to fetch platform stats' });
|
| 54 |
+
}
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
// ── Organizations ─────────────────────────────────────────────────────────
|
| 58 |
+
fastify.get('/organizations', async (req, reply) => {
|
| 59 |
+
const QuerySchema = z.object({
|
| 60 |
+
page: z.coerce.number().int().positive().default(1),
|
| 61 |
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
| 62 |
+
search: z.string().optional(),
|
| 63 |
+
plan: z.string().optional(),
|
| 64 |
+
});
|
| 65 |
+
const parsed = QuerySchema.safeParse(req.query);
|
| 66 |
+
if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' });
|
| 67 |
+
|
| 68 |
+
const { page, limit, search, plan } = parsed.data;
|
| 69 |
+
const where: any = {};
|
| 70 |
+
if (search) where.name = { contains: search, mode: 'insensitive' };
|
| 71 |
+
if (plan) where.subscriptionPlan = plan;
|
| 72 |
+
|
| 73 |
+
try {
|
| 74 |
+
const [orgs, total] = await Promise.all([
|
| 75 |
+
prisma.organization.findMany({
|
| 76 |
+
where,
|
| 77 |
+
select: {
|
| 78 |
+
id: true, name: true, wabaId: true, walletBalance: true,
|
| 79 |
+
subscriptionPlan: true, subscriptionStatus: true, isHardStopped: true,
|
| 80 |
+
createdAt: true, mode: true, isCrmActive: true, isEdTechActive: true,
|
| 81 |
+
_count: { select: { users: true } }
|
| 82 |
+
},
|
| 83 |
+
orderBy: { createdAt: 'desc' },
|
| 84 |
+
skip: (page - 1) * limit,
|
| 85 |
+
take: limit,
|
| 86 |
+
}),
|
| 87 |
+
prisma.organization.count({ where }),
|
| 88 |
+
]);
|
| 89 |
+
return { data: orgs.map(o => ({ ...o, userCount: o._count.users })), total, page, limit };
|
| 90 |
+
} catch (err) {
|
| 91 |
+
logger.error({ err }, '[SUPER_ADMIN] organizations list failed');
|
| 92 |
+
return reply.code(500).send({ error: 'Failed to list organizations' });
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
fastify.post('/organizations', async (req, reply) => {
|
| 97 |
+
const Schema = z.object({
|
| 98 |
+
name: z.string().min(1),
|
| 99 |
+
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).default('STARTER'),
|
| 100 |
+
aiCreditsLimit: z.number().int().positive().default(500),
|
| 101 |
+
isCrmActive: z.boolean().default(false),
|
| 102 |
+
isEdTechActive: z.boolean().default(true),
|
| 103 |
+
});
|
| 104 |
+
const body = Schema.safeParse(req.body);
|
| 105 |
+
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 106 |
+
|
| 107 |
+
try {
|
| 108 |
+
const org = await prisma.organization.create({ data: body.data });
|
| 109 |
+
return reply.code(201).send(org);
|
| 110 |
+
} catch (err: any) {
|
| 111 |
+
logger.error({ err }, '[SUPER_ADMIN] org create failed');
|
| 112 |
+
return reply.code(500).send({ error: 'Failed to create organization' });
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
fastify.patch<{ Params: { id: string } }>('/organizations/:id', async (req, reply) => {
|
| 117 |
+
const Schema = z.object({
|
| 118 |
+
name: z.string().min(1).optional(),
|
| 119 |
+
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).optional(),
|
| 120 |
+
subscriptionStatus: z.string().optional(),
|
| 121 |
+
aiCreditsLimit: z.number().int().positive().optional(),
|
| 122 |
+
isHardStopped: z.boolean().optional(),
|
| 123 |
+
isCrmActive: z.boolean().optional(),
|
| 124 |
+
isEdTechActive: z.boolean().optional(),
|
| 125 |
+
walletBalance: z.number().int().optional(),
|
| 126 |
+
});
|
| 127 |
+
const body = Schema.safeParse(req.body);
|
| 128 |
+
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 129 |
+
|
| 130 |
+
try {
|
| 131 |
+
const org = await prisma.organization.update({
|
| 132 |
+
where: { id: req.params.id },
|
| 133 |
+
data: body.data,
|
| 134 |
+
});
|
| 135 |
+
return org;
|
| 136 |
+
} catch (err: any) {
|
| 137 |
+
logger.error({ err, orgId: req.params.id }, '[SUPER_ADMIN] org update failed');
|
| 138 |
+
if (err.code === 'P2025') return reply.code(404).send({ error: 'Organization not found' });
|
| 139 |
+
return reply.code(500).send({ error: 'Failed to update organization' });
|
| 140 |
+
}
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
// Suspend / activate shortcut
|
| 144 |
+
fastify.post<{ Params: { id: string } }>('/organizations/:id/suspend', async (req, reply) => {
|
| 145 |
+
const Schema = z.object({ suspend: z.boolean() });
|
| 146 |
+
const body = Schema.safeParse(req.body);
|
| 147 |
+
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 148 |
+
try {
|
| 149 |
+
await prisma.organization.update({
|
| 150 |
+
where: { id: req.params.id },
|
| 151 |
+
data: { isHardStopped: body.data.suspend, subscriptionStatus: body.data.suspend ? 'SUSPENDED' : 'ACTIVE' }
|
| 152 |
+
});
|
| 153 |
+
return { ok: true };
|
| 154 |
+
} catch (err: any) {
|
| 155 |
+
if (err.code === 'P2025') return reply.code(404).send({ error: 'Organization not found' });
|
| 156 |
+
return reply.code(500).send({ error: 'Failed to suspend organization' });
|
| 157 |
+
}
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
// Impersonation — returns a short-lived JWT scoped to the target org
|
| 161 |
+
fastify.post<{ Params: { id: string } }>('/organizations/:id/impersonate', async (req, reply) => {
|
| 162 |
+
try {
|
| 163 |
+
const org = await prisma.organization.findUnique({ where: { id: req.params.id }, select: { id: true, name: true } });
|
| 164 |
+
if (!org) return reply.code(404).send({ error: 'Organization not found' });
|
| 165 |
+
|
| 166 |
+
const impersonateToken = (fastify as any).jwt.sign(
|
| 167 |
+
{ id: (req as any).user.id, role: 'ORG_ADMIN', organizationId: org.id, impersonating: true },
|
| 168 |
+
{ expiresIn: '1h' }
|
| 169 |
+
);
|
| 170 |
+
return { token: impersonateToken, orgName: org.name };
|
| 171 |
+
} catch (err) {
|
| 172 |
+
logger.error({ err }, '[SUPER_ADMIN] impersonate failed');
|
| 173 |
+
return reply.code(500).send({ error: 'Impersonation failed' });
|
| 174 |
+
}
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
// ── Users (cross-org) ─────────────────────────────────────────────────────
|
| 178 |
+
fastify.get('/users', async (req, reply) => {
|
| 179 |
+
const QuerySchema = z.object({
|
| 180 |
+
page: z.coerce.number().int().positive().default(1),
|
| 181 |
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
| 182 |
+
orgId: z.string().optional(),
|
| 183 |
+
role: z.string().optional(),
|
| 184 |
+
search: z.string().optional(),
|
| 185 |
+
});
|
| 186 |
+
const parsed = QuerySchema.safeParse(req.query);
|
| 187 |
+
if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' });
|
| 188 |
+
|
| 189 |
+
const { page, limit, orgId, role, search } = parsed.data;
|
| 190 |
+
const where: any = { deletedAt: null };
|
| 191 |
+
if (orgId) where.organizationId = orgId;
|
| 192 |
+
if (role) where.role = role;
|
| 193 |
+
if (search) where.OR = [
|
| 194 |
+
{ name: { contains: search, mode: 'insensitive' } },
|
| 195 |
+
{ email: { contains: search, mode: 'insensitive' } },
|
| 196 |
+
];
|
| 197 |
+
|
| 198 |
+
try {
|
| 199 |
+
const [users, total] = await Promise.all([
|
| 200 |
+
prisma.user.findMany({
|
| 201 |
+
where,
|
| 202 |
+
select: { id: true, name: true, email: true, phone: true, role: true, organizationId: true, createdAt: true, lastActivityAt: true,
|
| 203 |
+
organization: { select: { name: true } }
|
| 204 |
+
},
|
| 205 |
+
orderBy: { createdAt: 'desc' },
|
| 206 |
+
skip: (page - 1) * limit,
|
| 207 |
+
take: limit,
|
| 208 |
+
}),
|
| 209 |
+
prisma.user.count({ where }),
|
| 210 |
+
]);
|
| 211 |
+
return { data: users, total, page, limit };
|
| 212 |
+
} catch (err) {
|
| 213 |
+
logger.error({ err }, '[SUPER_ADMIN] users list failed');
|
| 214 |
+
return reply.code(500).send({ error: 'Failed to list users' });
|
| 215 |
+
}
|
| 216 |
+
});
|
| 217 |
+
|
| 218 |
+
fastify.patch<{ Params: { userId: string } }>('/users/:userId/role', async (req, reply) => {
|
| 219 |
+
const Schema = z.object({ role: z.enum(['STUDENT', 'ORG_MEMBER', 'ORG_ADMIN', 'ADMIN', 'SUPER_ADMIN']) });
|
| 220 |
+
const body = Schema.safeParse(req.body);
|
| 221 |
+
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 222 |
+
try {
|
| 223 |
+
const user = await prisma.user.update({ where: { id: req.params.userId }, data: { role: body.data.role } });
|
| 224 |
+
return { ok: true, role: user.role };
|
| 225 |
+
} catch (err: any) {
|
| 226 |
+
if (err.code === 'P2025') return reply.code(404).send({ error: 'User not found' });
|
| 227 |
+
return reply.code(500).send({ error: 'Failed to update role' });
|
| 228 |
+
}
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
// ── WhatsApp Numbers (cross-org) ──────────────────────────────────────────
|
| 232 |
+
fastify.get('/whatsapp/numbers', async (_req, reply) => {
|
| 233 |
+
try {
|
| 234 |
+
const numbers = await prisma.whatsAppPhoneNumber.findMany({
|
| 235 |
+
include: { organization: { select: { id: true, name: true } } },
|
| 236 |
+
orderBy: { createdAt: 'desc' },
|
| 237 |
+
});
|
| 238 |
+
return { data: numbers };
|
| 239 |
+
} catch (err) {
|
| 240 |
+
logger.error({ err }, '[SUPER_ADMIN] whatsapp numbers failed');
|
| 241 |
+
return reply.code(500).send({ error: 'Failed to list phone numbers' });
|
| 242 |
+
}
|
| 243 |
+
});
|
| 244 |
+
|
| 245 |
+
// ── Billing ───────────────────────────────────────────────────────────────
|
| 246 |
+
fastify.get('/billing/transactions', async (req, reply) => {
|
| 247 |
+
const QuerySchema = z.object({
|
| 248 |
+
page: z.coerce.number().int().positive().default(1),
|
| 249 |
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
| 250 |
+
orgId: z.string().optional(),
|
| 251 |
+
});
|
| 252 |
+
const parsed = QuerySchema.safeParse(req.query);
|
| 253 |
+
if (!parsed.success) return reply.code(400).send({ error: 'Invalid query params' });
|
| 254 |
+
|
| 255 |
+
const { page, limit, orgId } = parsed.data;
|
| 256 |
+
const where: any = {};
|
| 257 |
+
if (orgId) where.organizationId = orgId;
|
| 258 |
+
|
| 259 |
+
try {
|
| 260 |
+
const [txns, total] = await Promise.all([
|
| 261 |
+
prisma.walletTransaction.findMany({
|
| 262 |
+
where,
|
| 263 |
+
include: { organization: { select: { id: true, name: true } } },
|
| 264 |
+
orderBy: { createdAt: 'desc' },
|
| 265 |
+
skip: (page - 1) * limit,
|
| 266 |
+
take: limit,
|
| 267 |
+
}),
|
| 268 |
+
prisma.walletTransaction.count({ where }),
|
| 269 |
+
]);
|
| 270 |
+
return { data: txns, total, page, limit };
|
| 271 |
+
} catch (err) {
|
| 272 |
+
logger.error({ err }, '[SUPER_ADMIN] billing transactions failed');
|
| 273 |
+
return reply.code(500).send({ error: 'Failed to list transactions' });
|
| 274 |
+
}
|
| 275 |
+
});
|
| 276 |
+
|
| 277 |
+
fastify.post('/billing/credits', async (req, reply) => {
|
| 278 |
+
const Schema = z.object({
|
| 279 |
+
orgId: z.string(),
|
| 280 |
+
amount: z.number().int().positive(),
|
| 281 |
+
description: z.string().optional(),
|
| 282 |
+
});
|
| 283 |
+
const body = Schema.safeParse(req.body);
|
| 284 |
+
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 285 |
+
|
| 286 |
+
try {
|
| 287 |
+
const org = await prisma.organization.findUnique({ where: { id: body.data.orgId }, select: { walletBalance: true } });
|
| 288 |
+
if (!org) return reply.code(404).send({ error: 'Organization not found' });
|
| 289 |
+
|
| 290 |
+
const newBalance = org.walletBalance + body.data.amount;
|
| 291 |
+
const [updatedOrg, txn] = await prisma.$transaction([
|
| 292 |
+
prisma.organization.update({ where: { id: body.data.orgId }, data: { walletBalance: newBalance } }),
|
| 293 |
+
prisma.walletTransaction.create({
|
| 294 |
+
data: {
|
| 295 |
+
organizationId: body.data.orgId,
|
| 296 |
+
amount: body.data.amount,
|
| 297 |
+
balanceAfter: newBalance,
|
| 298 |
+
type: 'TOP_UP_MANUAL',
|
| 299 |
+
description: body.data.description || 'Manual credit by super-admin',
|
| 300 |
+
actorId: (req as any).user?.id,
|
| 301 |
+
}
|
| 302 |
+
})
|
| 303 |
+
]);
|
| 304 |
+
return { ok: true, newBalance: updatedOrg.walletBalance, transactionId: txn.id };
|
| 305 |
+
} catch (err) {
|
| 306 |
+
logger.error({ err }, '[SUPER_ADMIN] add credits failed');
|
| 307 |
+
return reply.code(500).send({ error: 'Failed to add credits' });
|
| 308 |
+
}
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
// ── Monitoring ─────────────────────────────────────────────��──────────────
|
| 312 |
+
fastify.get('/monitoring/health', async (_req, reply) => {
|
| 313 |
+
try {
|
| 314 |
+
const [dbPing, redisPing, queueWaiting, queueFailed, queueActive] = await Promise.all([
|
| 315 |
+
prisma.$queryRaw`SELECT 1`.then(() => true).catch(() => false),
|
| 316 |
+
redis.ping().then(() => true).catch(() => false),
|
| 317 |
+
whatsappQueue.getWaitingCount().catch(() => -1),
|
| 318 |
+
whatsappQueue.getFailedCount().catch(() => -1),
|
| 319 |
+
whatsappQueue.getActiveCount().catch(() => -1),
|
| 320 |
+
]);
|
| 321 |
+
|
| 322 |
+
// Token expiry check — find orgs with systemUserTokenIssuedAt older than 50 days
|
| 323 |
+
const tokenExpiryThreshold = new Date(Date.now() - 50 * 24 * 60 * 60 * 1000);
|
| 324 |
+
const expiringTokenOrgs = await prisma.organization.findMany({
|
| 325 |
+
where: {
|
| 326 |
+
systemUserTokenIssuedAt: { lte: tokenExpiryThreshold },
|
| 327 |
+
systemUserToken: { not: null }
|
| 328 |
+
},
|
| 329 |
+
select: { id: true, name: true, systemUserTokenIssuedAt: true }
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
// Low wallet balance
|
| 333 |
+
const lowBalanceOrgs = await prisma.organization.findMany({
|
| 334 |
+
where: { walletBalance: { lt: 100 }, isHardStopped: false },
|
| 335 |
+
select: { id: true, name: true, walletBalance: true }
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
+
return {
|
| 339 |
+
db: { ok: dbPing },
|
| 340 |
+
redis: { ok: redisPing },
|
| 341 |
+
queue: { waiting: queueWaiting, failed: queueFailed, active: queueActive },
|
| 342 |
+
tokenExpiries: expiringTokenOrgs.map(o => ({
|
| 343 |
+
orgId: o.id,
|
| 344 |
+
orgName: o.name,
|
| 345 |
+
issuedAt: o.systemUserTokenIssuedAt,
|
| 346 |
+
daysOld: o.systemUserTokenIssuedAt
|
| 347 |
+
? Math.floor((Date.now() - o.systemUserTokenIssuedAt.getTime()) / (24 * 60 * 60 * 1000))
|
| 348 |
+
: null
|
| 349 |
+
})),
|
| 350 |
+
lowBalanceOrgs,
|
| 351 |
+
};
|
| 352 |
+
} catch (err) {
|
| 353 |
+
logger.error({ err }, '[SUPER_ADMIN] monitoring failed');
|
| 354 |
+
return reply.code(500).send({ error: 'Failed to fetch monitoring data' });
|
| 355 |
+
}
|
| 356 |
+
});
|
| 357 |
+
|
| 358 |
+
// ── AI Agentic Command ────────────────────────────────────────────────────
|
| 359 |
+
fastify.post('/ai/command', async (req, reply) => {
|
| 360 |
+
const Schema = z.object({
|
| 361 |
+
command: z.string().min(1),
|
| 362 |
+
confirm: z.boolean().default(false),
|
| 363 |
+
pendingAction: z.object({
|
| 364 |
+
action: z.string(),
|
| 365 |
+
params: z.record(z.unknown()),
|
| 366 |
+
}).optional(),
|
| 367 |
+
});
|
| 368 |
+
const body = Schema.safeParse(req.body);
|
| 369 |
+
if (!body.success) return reply.code(400).send({ error: body.error.flatten() });
|
| 370 |
+
|
| 371 |
+
const { command, confirm, pendingAction } = body.data;
|
| 372 |
+
|
| 373 |
+
// If confirming a pending action
|
| 374 |
+
if (confirm && pendingAction) {
|
| 375 |
+
return executeSuperAdminAction(pendingAction.action, pendingAction.params, req, reply);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
// Parse command with AI
|
| 379 |
+
const { aiService } = await import('../services/ai');
|
| 380 |
+
const { z: zod } = await import('zod');
|
| 381 |
+
|
| 382 |
+
const CommandSchema = zod.object({
|
| 383 |
+
action: zod.enum(['QUERY_STATS', 'LIST_ORGS', 'SUSPEND_ORG', 'ACTIVATE_ORG', 'ADD_CREDITS', 'LIST_LOW_BALANCE', 'LIST_ALERTS', 'LIST_USERS']),
|
| 384 |
+
params: zod.record(zod.unknown()).default({}),
|
| 385 |
+
confirmation_required: zod.boolean(),
|
| 386 |
+
human_summary: zod.string(),
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
try {
|
| 390 |
+
const systemPrompt = `You are a super-admin AI assistant for the XAMLÉ platform. Parse the admin's command and return a structured action.
|
| 391 |
+
Available actions:
|
| 392 |
+
- QUERY_STATS: Get platform statistics (no params needed)
|
| 393 |
+
- LIST_ORGS: List organizations (params: search?, plan?)
|
| 394 |
+
- SUSPEND_ORG: Suspend an organization (params: orgName or orgId) — confirmation required
|
| 395 |
+
- ACTIVATE_ORG: Reactivate a suspended org (params: orgName or orgId) — confirmation required
|
| 396 |
+
- ADD_CREDITS: Add wallet credits to an org (params: orgName or orgId, amount) — confirmation required
|
| 397 |
+
- LIST_LOW_BALANCE: List orgs with low wallet balance
|
| 398 |
+
- LIST_ALERTS: List monitoring alerts
|
| 399 |
+
- LIST_USERS: List users (params: orgName?, role?)
|
| 400 |
+
|
| 401 |
+
Always extract organization names and amounts from the command. Set confirmation_required=true for destructive or financial actions.`;
|
| 402 |
+
|
| 403 |
+
const { data: parsed } = await aiService.generateStructuredData(
|
| 404 |
+
`${systemPrompt}\n\nAdmin command: "${command}"`,
|
| 405 |
+
CommandSchema,
|
| 406 |
+
0.1
|
| 407 |
+
);
|
| 408 |
+
|
| 409 |
+
if (parsed.confirmation_required) {
|
| 410 |
+
return {
|
| 411 |
+
status: 'pending_confirmation',
|
| 412 |
+
summary: parsed.human_summary,
|
| 413 |
+
action: parsed.action,
|
| 414 |
+
params: parsed.params,
|
| 415 |
+
};
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
return executeSuperAdminAction(parsed.action, parsed.params ?? {}, req, reply);
|
| 419 |
+
} catch (err) {
|
| 420 |
+
logger.error({ err }, '[SUPER_ADMIN] AI command failed');
|
| 421 |
+
return reply.code(503).send({ error: 'AI service unavailable' });
|
| 422 |
+
}
|
| 423 |
+
});
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
async function executeSuperAdminAction(action: string, params: Record<string, unknown>, req: any, reply: any) {
|
| 427 |
+
try {
|
| 428 |
+
switch (action) {
|
| 429 |
+
case 'QUERY_STATS': {
|
| 430 |
+
const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
| 431 |
+
const [orgsCount, usersCount, messagesLast24h] = await Promise.all([
|
| 432 |
+
prisma.organization.count(),
|
| 433 |
+
prisma.user.count({ where: { deletedAt: null } }),
|
| 434 |
+
prisma.message.count({ where: { createdAt: { gte: since24h } } }),
|
| 435 |
+
]);
|
| 436 |
+
return { status: 'success', result: { orgsCount, usersCount, messagesLast24h } };
|
| 437 |
+
}
|
| 438 |
+
case 'LIST_ORGS': {
|
| 439 |
+
const orgs = await prisma.organization.findMany({
|
| 440 |
+
where: params.search ? { name: { contains: params.search as string, mode: 'insensitive' } } : undefined,
|
| 441 |
+
select: { id: true, name: true, subscriptionPlan: true, walletBalance: true, isHardStopped: true },
|
| 442 |
+
take: 20,
|
| 443 |
+
orderBy: { createdAt: 'desc' },
|
| 444 |
+
});
|
| 445 |
+
return { status: 'success', result: orgs };
|
| 446 |
+
}
|
| 447 |
+
case 'LIST_LOW_BALANCE': {
|
| 448 |
+
const orgs = await prisma.organization.findMany({
|
| 449 |
+
where: { walletBalance: { lt: 100 } },
|
| 450 |
+
select: { id: true, name: true, walletBalance: true },
|
| 451 |
+
orderBy: { walletBalance: 'asc' },
|
| 452 |
+
});
|
| 453 |
+
return { status: 'success', result: orgs };
|
| 454 |
+
}
|
| 455 |
+
case 'LIST_ALERTS': {
|
| 456 |
+
const tokenThreshold = new Date(Date.now() - 50 * 24 * 60 * 60 * 1000);
|
| 457 |
+
const [expiringTokens, lowBalance] = await Promise.all([
|
| 458 |
+
prisma.organization.findMany({
|
| 459 |
+
where: { systemUserTokenIssuedAt: { lte: tokenThreshold }, systemUserToken: { not: null } },
|
| 460 |
+
select: { id: true, name: true, systemUserTokenIssuedAt: true },
|
| 461 |
+
}),
|
| 462 |
+
prisma.organization.findMany({
|
| 463 |
+
where: { walletBalance: { lt: 100 } },
|
| 464 |
+
select: { id: true, name: true, walletBalance: true },
|
| 465 |
+
}),
|
| 466 |
+
]);
|
| 467 |
+
return { status: 'success', result: { expiringTokens, lowBalance } };
|
| 468 |
+
}
|
| 469 |
+
case 'SUSPEND_ORG':
|
| 470 |
+
case 'ACTIVATE_ORG': {
|
| 471 |
+
const suspend = action === 'SUSPEND_ORG';
|
| 472 |
+
const orgSearch = (params.orgName || params.orgId) as string;
|
| 473 |
+
const org = await prisma.organization.findFirst({
|
| 474 |
+
where: params.orgId
|
| 475 |
+
? { id: params.orgId as string }
|
| 476 |
+
: { name: { contains: orgSearch, mode: 'insensitive' } }
|
| 477 |
+
});
|
| 478 |
+
if (!org) return reply.code(404).send({ error: `Organization not found: ${orgSearch}` });
|
| 479 |
+
await prisma.organization.update({
|
| 480 |
+
where: { id: org.id },
|
| 481 |
+
data: { isHardStopped: suspend, subscriptionStatus: suspend ? 'SUSPENDED' : 'ACTIVE' }
|
| 482 |
+
});
|
| 483 |
+
return { status: 'success', result: { orgId: org.id, orgName: org.name, action: suspend ? 'suspended' : 'activated' } };
|
| 484 |
+
}
|
| 485 |
+
case 'ADD_CREDITS': {
|
| 486 |
+
const orgSearch = (params.orgName || params.orgId) as string;
|
| 487 |
+
const amount = typeof params.amount === 'number' ? params.amount : parseInt(params.amount as string, 10);
|
| 488 |
+
if (!amount || amount <= 0) return reply.code(400).send({ error: 'Invalid amount' });
|
| 489 |
+
const org = await prisma.organization.findFirst({
|
| 490 |
+
where: params.orgId
|
| 491 |
+
? { id: params.orgId as string }
|
| 492 |
+
: { name: { contains: orgSearch, mode: 'insensitive' } }
|
| 493 |
+
});
|
| 494 |
+
if (!org) return reply.code(404).send({ error: `Organization not found: ${orgSearch}` });
|
| 495 |
+
const newBalance = org.walletBalance + amount;
|
| 496 |
+
await prisma.$transaction([
|
| 497 |
+
prisma.organization.update({ where: { id: org.id }, data: { walletBalance: newBalance } }),
|
| 498 |
+
prisma.walletTransaction.create({
|
| 499 |
+
data: {
|
| 500 |
+
organizationId: org.id,
|
| 501 |
+
amount,
|
| 502 |
+
balanceAfter: newBalance,
|
| 503 |
+
type: 'TOP_UP_MANUAL',
|
| 504 |
+
description: `Super-admin AI command: add ${amount} credits`,
|
| 505 |
+
actorId: req.user?.id,
|
| 506 |
+
}
|
| 507 |
+
})
|
| 508 |
+
]);
|
| 509 |
+
return { status: 'success', result: { orgId: org.id, orgName: org.name, amount, newBalance } };
|
| 510 |
+
}
|
| 511 |
+
case 'LIST_USERS': {
|
| 512 |
+
const where: any = { deletedAt: null };
|
| 513 |
+
if (params.role) where.role = params.role;
|
| 514 |
+
if (params.orgName) {
|
| 515 |
+
const org = await prisma.organization.findFirst({ where: { name: { contains: params.orgName as string, mode: 'insensitive' } } });
|
| 516 |
+
if (org) where.organizationId = org.id;
|
| 517 |
+
}
|
| 518 |
+
const users = await prisma.user.findMany({ where, select: { id: true, name: true, email: true, role: true, organizationId: true }, take: 20 });
|
| 519 |
+
return { status: 'success', result: users };
|
| 520 |
+
}
|
| 521 |
+
default:
|
| 522 |
+
return reply.code(400).send({ error: `Unknown action: ${action}` });
|
| 523 |
+
}
|
| 524 |
+
} catch (err) {
|
| 525 |
+
logger.error({ err, action }, '[SUPER_ADMIN] action execution failed');
|
| 526 |
+
return reply.code(500).send({ error: 'Action execution failed' });
|
| 527 |
+
}
|
| 528 |
+
}
|