claudmax 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claudmax-1.0.16.tgz +0 -0
- package/{packages/cli/index.js → index.js} +2 -0
- package/package.json +27 -55
- package/.claude/settings.local.json +0 -7
- package/.env.example +0 -24
- package/.github/workflows/publish.yml +0 -31
- package/README.md +0 -178
- package/claudmax-mcp-1.0.2.tgz +0 -0
- package/help +0 -0
- package/help-wal +0 -0
- package/next-env.d.ts +0 -6
- package/next.config.mjs +0 -43
- package/packages/cli/claudmax-1.0.16.tgz +0 -0
- package/packages/cli/package.json +0 -33
- package/packages/mcp/claudmax-mcp-1.0.0.tgz +0 -0
- package/packages/mcp/claudmax-mcp-1.0.1.tgz +0 -0
- package/packages/mcp/claudmax-mcp-1.0.2.tgz +0 -0
- package/packages/mcp/claudmax-mcp-1.0.3.tgz +0 -0
- package/packages/mcp/index.js +0 -129
- package/packages/mcp/package-lock.json +0 -1146
- package/packages/mcp/package.json +0 -32
- package/postcss.config.mjs +0 -6
- package/prisma/schema.prisma +0 -130
- package/prisma/seed.ts +0 -27
- package/public/favicon.svg +0 -10
- package/public/robots.txt +0 -10
- package/run_build.sh +0 -4
- package/scripts/migrate-plans.js +0 -98
- package/scripts/seed-blog.ts +0 -1014
- package/src/app/admin/dashboard/AdminDashboardClient.tsx +0 -1546
- package/src/app/admin/dashboard/page.tsx +0 -13
- package/src/app/admin/page.tsx +0 -132
- package/src/app/api/admin/auth/me/route.ts +0 -34
- package/src/app/api/admin/health/route.ts +0 -110
- package/src/app/api/admin/keys/[id]/route.ts +0 -116
- package/src/app/api/admin/keys/route.ts +0 -192
- package/src/app/api/admin/keys-list/route.ts +0 -81
- package/src/app/api/admin/login/route.ts +0 -72
- package/src/app/api/admin/logout/route.ts +0 -8
- package/src/app/api/admin/migrate/route.ts +0 -133
- package/src/app/api/admin/plans/[id]/route.ts +0 -65
- package/src/app/api/admin/plans/route.ts +0 -66
- package/src/app/api/admin/posts/[id]/route.ts +0 -81
- package/src/app/api/admin/posts/route.ts +0 -83
- package/src/app/api/admin/seed/route.ts +0 -145
- package/src/app/api/admin/settings/route.ts +0 -44
- package/src/app/api/admin/stats/route.ts +0 -74
- package/src/app/api/admin/users/[id]/route.ts +0 -166
- package/src/app/api/admin/users/plans/route.ts +0 -45
- package/src/app/api/admin/users/route.ts +0 -202
- package/src/app/api/blog/[slug]/route.ts +0 -22
- package/src/app/api/blog/route.ts +0 -40
- package/src/app/api/cron/daily-status/route.ts +0 -208
- package/src/app/api/support/chat/route.ts +0 -55
- package/src/app/api/support/chat/session/route.ts +0 -62
- package/src/app/api/support/chat/stream/route.ts +0 -44
- package/src/app/api/support/email/route.ts +0 -63
- package/src/app/api/tools/understand_image/route.ts +0 -113
- package/src/app/api/tools/upload/route.ts +0 -179
- package/src/app/api/tools/web_search/route.ts +0 -99
- package/src/app/api/v1/audio/route.ts +0 -67
- package/src/app/api/v1/audio/speech/route.ts +0 -73
- package/src/app/api/v1/chat/completions/route.ts +0 -3
- package/src/app/api/v1/chat/route.ts +0 -1079
- package/src/app/api/v1/images/generations/route.ts +0 -93
- package/src/app/api/v1/info/route.ts +0 -30
- package/src/app/api/v1/key-status/route.ts +0 -109
- package/src/app/api/v1/key-status/stream/route.ts +0 -135
- package/src/app/api/v1/messages/count_tokens/route.ts +0 -22
- package/src/app/api/v1/messages/route.ts +0 -807
- package/src/app/api/v1/models/route.ts +0 -14
- package/src/app/api/v1/route.ts +0 -18
- package/src/app/blog/BlogClient.tsx +0 -193
- package/src/app/blog/[slug]/page.tsx +0 -117
- package/src/app/blog/page.tsx +0 -20
- package/src/app/check-usage/CheckUsageClient.tsx +0 -186
- package/src/app/check-usage/layout.tsx +0 -11
- package/src/app/check-usage/page.tsx +0 -15
- package/src/app/docs/layout.tsx +0 -16
- package/src/app/docs/page.tsx +0 -1055
- package/src/app/faq/FAQClient.tsx +0 -227
- package/src/app/faq/page.tsx +0 -21
- package/src/app/globals.css +0 -75
- package/src/app/layout.tsx +0 -80
- package/src/app/page.tsx +0 -256
- package/src/app/reseller/ResellerClient.tsx +0 -435
- package/src/app/reseller/page.tsx +0 -15
- package/src/app/setup.ps1/route.ts +0 -79
- package/src/app/setup.sh/route.ts +0 -113
- package/src/app/sitemap.ts +0 -50
- package/src/app/status/StatusClient.tsx +0 -103
- package/src/app/status/layout.tsx +0 -11
- package/src/app/status/page.tsx +0 -15
- package/src/app/support/SupportClient.tsx +0 -411
- package/src/app/support/page.tsx +0 -25
- package/src/app/v1/chat/completions/route.ts +0 -3
- package/src/app/v1/chat/route.ts +0 -4
- package/src/app/v1/messages/route.ts +0 -3
- package/src/components/Footer.tsx +0 -120
- package/src/components/Header.tsx +0 -131
- package/src/components/landing/features.tsx +0 -99
- package/src/components/ui/badge.tsx +0 -32
- package/src/components/ui/button.tsx +0 -46
- package/src/components/ui/card.tsx +0 -50
- package/src/components/ui/dialog.tsx +0 -97
- package/src/components/ui/dropdown-menu.tsx +0 -156
- package/src/components/ui/input.tsx +0 -21
- package/src/components/ui/label.tsx +0 -15
- package/src/components/ui/separator.tsx +0 -22
- package/src/components/ui/switch.tsx +0 -27
- package/src/components/ui/tabs.tsx +0 -51
- package/src/components/ui/toast.tsx +0 -103
- package/src/lib/auth.ts +0 -45
- package/src/lib/prisma.ts +0 -20
- package/src/lib/providers.ts +0 -158
- package/src/lib/security.ts +0 -165
- package/src/lib/utils.ts +0 -14
- package/src/middleware.ts +0 -30
- package/tailwind.config.ts +0 -53
- package/tsconfig.json +0 -41
- package/tsconfig.tsbuildinfo +0 -1
- package/vercel.json +0 -8
- /package/{packages/cli/bin → bin}/claudmax.js +0 -0
- /package/{packages/cli/claudmax-1.0.17.tgz → claudmax-1.0.17.tgz} +0 -0
|
@@ -1,1546 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
5
|
-
|
|
6
|
-
interface AdminUser {
|
|
7
|
-
id: string;
|
|
8
|
-
username: string;
|
|
9
|
-
name: string;
|
|
10
|
-
role: string;
|
|
11
|
-
isActive: boolean;
|
|
12
|
-
createdAt: string;
|
|
13
|
-
apiKeyCount: number;
|
|
14
|
-
blockedUntil: string | null;
|
|
15
|
-
canCreateKey: boolean;
|
|
16
|
-
canDeleteKey: boolean;
|
|
17
|
-
canBlockKey: boolean;
|
|
18
|
-
canManageTokens: boolean;
|
|
19
|
-
canCreateReseller: boolean;
|
|
20
|
-
canManageResellers: boolean;
|
|
21
|
-
canManageUsers: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface ApiKey {
|
|
25
|
-
id: string;
|
|
26
|
-
key: string;
|
|
27
|
-
name: string;
|
|
28
|
-
prefix: string;
|
|
29
|
-
tier: string;
|
|
30
|
-
isActive: boolean;
|
|
31
|
-
blockedUntil: string | null;
|
|
32
|
-
tokenLimitOverride: number | null;
|
|
33
|
-
displayMultiplier: number;
|
|
34
|
-
windowTokensUsed: number;
|
|
35
|
-
windowRequestsUsed: number;
|
|
36
|
-
totalTokensUsed: number;
|
|
37
|
-
windowStartAt: string | null;
|
|
38
|
-
createdAt: string;
|
|
39
|
-
lastUsedAt: string | null;
|
|
40
|
-
reseller?: { id: string; name: string } | null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface Stats {
|
|
44
|
-
totalKeys: number;
|
|
45
|
-
activeKeys: number;
|
|
46
|
-
totalRequests: number;
|
|
47
|
-
totalTokens: number;
|
|
48
|
-
totalUsers: number;
|
|
49
|
-
keysByTier: { tier: string; _count: { _all: number } }[];
|
|
50
|
-
recentKeys: any[];
|
|
51
|
-
topTokens: any[];
|
|
52
|
-
myRole: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
56
|
-
const TIER_LIMITS: Record<string, { requestsPerWindow: number; tokensPerWindow: number }> = {
|
|
57
|
-
'5x': { requestsPerWindow: 18000, tokensPerWindow: 5_000_000 },
|
|
58
|
-
'20x': { requestsPerWindow: 18000, tokensPerWindow: 20_000_000 },
|
|
59
|
-
unlimited: { requestsPerWindow: 999_999_999, tokensPerWindow: 999_999_999_999 },
|
|
60
|
-
};
|
|
61
|
-
function fmtTokens(n: number) {
|
|
62
|
-
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
|
|
63
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
64
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
65
|
-
return String(n);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function fmtCost(n: number) {
|
|
69
|
-
// Assume $3 per million tokens (claude-sonnet-4.6 mid-range)
|
|
70
|
-
const cost = (n / 1_000_000) * 3;
|
|
71
|
-
if (cost >= 1000) return `$${(cost / 1000).toFixed(1)}K`;
|
|
72
|
-
if (cost >= 1) return `$${cost.toFixed(2)}`;
|
|
73
|
-
return `$${cost.toFixed(4)}`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function getDisplayedTokens(key: ApiKey) {
|
|
77
|
-
return Math.floor((key.windowTokensUsed || 0) * (key.displayMultiplier ?? 3));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function roleLabel(role: string) {
|
|
81
|
-
return { super_admin: 'Super Admin', admin: 'Admin', reseller: 'Reseller' }[role] ?? role;
|
|
82
|
-
}
|
|
83
|
-
function roleColor(role: string) {
|
|
84
|
-
return { super_admin: 'bg-purple-50 text-purple-700 border-purple-200', admin: 'bg-blue-50 text-blue-700 border-blue-200', reseller: 'bg-amber-50 text-amber-700 border-amber-200' }[role] ?? 'bg-gray-50 text-gray-600 border-gray-200';
|
|
85
|
-
}
|
|
86
|
-
function tierColor(tier: string) {
|
|
87
|
-
return { '5x': 'bg-blue-50 text-blue-600 border-blue-100', '20x': 'bg-purple-50 text-purple-600 border-purple-100', unlimited: 'bg-amber-50 text-amber-600 border-amber-100' }[tier] ?? 'bg-gray-50';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ─── Stat Card ────────────────────────────────────────────────────────────────
|
|
91
|
-
function StatCard({ label, value, sub, icon, color = 'purple' }: { label: string; value: string | number; sub?: string; icon: React.ReactNode; color?: string }) {
|
|
92
|
-
const colors: Record<string, string> = { purple: 'bg-purple-50 border-purple-100', green: 'bg-emerald-50 border-emerald-100', blue: 'bg-blue-50 border-blue-100', amber: 'bg-amber-50 border-amber-100' };
|
|
93
|
-
const iconColors: Record<string, string> = { purple: 'text-purple-600', green: 'text-emerald-600', blue: 'text-blue-600', amber: 'text-amber-600' };
|
|
94
|
-
return (
|
|
95
|
-
<div className={`rounded-2xl border p-5 ${colors[color]} card-shadow`}>
|
|
96
|
-
<div className="flex items-center justify-between mb-3">
|
|
97
|
-
<p className="text-[#9CA3AF] text-xs font-bold uppercase tracking-wider">{label}</p>
|
|
98
|
-
<div className={`w-8 h-8 rounded-lg bg-white border border-gray-100 flex items-center justify-center ${iconColors[color]}`}>{icon}</div>
|
|
99
|
-
</div>
|
|
100
|
-
<p className="text-3xl font-black text-[#111827]">{value}</p>
|
|
101
|
-
{sub && <p className="text-[#D1D5DB] text-xs mt-1">{sub}</p>}
|
|
102
|
-
</div>
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ─── Modals ──────────────────────────────────────────────────────────────────
|
|
107
|
-
function ConfirmModal({ title, message, onConfirm, onCancel, danger }: { title: string; message: string; onConfirm: () => void; onCancel: () => void; danger?: boolean }) {
|
|
108
|
-
return (
|
|
109
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
|
110
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-sm p-6 card-shadow-md">
|
|
111
|
-
<h3 className="text-lg font-bold text-[#111827] mb-2">{title}</h3>
|
|
112
|
-
<p className="text-[#6B7280] text-sm mb-6">{message}</p>
|
|
113
|
-
<div className="flex gap-3">
|
|
114
|
-
<button onClick={onCancel} className="flex-1 h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
115
|
-
<button onClick={onConfirm} className={`flex-1 h-10 rounded-xl font-semibold text-sm text-white transition-colors ${danger ? 'bg-red-500 hover:bg-red-600' : 'bg-[#5244F3] hover:bg-[#4338CA]'}`}>Confirm</button>
|
|
116
|
-
</div>
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function CreateUserModal({ onCreate, onCancel, myRole }: { onCreate: (data: { username: string; password: string; name: string; role: string }) => void; onCancel: () => void; myRole: string }) {
|
|
123
|
-
const [username, setUsername] = useState('');
|
|
124
|
-
const [password, setPassword] = useState('');
|
|
125
|
-
const [name, setName] = useState('');
|
|
126
|
-
const [role, setRole] = useState('reseller');
|
|
127
|
-
return (
|
|
128
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
|
129
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-sm p-6 card-shadow-md max-h-[90vh] overflow-y-auto">
|
|
130
|
-
<h3 className="text-lg font-bold text-[#111827] mb-1">Create User</h3>
|
|
131
|
-
<p className="text-[#9CA3AF] text-xs mb-4">Create an admin, reseller, or sub-user account.</p>
|
|
132
|
-
<div className="space-y-3">
|
|
133
|
-
<div>
|
|
134
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Full Name</label>
|
|
135
|
-
<input value={name} onChange={e => setName(e.target.value)} placeholder="John Doe" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm text-[#111827] focus:outline-none focus:border-purple-400 transition-all" />
|
|
136
|
-
</div>
|
|
137
|
-
<div>
|
|
138
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Username</label>
|
|
139
|
-
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="johndoe" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm text-[#111827] focus:outline-none focus:border-purple-400 transition-all" />
|
|
140
|
-
</div>
|
|
141
|
-
<div>
|
|
142
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Password</label>
|
|
143
|
-
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Min 8 characters" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm text-[#111827] focus:outline-none focus:border-purple-400 transition-all" />
|
|
144
|
-
</div>
|
|
145
|
-
{myRole === 'super_admin' && (
|
|
146
|
-
<div>
|
|
147
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-2">Role</label>
|
|
148
|
-
<div className="grid grid-cols-3 gap-2">
|
|
149
|
-
{[{ v: 'admin', l: 'Admin' }, { v: 'reseller', l: 'Reseller' }].map(r => (
|
|
150
|
-
<button key={r.v} onClick={() => setRole(r.v)} className={`p-2.5 rounded-xl border text-center text-xs font-semibold transition-all ${role === r.v ? 'border-purple-400 bg-purple-50 text-purple-700 ring-2 ring-purple-100' : 'border-gray-200 text-[#6B7280]'}`}>
|
|
151
|
-
{r.l}
|
|
152
|
-
</button>
|
|
153
|
-
))}
|
|
154
|
-
</div>
|
|
155
|
-
</div>
|
|
156
|
-
)}
|
|
157
|
-
{myRole === 'admin' && (
|
|
158
|
-
<div>
|
|
159
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-2">Role</label>
|
|
160
|
-
<div className="p-2.5 rounded-xl border border-gray-100 bg-gray-50 text-center text-xs font-semibold text-[#6B7280]">Reseller (only option)</div>
|
|
161
|
-
</div>
|
|
162
|
-
)}
|
|
163
|
-
<div className="flex gap-3 pt-2">
|
|
164
|
-
<button onClick={onCancel} className="flex-1 h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
165
|
-
<button onClick={() => { if (username && password && name) onCreate({ username, password, name, role }); }} disabled={!username || !password || !name || password.length < 6} className="flex-1 h-10 rounded-xl bg-[#5244F3] hover:bg-[#4338CA] disabled:bg-gray-300 text-white font-semibold text-sm transition-colors">Create User</button>
|
|
166
|
-
</div>
|
|
167
|
-
</div>
|
|
168
|
-
</div>
|
|
169
|
-
</div>
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function PermissionsModal({ user, onSave, onCancel }: { user: AdminUser; onSave: (permissions: any) => void; onCancel: () => void }) {
|
|
174
|
-
const [perms, setPerms] = useState({
|
|
175
|
-
canCreateKey: user.canCreateKey,
|
|
176
|
-
canDeleteKey: user.canDeleteKey,
|
|
177
|
-
canBlockKey: user.canBlockKey,
|
|
178
|
-
canManageTokens: user.canManageTokens,
|
|
179
|
-
canCreateReseller: user.canCreateReseller,
|
|
180
|
-
canManageResellers: user.canManageResellers,
|
|
181
|
-
canManageUsers: user.canManageUsers,
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
const toggle = (key: keyof typeof perms) => setPerms(p => ({ ...p, [key]: !p[key] }));
|
|
185
|
-
|
|
186
|
-
const permLabels: Record<keyof typeof perms, { label: string; desc: string }> = {
|
|
187
|
-
canCreateKey: { label: 'Create Keys', desc: 'Can generate new API keys' },
|
|
188
|
-
canDeleteKey: { label: 'Delete Keys', desc: 'Can delete API keys' },
|
|
189
|
-
canBlockKey: { label: 'Block Keys', desc: 'Can block and unblock API keys' },
|
|
190
|
-
canManageTokens: { label: 'Manage Tokens', desc: 'Can adjust token limits per key' },
|
|
191
|
-
canCreateReseller: { label: 'Create Resellers', desc: 'Can create reseller accounts' },
|
|
192
|
-
canManageResellers: { label: 'Manage Resellers', desc: 'Can manage reseller accounts' },
|
|
193
|
-
canManageUsers: { label: 'Manage Users', desc: 'Can manage users and permissions' },
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
return (
|
|
197
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
|
198
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-sm p-6 card-shadow-md max-h-[90vh] overflow-y-auto">
|
|
199
|
-
<h3 className="text-lg font-bold text-[#111827] mb-1">Permissions</h3>
|
|
200
|
-
<p className="text-[#9CA3AF] text-xs font-mono mb-4">@{user.username} · {roleLabel(user.role)}</p>
|
|
201
|
-
<div className="space-y-2">
|
|
202
|
-
{(Object.keys(perms) as Array<keyof typeof perms>).map(key => (
|
|
203
|
-
<div key={key} className="flex items-center justify-between p-3 bg-[#F9FAFB] rounded-xl border border-gray-100">
|
|
204
|
-
<div>
|
|
205
|
-
<p className="text-[#374151] text-sm font-medium">{permLabels[key].label}</p>
|
|
206
|
-
<p className="text-[#9CA3AF] text-xs">{permLabels[key].desc}</p>
|
|
207
|
-
</div>
|
|
208
|
-
<button
|
|
209
|
-
onClick={() => toggle(key)}
|
|
210
|
-
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${perms[key] ? 'bg-purple-600' : 'bg-gray-200'}`}
|
|
211
|
-
>
|
|
212
|
-
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${perms[key] ? 'translate-x-6' : 'translate-x-1'}`} />
|
|
213
|
-
</button>
|
|
214
|
-
</div>
|
|
215
|
-
))}
|
|
216
|
-
</div>
|
|
217
|
-
<div className="flex gap-3 mt-4">
|
|
218
|
-
<button onClick={onCancel} className="flex-1 h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
219
|
-
<button onClick={() => onSave(perms)} className="flex-1 h-10 rounded-xl bg-[#5244F3] hover:bg-[#4338CA] text-white font-semibold text-sm transition-colors">Save Permissions</button>
|
|
220
|
-
</div>
|
|
221
|
-
</div>
|
|
222
|
-
</div>
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function TempBlockModal({ user, onConfirm, onCancel }: { user: AdminUser; onConfirm: (duration: number) => void; onCancel: () => void }) {
|
|
227
|
-
const durations = [
|
|
228
|
-
{ label: '1 Hour', value: 3600000 },
|
|
229
|
-
{ label: '6 Hours', value: 21600000 },
|
|
230
|
-
{ label: '24 Hours', value: 86400000 },
|
|
231
|
-
{ label: '3 Days', value: 259200000 },
|
|
232
|
-
{ label: '7 Days', value: 604800000 },
|
|
233
|
-
{ label: '30 Days', value: 2592000000 },
|
|
234
|
-
];
|
|
235
|
-
return (
|
|
236
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
|
237
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-xs p-6 card-shadow-md">
|
|
238
|
-
<h3 className="text-lg font-bold text-[#111827] mb-1">Temporarily Block</h3>
|
|
239
|
-
<p className="text-[#9CA3AF] text-xs font-mono mb-4">@{user.username} will be blocked for this duration.</p>
|
|
240
|
-
<div className="space-y-2">
|
|
241
|
-
{durations.map(d => (
|
|
242
|
-
<button key={d.value} onClick={() => onConfirm(d.value)} className="w-full h-10 rounded-xl border border-gray-200 text-[#374151] font-semibold text-sm hover:bg-purple-50 hover:border-purple-300 hover:text-purple-700 transition-colors text-left px-3">
|
|
243
|
-
{d.label}
|
|
244
|
-
</button>
|
|
245
|
-
))}
|
|
246
|
-
</div>
|
|
247
|
-
<div className="mt-3">
|
|
248
|
-
<button onClick={onCancel} className="w-full h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
249
|
-
</div>
|
|
250
|
-
</div>
|
|
251
|
-
</div>
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function BlogModal({ post, onSave, onCancel }: { post?: any; onSave: (data: any) => void; onCancel: () => void }) {
|
|
256
|
-
const [title, setTitle] = useState(post?.title ?? '');
|
|
257
|
-
const [content, setContent] = useState(post?.content ?? '');
|
|
258
|
-
const [excerpt, setExcerpt] = useState(post?.excerpt ?? '');
|
|
259
|
-
const [tags, setTags] = useState(post?.tags ?? '');
|
|
260
|
-
const [coverImage, setCoverImage] = useState(post?.coverImage ?? '');
|
|
261
|
-
const [seoTitle, setSeoTitle] = useState(post?.seoTitle ?? '');
|
|
262
|
-
const [seoDesc, setSeoDesc] = useState(post?.seoDesc ?? '');
|
|
263
|
-
const [published, setPublished] = useState(post?.published ?? false);
|
|
264
|
-
const [saving, setSaving] = useState(false);
|
|
265
|
-
|
|
266
|
-
const handleSave = async () => {
|
|
267
|
-
if (!title || !content) return;
|
|
268
|
-
setSaving(true);
|
|
269
|
-
await onSave({ title, content, excerpt, tags, coverImage, seoTitle, seoDesc, published });
|
|
270
|
-
setSaving(false);
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
return (
|
|
274
|
-
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 backdrop-blur-sm p-4 overflow-y-auto">
|
|
275
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-2xl p-6 card-shadow-md my-8">
|
|
276
|
-
<h3 className="text-lg font-bold text-[#111827] mb-4">{post ? 'Edit Post' : 'New Blog Post'}</h3>
|
|
277
|
-
<div className="space-y-3 max-h-[70vh] overflow-y-auto pr-1">
|
|
278
|
-
<div>
|
|
279
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Title *</label>
|
|
280
|
-
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="Blog post title" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm text-[#111827] focus:outline-none focus:border-purple-400 transition-all" />
|
|
281
|
-
</div>
|
|
282
|
-
<div>
|
|
283
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Content * (HTML supported)</label>
|
|
284
|
-
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder="Write your blog post content here..." rows={10} className="w-full px-3 py-2 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm text-[#111827] focus:outline-none focus:border-purple-400 transition-all resize-y" />
|
|
285
|
-
</div>
|
|
286
|
-
<div className="grid grid-cols-2 gap-3">
|
|
287
|
-
<div>
|
|
288
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Excerpt</label>
|
|
289
|
-
<input value={excerpt} onChange={e => setExcerpt(e.target.value)} placeholder="Short description" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" />
|
|
290
|
-
</div>
|
|
291
|
-
<div>
|
|
292
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Tags (comma-separated)</label>
|
|
293
|
-
<input value={tags} onChange={e => setTags(e.target.value)} placeholder="AI, Claude, API" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" />
|
|
294
|
-
</div>
|
|
295
|
-
</div>
|
|
296
|
-
<div>
|
|
297
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Cover Image URL</label>
|
|
298
|
-
<input value={coverImage} onChange={e => setCoverImage(e.target.value)} placeholder="https://..." className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" />
|
|
299
|
-
</div>
|
|
300
|
-
<div className="grid grid-cols-2 gap-3">
|
|
301
|
-
<div>
|
|
302
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">SEO Title</label>
|
|
303
|
-
<input value={seoTitle} onChange={e => setSeoTitle(e.target.value)} placeholder="Custom SEO title" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" />
|
|
304
|
-
</div>
|
|
305
|
-
<div>
|
|
306
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">SEO Description</label>
|
|
307
|
-
<input value={seoDesc} onChange={e => setSeoDesc(e.target.value)} placeholder="Meta description" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" />
|
|
308
|
-
</div>
|
|
309
|
-
</div>
|
|
310
|
-
<div className="flex items-center gap-3">
|
|
311
|
-
<button
|
|
312
|
-
onClick={() => setPublished(!published)}
|
|
313
|
-
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${published ? 'bg-purple-600' : 'bg-gray-200'}`}
|
|
314
|
-
>
|
|
315
|
-
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${published ? 'translate-x-6' : 'translate-x-1'}`} />
|
|
316
|
-
</button>
|
|
317
|
-
<span className="text-sm font-medium text-[#374151]">{published ? 'Published' : 'Draft'}</span>
|
|
318
|
-
</div>
|
|
319
|
-
</div>
|
|
320
|
-
<div className="flex gap-3 mt-4 pt-4 border-t border-gray-100">
|
|
321
|
-
<button onClick={onCancel} className="flex-1 h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
322
|
-
<button onClick={handleSave} disabled={saving || !title || !content} className="flex-1 h-10 rounded-xl bg-[#5244F3] hover:bg-[#4338CA] disabled:bg-gray-300 text-white font-semibold text-sm transition-colors">{saving ? 'Saving...' : 'Save Post'}</button>
|
|
323
|
-
</div>
|
|
324
|
-
</div>
|
|
325
|
-
</div>
|
|
326
|
-
);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function BlogTab({ posts, total, page, onFetch, onFetchStats }: { posts: any[]; total: number; page: number; onFetch: (p: number) => void; onFetchStats: () => void }) {
|
|
330
|
-
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
331
|
-
const [confirm, setConfirm] = useState<{ message: string; onConfirm: () => void } | null>(null);
|
|
332
|
-
const [editingPost, setEditingPost] = useState<any | null>(null);
|
|
333
|
-
const [creating, setCreating] = useState(false);
|
|
334
|
-
|
|
335
|
-
const doAction = async (id: string, action: string, data?: any) => {
|
|
336
|
-
setActionLoading(id + action);
|
|
337
|
-
try {
|
|
338
|
-
const r = await fetch(`/api/admin/posts/${id}`, {
|
|
339
|
-
method: action === 'delete' ? 'DELETE' : 'PUT',
|
|
340
|
-
headers: { 'Content-Type': 'application/json' },
|
|
341
|
-
body: action === 'delete' ? undefined : JSON.stringify(data),
|
|
342
|
-
});
|
|
343
|
-
if (r.ok) { await onFetch(page); onFetchStats(); }
|
|
344
|
-
} finally { setActionLoading(null); }
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
const handleCreate = async (data: any) => {
|
|
348
|
-
setCreating(false);
|
|
349
|
-
setActionLoading('creating');
|
|
350
|
-
try {
|
|
351
|
-
await fetch('/api/admin/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
|
352
|
-
await onFetch(1);
|
|
353
|
-
onFetchStats();
|
|
354
|
-
} finally { setActionLoading(null); }
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
return (
|
|
358
|
-
<>
|
|
359
|
-
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
360
|
-
<div className="flex items-center gap-2">
|
|
361
|
-
<span className="text-[#9CA3AF] text-xs">{total} posts</span>
|
|
362
|
-
</div>
|
|
363
|
-
<button onClick={() => setCreating(true)} className="h-9 px-4 bg-[#5244F3] hover:bg-[#4338CA] text-white rounded-xl text-sm font-semibold transition-colors shadow-sm flex items-center gap-1.5">
|
|
364
|
-
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
|
365
|
-
New Post
|
|
366
|
-
</button>
|
|
367
|
-
</div>
|
|
368
|
-
|
|
369
|
-
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden card-shadow">
|
|
370
|
-
<div className="overflow-x-auto">
|
|
371
|
-
<table className="w-full text-sm">
|
|
372
|
-
<thead>
|
|
373
|
-
<tr className="border-b border-gray-100 bg-gray-50">
|
|
374
|
-
{['Title', 'Status', 'Tags', 'Views', 'Created', 'Actions'].map(h => (
|
|
375
|
-
<th key={h} className="text-left text-[#9CA3AF] font-bold px-4 py-3 text-xs uppercase tracking-wider whitespace-nowrap">{h}</th>
|
|
376
|
-
))}
|
|
377
|
-
</tr>
|
|
378
|
-
</thead>
|
|
379
|
-
<tbody>
|
|
380
|
-
{posts.length === 0 ? (
|
|
381
|
-
<tr><td colSpan={6} className="px-4 py-16 text-center text-[#D1D5DB] text-sm">No blog posts yet.</td></tr>
|
|
382
|
-
) : posts.map(p => (
|
|
383
|
-
<tr key={p.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50/50 transition-colors">
|
|
384
|
-
<td className="px-4 py-3">
|
|
385
|
-
<p className="text-[#374151] text-sm font-medium max-w-64 truncate">{p.title}</p>
|
|
386
|
-
<p className="text-[#D1D5DB] text-xs font-mono truncate max-w-64">{p.slug}</p>
|
|
387
|
-
</td>
|
|
388
|
-
<td className="px-4 py-3">
|
|
389
|
-
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${p.published ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' : 'bg-gray-100 text-gray-500 border border-gray-200'}`}>
|
|
390
|
-
{p.published ? 'Published' : 'Draft'}
|
|
391
|
-
</span>
|
|
392
|
-
</td>
|
|
393
|
-
<td className="px-4 py-3">
|
|
394
|
-
<div className="flex gap-1 flex-wrap">
|
|
395
|
-
{(p.tags as string ?? '').split(',').filter(Boolean).map((t: string) => (
|
|
396
|
-
<span key={t} className="text-xs bg-purple-50 text-purple-600 px-1.5 py-0.5 rounded-full border border-purple-100">{t.trim()}</span>
|
|
397
|
-
))}
|
|
398
|
-
</div>
|
|
399
|
-
</td>
|
|
400
|
-
<td className="px-4 py-3 text-xs font-mono text-[#6B7280]">{p.views?.toLocaleString() ?? 0}</td>
|
|
401
|
-
<td className="px-4 py-3 text-xs text-[#9CA3AF]">{new Date(p.createdAt).toLocaleDateString()}</td>
|
|
402
|
-
<td className="px-4 py-3">
|
|
403
|
-
<div className="flex items-center gap-1">
|
|
404
|
-
<button onClick={() => setEditingPost(p)} className="w-7 h-7 rounded-lg hover:bg-gray-100 flex items-center justify-center text-[#9CA3AF] hover:text-[#6B7280] transition-colors" title="Edit">
|
|
405
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
406
|
-
</button>
|
|
407
|
-
{p.published ? (
|
|
408
|
-
<button onClick={() => setConfirm({ message: `Unpublish "${p.title}"?`, onConfirm: () => { setConfirm(null); doAction(p.id, 'update', { published: false }); } })} disabled={actionLoading === p.id + 'update'} className="w-7 h-7 rounded-lg hover:bg-amber-50 flex items-center justify-center text-[#9CA3AF] hover:text-amber-500 transition-colors" title="Unpublish">
|
|
409
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18.36 6.64A9 9 0 0 1 20.77 15M6.16 6.16a9 9 0 1 0 12.68 12.68M12 2v4M2 12h4"/></svg>
|
|
410
|
-
</button>
|
|
411
|
-
) : (
|
|
412
|
-
<button onClick={() => setConfirm({ message: `Publish "${p.title}"?`, onConfirm: () => { setConfirm(null); doAction(p.id, 'update', { published: true }); } })} disabled={actionLoading === p.id + 'update'} className="w-7 h-7 rounded-lg hover:bg-emerald-50 flex items-center justify-center text-[#9CA3AF] hover:text-emerald-500 transition-colors" title="Publish">
|
|
413
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/></svg>
|
|
414
|
-
</button>
|
|
415
|
-
)}
|
|
416
|
-
<button onClick={() => setConfirm({ message: `Delete "${p.title}"? This cannot be undone.`, onConfirm: () => { setConfirm(null); doAction(p.id, 'delete'); } })} disabled={actionLoading === p.id + 'delete'} className="w-7 h-7 rounded-lg hover:bg-red-50 flex items-center justify-center text-[#9CA3AF] hover:text-red-500 transition-colors" title="Delete">
|
|
417
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/><path d="M10,11v6M14,11v6"/><path d="M9,6V4a1,1,0,0,1,1-1h4a1,1,0,0,1,1,1V6"/></svg>
|
|
418
|
-
</button>
|
|
419
|
-
</div>
|
|
420
|
-
</td>
|
|
421
|
-
</tr>
|
|
422
|
-
))}
|
|
423
|
-
</tbody>
|
|
424
|
-
</table>
|
|
425
|
-
</div>
|
|
426
|
-
{total > 10 && (
|
|
427
|
-
<div className="flex items-center justify-center gap-1 py-3 border-t border-gray-100">
|
|
428
|
-
{Array.from({ length: Math.min(Math.ceil(total / 10), 7) }, (_, i) => (
|
|
429
|
-
<button key={i} onClick={() => onFetch(i + 1)} className={`w-8 h-8 rounded-lg text-xs font-semibold transition-colors ${page === i + 1 ? 'bg-purple-600 text-white' : 'hover:bg-gray-100 text-[#6B7280]'}`}>{i + 1}</button>
|
|
430
|
-
))}
|
|
431
|
-
</div>
|
|
432
|
-
)}
|
|
433
|
-
</div>
|
|
434
|
-
|
|
435
|
-
{confirm && (
|
|
436
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
|
437
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-sm p-6 card-shadow-md">
|
|
438
|
-
<p className="text-[#374151] text-sm mb-6">{confirm.message}</p>
|
|
439
|
-
<div className="flex gap-3">
|
|
440
|
-
<button onClick={() => setConfirm(null)} className="flex-1 h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
441
|
-
<button onClick={confirm.onConfirm} className="flex-1 h-10 rounded-xl bg-[#5244F3] hover:bg-[#4338CA] text-white font-semibold text-sm transition-colors">Confirm</button>
|
|
442
|
-
</div>
|
|
443
|
-
</div>
|
|
444
|
-
</div>
|
|
445
|
-
)}
|
|
446
|
-
{editingPost && (
|
|
447
|
-
<BlogModal post={editingPost} onSave={async (data) => { await doAction(editingPost.id, 'update', data); setEditingPost(null); }} onCancel={() => setEditingPost(null)} />
|
|
448
|
-
)}
|
|
449
|
-
{creating && (
|
|
450
|
-
<BlogModal onSave={async (data) => { await handleCreate(data); setCreating(false); }} onCancel={() => setCreating(false)} />
|
|
451
|
-
)}
|
|
452
|
-
</>
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function SettingsTab() {
|
|
457
|
-
const [supportEnabled, setSupportEnabled] = useState(true);
|
|
458
|
-
const [loading, setLoading] = useState(false);
|
|
459
|
-
const [saved, setSaved] = useState(false);
|
|
460
|
-
|
|
461
|
-
useEffect(() => {
|
|
462
|
-
fetch('/api/admin/settings').then(r => r.ok ? r.json() : { supportEnabled: true }).then((d: { supportEnabled: boolean }) => setSupportEnabled(d.supportEnabled ?? true)).catch(() => {});
|
|
463
|
-
}, []);
|
|
464
|
-
|
|
465
|
-
const toggle = async () => {
|
|
466
|
-
setLoading(true);
|
|
467
|
-
const newVal = !supportEnabled;
|
|
468
|
-
try {
|
|
469
|
-
const r = await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ supportEnabled: newVal }) });
|
|
470
|
-
if (r.ok) {
|
|
471
|
-
setSupportEnabled(newVal);
|
|
472
|
-
setSaved(true);
|
|
473
|
-
setTimeout(() => setSaved(false), 2000);
|
|
474
|
-
}
|
|
475
|
-
} finally { setLoading(false); }
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
return (
|
|
479
|
-
<div className="space-y-6">
|
|
480
|
-
<div className="bg-white rounded-2xl border border-gray-200 card-shadow p-6">
|
|
481
|
-
<h2 className="text-lg font-bold text-[#111827] mb-1">Site Settings</h2>
|
|
482
|
-
<p className="text-[#9CA3AF] text-xs mb-6">Configure global site features.</p>
|
|
483
|
-
|
|
484
|
-
<div className="space-y-4">
|
|
485
|
-
<div className="flex items-center justify-between p-4 bg-[#F9FAFB] rounded-xl border border-gray-100">
|
|
486
|
-
<div className="flex items-center gap-3">
|
|
487
|
-
<div className="w-10 h-10 rounded-lg bg-purple-50 flex items-center justify-center">
|
|
488
|
-
<svg className="w-5 h-5 text-purple-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
489
|
-
</div>
|
|
490
|
-
<div>
|
|
491
|
-
<p className="text-[#374151] text-sm font-semibold">Support Page</p>
|
|
492
|
-
<p className="text-[#9CA3AF] text-xs">Show or hide the Support page from header, footer, and mobile menu.</p>
|
|
493
|
-
</div>
|
|
494
|
-
</div>
|
|
495
|
-
<div className="flex items-center gap-3">
|
|
496
|
-
{saved && <span className="text-xs text-emerald-600 font-semibold">Saved!</span>}
|
|
497
|
-
<button
|
|
498
|
-
onClick={toggle}
|
|
499
|
-
disabled={loading}
|
|
500
|
-
className={`relative inline-flex h-7 w-13 items-center rounded-full transition-colors focus:outline-none ${supportEnabled ? 'bg-purple-600' : 'bg-gray-200'}`}
|
|
501
|
-
>
|
|
502
|
-
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${supportEnabled ? 'translate-x-7' : 'translate-x-1.5'}`} />
|
|
503
|
-
</button>
|
|
504
|
-
</div>
|
|
505
|
-
</div>
|
|
506
|
-
</div>
|
|
507
|
-
</div>
|
|
508
|
-
</div>
|
|
509
|
-
);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function EditUserModal({ user, onSave, onCancel }: { user: AdminUser; onSave: (data: any) => void; onCancel: () => void }) {
|
|
513
|
-
const [name, setName] = useState(user.name);
|
|
514
|
-
const [password, setPassword] = useState('');
|
|
515
|
-
const [role, setRole] = useState(user.role);
|
|
516
|
-
return (
|
|
517
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
|
518
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-sm p-6 card-shadow-md">
|
|
519
|
-
<h3 className="text-lg font-bold text-[#111827] mb-1">Edit User</h3>
|
|
520
|
-
<p className="text-[#9CA3AF] text-xs font-mono mb-4">@{user.username}</p>
|
|
521
|
-
<div className="space-y-3">
|
|
522
|
-
<div><label className="text-[#6B7280] text-xs font-medium block mb-1">Name</label><input value={name} onChange={e => setName(e.target.value)} className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" /></div>
|
|
523
|
-
<div><label className="text-[#6B7280] text-xs font-medium block mb-1">New Password <span className="text-[#9CA3AF]">(optional)</span></label><input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Leave blank to keep current" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" /></div>
|
|
524
|
-
<div><label className="text-[#6B7280] text-xs font-medium block mb-2">Role</label><div className="grid grid-cols-3 gap-2">{['super_admin', 'admin', 'reseller'].map(r => (<button key={r} onClick={() => setRole(r)} className={`p-2 rounded-xl border text-center text-xs font-semibold transition-all ${role === r ? 'border-purple-400 bg-purple-50 text-purple-700 ring-2 ring-purple-100' : 'border-gray-200 text-[#6B7280]'}`}>{roleLabel(r)}</button>))}</div></div>
|
|
525
|
-
<div className="flex gap-3">
|
|
526
|
-
<button onClick={onCancel} className="flex-1 h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
527
|
-
<button onClick={() => onSave({ name, password: password || undefined, role })} className="flex-1 h-10 rounded-xl bg-[#5244F3] hover:bg-[#4338CA] text-white font-semibold text-sm transition-colors">Save Changes</button>
|
|
528
|
-
</div>
|
|
529
|
-
</div>
|
|
530
|
-
</div>
|
|
531
|
-
</div>
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function EditKeyModal({ apiKey, onSave, onCancel }: { apiKey: any; onSave: (data: { tokenLimitOverride?: number | null; expiresAt?: string | null; name?: string }) => void; onCancel: () => void }) {
|
|
536
|
-
const [tokenLimitOverride, setTokenLimitOverride] = useState(apiKey.tokenLimitOverride ? String(apiKey.tokenLimitOverride) : '');
|
|
537
|
-
const [expiresAt, setExpiresAt] = useState(
|
|
538
|
-
apiKey.expiresAt ? new Date(apiKey.expiresAt).toISOString().split('T')[0] : ''
|
|
539
|
-
);
|
|
540
|
-
const [name, setName] = useState(apiKey.name || '');
|
|
541
|
-
const [overrideEnabled, setOverrideEnabled] = useState(!!apiKey.tokenLimitOverride);
|
|
542
|
-
|
|
543
|
-
return (
|
|
544
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
|
545
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-sm p-6 card-shadow-md">
|
|
546
|
-
<h3 className="text-lg font-bold text-[#111827] mb-1">Edit API Key</h3>
|
|
547
|
-
<p className="text-[#9CA3AF] text-xs mb-4">Update token limits, expiry, or name.</p>
|
|
548
|
-
<div className="space-y-3">
|
|
549
|
-
{/* Key Name */}
|
|
550
|
-
<div>
|
|
551
|
-
<label className="text-[#6B7280] text-xs font-medium block mb-1">Key Name</label>
|
|
552
|
-
<input value={name} onChange={e => setName(e.target.value)} className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" />
|
|
553
|
-
</div>
|
|
554
|
-
{/* Token Limit Override */}
|
|
555
|
-
<div>
|
|
556
|
-
<div className="flex items-center justify-between mb-1">
|
|
557
|
-
<label className="text-[#6B7280] text-xs font-medium">Token Limit Override</label>
|
|
558
|
-
<label className="flex items-center gap-1 text-[10px] text-[#9CA3AF] cursor-pointer">
|
|
559
|
-
<input type="checkbox" checked={overrideEnabled} onChange={e => { setOverrideEnabled(e.target.checked); if (!e.target.checked) setTokenLimitOverride(''); }} className="accent-purple-500" />
|
|
560
|
-
Custom
|
|
561
|
-
</label>
|
|
562
|
-
</div>
|
|
563
|
-
{overrideEnabled ? (
|
|
564
|
-
<div className="flex gap-2 items-center">
|
|
565
|
-
<input
|
|
566
|
-
type="number" min={100000} max={1000000000} step={100000}
|
|
567
|
-
value={tokenLimitOverride}
|
|
568
|
-
onChange={e => setTokenLimitOverride(e.target.value)}
|
|
569
|
-
placeholder="Leave empty for plan default"
|
|
570
|
-
className="flex-1 h-10 px-3 bg-[#F9FAFB] border border-purple-300 rounded-xl text-sm focus:outline-none focus:border-purple-400 focus:ring-1 focus:ring-purple-100 transition-all"
|
|
571
|
-
/>
|
|
572
|
-
<span className="text-[#9CA3AF] text-xs">tok/5h</span>
|
|
573
|
-
</div>
|
|
574
|
-
) : (
|
|
575
|
-
<p className="text-[#9CA3AF] text-xs py-2">
|
|
576
|
-
Using plan default {apiKey.tokenLimitOverride ? `(override: ${(Number(apiKey.tokenLimitOverride) / 1_000_000).toFixed(1)}M)` : ''}
|
|
577
|
-
</p>
|
|
578
|
-
)}
|
|
579
|
-
</div>
|
|
580
|
-
{/* Expiry Date */}
|
|
581
|
-
<div>
|
|
582
|
-
<div className="flex items-center justify-between mb-1">
|
|
583
|
-
<label className="text-[#6B7280] text-xs font-medium">Expiry Date</label>
|
|
584
|
-
{expiresAt && (
|
|
585
|
-
<button onClick={() => setExpiresAt('')} className="text-[10px] text-red-400 hover:text-red-500">Clear expiry</button>
|
|
586
|
-
)}
|
|
587
|
-
</div>
|
|
588
|
-
<input
|
|
589
|
-
type="date"
|
|
590
|
-
value={expiresAt}
|
|
591
|
-
min={new Date().toISOString().split('T')[0]}
|
|
592
|
-
onChange={e => setExpiresAt(e.target.value)}
|
|
593
|
-
className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all"
|
|
594
|
-
/>
|
|
595
|
-
<p className="text-[#D1D5DB] text-[10px] mt-1">
|
|
596
|
-
{expiresAt ? `Expires: ${new Date(expiresAt).toLocaleDateString()} · Admin override` : 'No expiry set (uses plan duration)'}
|
|
597
|
-
</p>
|
|
598
|
-
</div>
|
|
599
|
-
</div>
|
|
600
|
-
<div className="flex gap-3 pt-4">
|
|
601
|
-
<button onClick={onCancel} className="flex-1 h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
602
|
-
<button onClick={() => onSave({
|
|
603
|
-
name,
|
|
604
|
-
tokenLimitOverride: overrideEnabled && tokenLimitOverride ? Number(tokenLimitOverride) : null,
|
|
605
|
-
expiresAt: expiresAt || null,
|
|
606
|
-
})} className="flex-1 h-10 rounded-xl bg-[#5244F3] hover:bg-[#4338CA] text-white font-semibold text-sm transition-colors">Save Changes</button>
|
|
607
|
-
</div>
|
|
608
|
-
</div>
|
|
609
|
-
</div>
|
|
610
|
-
);
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function CreateKeyModal({ onCreate, onCancel, users, myRole, plans = [], allowedPlanIds = [], defaultPlanId = '' }: { onCreate: (data: { name: string; planId?: string; resellerId?: string; days?: number; tokenLimitOverride?: number }) => void; onCancel: () => void; users?: AdminUser[]; myRole: string; plans?: any[]; allowedPlanIds?: string[]; defaultPlanId?: string }) {
|
|
614
|
-
const [name, setName] = useState('');
|
|
615
|
-
const [planId, setPlanId] = useState(defaultPlanId || (plans[0]?.id ?? ''));
|
|
616
|
-
const [resellerId, setResellerId] = useState('');
|
|
617
|
-
const [days, setDays] = useState(30);
|
|
618
|
-
const [tokenLimitOverride, setTokenLimitOverride] = useState('');
|
|
619
|
-
const [overrideEnabled, setOverrideEnabled] = useState(false);
|
|
620
|
-
const isAdmin = myRole === 'super_admin' || myRole === 'admin';
|
|
621
|
-
|
|
622
|
-
const availablePlans = myRole === 'reseller' ? plans.filter(p => allowedPlanIds.includes(p.id)) : plans;
|
|
623
|
-
const selectedPlan = plans.find(p => p.id === planId);
|
|
624
|
-
|
|
625
|
-
const defaultDays = selectedPlan?.durationDays ?? 30;
|
|
626
|
-
useEffect(() => { setDays(defaultDays); }, [defaultDays]);
|
|
627
|
-
|
|
628
|
-
const durationOptions: { label: string; value: number }[] = [];
|
|
629
|
-
if (selectedPlan) {
|
|
630
|
-
if (selectedPlan.maxDurationDays === -1) {
|
|
631
|
-
[7, 15, 30, 60, 90, 180, 365].forEach(d => {
|
|
632
|
-
if (d >= selectedPlan.minDurationDays) durationOptions.push({ label: `${d} days`, value: d });
|
|
633
|
-
});
|
|
634
|
-
} else {
|
|
635
|
-
for (let d = selectedPlan.minDurationDays; d <= selectedPlan.maxDurationDays; d += 7) {
|
|
636
|
-
if (d > 0) durationOptions.push({ label: `${d} days`, value: d });
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
return (
|
|
642
|
-
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4">
|
|
643
|
-
<div className="bg-white rounded-2xl border border-gray-200 w-full max-w-sm p-6 card-shadow-md max-h-[90vh] overflow-y-auto">
|
|
644
|
-
<h3 className="text-lg font-bold text-[#111827] mb-1">Create API Key</h3>
|
|
645
|
-
<p className="text-[#9CA3AF] text-xs mb-4">{myRole === 'reseller' ? 'Create a key for your account.' : 'Generate a new API key for a client.'}</p>
|
|
646
|
-
<div className="space-y-3">
|
|
647
|
-
<div><label className="text-[#6B7280] text-xs font-medium block mb-1">Application Name</label><input value={name} onChange={e => setName(e.target.value)} placeholder="Client iOS App" className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" /></div>
|
|
648
|
-
{myRole !== 'reseller' && users && users.length > 0 && (
|
|
649
|
-
<div><label className="text-[#6B7280] text-xs font-medium block mb-1">Assign to Reseller</label>
|
|
650
|
-
<select value={resellerId} onChange={e => setResellerId(e.target.value)} className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all">
|
|
651
|
-
<option value="">No reseller (general)</option>
|
|
652
|
-
{users.filter(u => u.role === 'reseller').map(u => (<option key={u.id} value={u.id}>{u.name} (@{u.username})</option>))}
|
|
653
|
-
</select>
|
|
654
|
-
</div>
|
|
655
|
-
)}
|
|
656
|
-
<div><label className="text-[#6B7280] text-xs font-medium block mb-1">Assign Plan</label>
|
|
657
|
-
<select value={planId} onChange={e => { setPlanId(e.target.value); setDays(plans.find(p => p.id === e.target.value)?.durationDays ?? 30); }} className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all">
|
|
658
|
-
{availablePlans.map(p => {
|
|
659
|
-
const tokensM = Number(p.tokensPerWindow) / 1_000_000;
|
|
660
|
-
const durationLabel = p.durationDays === -1 ? 'No expiry' : p.durationDays > 0 ? `${p.durationDays}d` : '';
|
|
661
|
-
return (
|
|
662
|
-
<option key={p.id} value={p.id}>{p.name} — {p.tier} ({tokensM}M tok / {p.requestsPerWindow} req / {durationLabel})</option>
|
|
663
|
-
);
|
|
664
|
-
})}
|
|
665
|
-
</select>
|
|
666
|
-
</div>
|
|
667
|
-
{selectedPlan && selectedPlan.durationDays !== -1 && (
|
|
668
|
-
<div>
|
|
669
|
-
<div className="flex items-center justify-between mb-1">
|
|
670
|
-
<label className="text-[#6B7280] text-xs font-medium">Duration {isAdmin && <span className="text-purple-500 text-[10px]">(Admin: up to 365d)</span>}</label>
|
|
671
|
-
{isAdmin && (
|
|
672
|
-
<label className="flex items-center gap-1 text-[10px] text-[#9CA3AF] cursor-pointer">
|
|
673
|
-
<input type="checkbox" checked={overrideEnabled} onChange={e => { setOverrideEnabled(e.target.checked); if (!e.target.checked) setDays(defaultDays); }} className="accent-purple-500" />
|
|
674
|
-
Custom
|
|
675
|
-
</label>
|
|
676
|
-
)}
|
|
677
|
-
</div>
|
|
678
|
-
{overrideEnabled ? (
|
|
679
|
-
<input
|
|
680
|
-
type="number" min={1} max={365} value={days}
|
|
681
|
-
onChange={e => setDays(Math.min(365, Math.max(1, Number(e.target.value))))}
|
|
682
|
-
className="w-full h-10 px-3 bg-[#F9FAFB] border border-purple-300 rounded-xl text-sm focus:outline-none focus:border-purple-400 focus:ring-1 focus:ring-purple-100 transition-all"
|
|
683
|
-
/>
|
|
684
|
-
) : durationOptions.length > 0 ? (
|
|
685
|
-
<div className="grid grid-cols-4 gap-1.5">
|
|
686
|
-
{durationOptions.map(opt => (
|
|
687
|
-
<button key={opt.value} onClick={() => setDays(opt.value)} className={`px-2 py-1.5 rounded-lg border text-center text-xs font-semibold transition-all ${days === opt.value ? 'border-purple-400 bg-purple-50 text-purple-700 ring-1 ring-purple-200' : 'border-gray-200 text-[#6B7280] hover:border-gray-300'}`}>
|
|
688
|
-
{opt.label}
|
|
689
|
-
</button>
|
|
690
|
-
))}
|
|
691
|
-
</div>
|
|
692
|
-
) : (
|
|
693
|
-
<input type="number" min={selectedPlan.minDurationDays} max={365} value={days} onChange={e => setDays(Number(e.target.value))} className="w-full h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all" />
|
|
694
|
-
)}
|
|
695
|
-
<p className="text-[#D1D5DB] text-[10px] mt-1">
|
|
696
|
-
{overrideEnabled ? `${days} days · Admin override (max 365d)` : (selectedPlan.minDurationDays > 0 ? `Min: ${selectedPlan.minDurationDays}d` : '') + (selectedPlan.maxDurationDays !== -1 ? ` · Max: ${selectedPlan.maxDurationDays}d` : ' · No max')}
|
|
697
|
-
</p>
|
|
698
|
-
</div>
|
|
699
|
-
)}
|
|
700
|
-
{selectedPlan && selectedPlan.durationDays === -1 && (
|
|
701
|
-
<div className="p-2.5 rounded-lg border border-gray-100 bg-gray-50 text-center text-xs text-[#9CA3AF]">Duration: No expiry (unlimited plan)</div>
|
|
702
|
-
)}
|
|
703
|
-
{/* Token limit override for admins */}
|
|
704
|
-
{isAdmin && selectedPlan && (
|
|
705
|
-
<div>
|
|
706
|
-
<div className="flex items-center justify-between mb-1">
|
|
707
|
-
<label className="text-[#6B7280] text-xs font-medium">Token Limit Override</label>
|
|
708
|
-
<label className="flex items-center gap-1 text-[10px] text-[#9CA3AF] cursor-pointer">
|
|
709
|
-
<input type="checkbox" checked={overrideEnabled} onChange={e => { setOverrideEnabled(e.target.checked); if (!e.target.checked) setTokenLimitOverride(''); }} className="accent-purple-500" />
|
|
710
|
-
Custom
|
|
711
|
-
</label>
|
|
712
|
-
</div>
|
|
713
|
-
<div className="flex gap-2 items-center">
|
|
714
|
-
<input
|
|
715
|
-
type="number" min={100000} max={1000000000} step={100000}
|
|
716
|
-
value={tokenLimitOverride}
|
|
717
|
-
onChange={e => setTokenLimitOverride(e.target.value)}
|
|
718
|
-
placeholder={`Default: ${Number(selectedPlan.tokensPerWindow).toLocaleString()}`}
|
|
719
|
-
className="flex-1 h-10 px-3 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm focus:outline-none focus:border-purple-400 transition-all"
|
|
720
|
-
/>
|
|
721
|
-
<span className="text-[#9CA3AF] text-xs">tokens/5h</span>
|
|
722
|
-
</div>
|
|
723
|
-
<p className="text-[#D1D5DB] text-[10px] mt-1">
|
|
724
|
-
{tokenLimitOverride ? `${(Number(tokenLimitOverride) / 1_000_000).toFixed(1)}M tokens/5h window · Admin override` : `Plan default: ${(Number(selectedPlan.tokensPerWindow) / 1_000_000).toFixed(1)}M tokens/5h`}
|
|
725
|
-
</p>
|
|
726
|
-
</div>
|
|
727
|
-
)}
|
|
728
|
-
<div className="flex gap-3 pt-1">
|
|
729
|
-
<button onClick={onCancel} className="flex-1 h-10 rounded-xl border border-gray-200 text-[#6B7280] font-semibold text-sm hover:bg-gray-50 transition-colors">Cancel</button>
|
|
730
|
-
<button onClick={() => onCreate({ name: name || 'My API Key', planId: planId || undefined, resellerId: resellerId || undefined, days: selectedPlan?.durationDays === -1 ? undefined : days, tokenLimitOverride: overrideEnabled && tokenLimitOverride ? Number(tokenLimitOverride) : undefined })} className="flex-1 h-10 rounded-xl bg-[#5244F3] hover:bg-[#4338CA] text-white font-semibold text-sm transition-colors">Generate Key</button>
|
|
731
|
-
</div>
|
|
732
|
-
</div>
|
|
733
|
-
</div>
|
|
734
|
-
</div>
|
|
735
|
-
);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
739
|
-
export default function AdminDashboard() {
|
|
740
|
-
const router = useRouter();
|
|
741
|
-
const [me, setMe] = useState<{ id: string; username: string; name: string; role: string; canCreateKey: boolean; canDeleteKey: boolean; canBlockKey: boolean; canManageTokens: boolean; canCreateReseller: boolean; canManageResellers: boolean; canManageUsers: boolean; allowedPlanIds?: string; defaultPlanId?: string | null } | null>(null);
|
|
742
|
-
const [stats, setStats] = useState<Stats | null>(null);
|
|
743
|
-
const [keys, setKeys] = useState<ApiKey[]>([]);
|
|
744
|
-
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
745
|
-
const [totalKeys, setTotalKeys] = useState(0);
|
|
746
|
-
const [totalUsers, setTotalUsers] = useState(0);
|
|
747
|
-
const [totalPosts, setTotalPosts] = useState(0);
|
|
748
|
-
const [blogPosts, setBlogPosts] = useState<any[]>([]);
|
|
749
|
-
const [blogPage, setBlogPage] = useState(1);
|
|
750
|
-
const [blogSearch, setBlogSearch] = useState('');
|
|
751
|
-
const [blogStatus, setBlogStatus] = useState('');
|
|
752
|
-
const [page, setPage] = useState(1);
|
|
753
|
-
const [userPage, setUserPage] = useState(1);
|
|
754
|
-
const [loading, setLoading] = useState(true);
|
|
755
|
-
const [activeTab, setActiveTab] = useState('overview');
|
|
756
|
-
const [search, setSearch] = useState('');
|
|
757
|
-
const [tierFilter, setTierFilter] = useState('');
|
|
758
|
-
const [statusFilter, setStatusFilter] = useState('');
|
|
759
|
-
const [userSearch, setUserSearch] = useState('');
|
|
760
|
-
const [userRoleFilter, setUserRoleFilter] = useState('');
|
|
761
|
-
const [userStatusFilter, setUserStatusFilter] = useState('');
|
|
762
|
-
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
763
|
-
const [copiedKey, setCopiedKey] = useState('');
|
|
764
|
-
const [newlyCreated, setNewlyCreated] = useState('');
|
|
765
|
-
const [confirmModal, setConfirmModal] = useState<{ title: string; message: string; onConfirm: () => void; danger?: boolean } | null>(null);
|
|
766
|
-
const [editUser, setEditUser] = useState<AdminUser | null>(null);
|
|
767
|
-
const [createUserOpen, setCreateUserOpen] = useState(false);
|
|
768
|
-
const [createKeyOpen, setCreateKeyOpen] = useState(false);
|
|
769
|
-
const [editKey, setEditKey] = useState<any | null>(null);
|
|
770
|
-
const [permissionsUser, setPermissionsUser] = useState<AdminUser | null>(null);
|
|
771
|
-
const [tempBlockUser, setTempBlockUser] = useState<AdminUser | null>(null);
|
|
772
|
-
const [editingPost, setEditingPost] = useState<any | null>(null);
|
|
773
|
-
const [creatingPost, setCreatingPost] = useState(false);
|
|
774
|
-
const [plans, setPlans] = useState<any[]>([]);
|
|
775
|
-
const [healthData, setHealthData] = useState<any>(null);
|
|
776
|
-
const [healthLoading, setHealthLoading] = useState(false);
|
|
777
|
-
const searchRef = useRef<ReturnType<typeof setTimeout>>();
|
|
778
|
-
|
|
779
|
-
// Auth check
|
|
780
|
-
useEffect(() => {
|
|
781
|
-
fetch('/api/admin/auth/me').then(r => {
|
|
782
|
-
if (!r.ok) { router.push('/admin'); return null; }
|
|
783
|
-
return r.json();
|
|
784
|
-
}).then(data => {
|
|
785
|
-
if (!data) return;
|
|
786
|
-
setMe(data);
|
|
787
|
-
if (data.role === 'reseller') setActiveTab('keys');
|
|
788
|
-
}).catch(() => router.push('/admin'));
|
|
789
|
-
}, [router]);
|
|
790
|
-
|
|
791
|
-
const fetchStats = useCallback(async () => {
|
|
792
|
-
try {
|
|
793
|
-
const r = await fetch('/api/admin/stats');
|
|
794
|
-
if (r.ok) setStats(await r.json());
|
|
795
|
-
} catch {}
|
|
796
|
-
}, []);
|
|
797
|
-
|
|
798
|
-
const fetchKeys = useCallback(async (p = 1) => {
|
|
799
|
-
setLoading(true);
|
|
800
|
-
try {
|
|
801
|
-
const params = new URLSearchParams({ page: String(p), limit: '15' });
|
|
802
|
-
if (search) params.set('search', search);
|
|
803
|
-
if (tierFilter) params.set('tier', tierFilter);
|
|
804
|
-
if (statusFilter) params.set('status', statusFilter);
|
|
805
|
-
const r = await fetch(`/api/admin/keys-list?${params}`);
|
|
806
|
-
if (r.ok) {
|
|
807
|
-
const d = await r.json();
|
|
808
|
-
setKeys(d.keys);
|
|
809
|
-
setTotalKeys(d.total);
|
|
810
|
-
setPage(d.page);
|
|
811
|
-
}
|
|
812
|
-
} catch {} finally { setLoading(false); }
|
|
813
|
-
}, [search, tierFilter, statusFilter]);
|
|
814
|
-
|
|
815
|
-
const fetchUsers = useCallback(async (p = 1) => {
|
|
816
|
-
try {
|
|
817
|
-
const params = new URLSearchParams({ page: String(p) });
|
|
818
|
-
if (userSearch) params.set('search', userSearch);
|
|
819
|
-
if (userRoleFilter) params.set('role', userRoleFilter);
|
|
820
|
-
if (userStatusFilter) params.set('status', userStatusFilter);
|
|
821
|
-
const r = await fetch(`/api/admin/users?${params}`);
|
|
822
|
-
if (r.ok) {
|
|
823
|
-
const d = await r.json();
|
|
824
|
-
setUsers(d.users);
|
|
825
|
-
setTotalUsers(d.total);
|
|
826
|
-
setUserPage(d.page);
|
|
827
|
-
}
|
|
828
|
-
} catch {}
|
|
829
|
-
}, [userSearch, userRoleFilter, userStatusFilter]);
|
|
830
|
-
|
|
831
|
-
useEffect(() => { if (me) fetchStats(); }, [me, fetchStats]);
|
|
832
|
-
useEffect(() => { if (me) fetchKeys(); }, [me, fetchKeys]);
|
|
833
|
-
useEffect(() => { if (me && me.role !== 'reseller') fetchUsers(); }, [me, fetchUsers]);
|
|
834
|
-
|
|
835
|
-
const fetchHealth = useCallback(async () => {
|
|
836
|
-
if (me?.role !== 'super_admin') return;
|
|
837
|
-
setHealthLoading(true);
|
|
838
|
-
try {
|
|
839
|
-
const r = await fetch('/api/admin/health');
|
|
840
|
-
if (r.ok) setHealthData(await r.json());
|
|
841
|
-
} catch {} finally { setHealthLoading(false); }
|
|
842
|
-
}, [me]);
|
|
843
|
-
|
|
844
|
-
// Auto-refresh stats every 10s and keys every 10s
|
|
845
|
-
useEffect(() => {
|
|
846
|
-
const interval = setInterval(() => { fetchStats(); fetchKeys(); if (activeTab === 'health') fetchHealth(); }, 10000);
|
|
847
|
-
return () => clearInterval(interval);
|
|
848
|
-
}, [fetchStats, fetchKeys, fetchHealth, activeTab]);
|
|
849
|
-
|
|
850
|
-
// Debounced search
|
|
851
|
-
useEffect(() => {
|
|
852
|
-
clearTimeout(searchRef.current);
|
|
853
|
-
searchRef.current = setTimeout(() => fetchKeys(1), 400);
|
|
854
|
-
return () => clearTimeout(searchRef.current);
|
|
855
|
-
}, [search, tierFilter, statusFilter, fetchKeys]);
|
|
856
|
-
|
|
857
|
-
useEffect(() => {
|
|
858
|
-
const t = setTimeout(() => fetchUsers(1), 400);
|
|
859
|
-
return () => clearTimeout(t);
|
|
860
|
-
}, [userSearch, userRoleFilter, userStatusFilter, fetchUsers]);
|
|
861
|
-
|
|
862
|
-
const fetchBlogPosts = useCallback(async (p = 1) => {
|
|
863
|
-
try {
|
|
864
|
-
const params = new URLSearchParams({ page: String(p) });
|
|
865
|
-
if (blogStatus) params.set('status', blogStatus);
|
|
866
|
-
const r = await fetch(`/api/admin/posts?${params}`);
|
|
867
|
-
if (r.ok) {
|
|
868
|
-
const d = await r.json();
|
|
869
|
-
setBlogPosts(d.posts);
|
|
870
|
-
setTotalPosts(d.total);
|
|
871
|
-
setBlogPage(d.page);
|
|
872
|
-
}
|
|
873
|
-
} catch {}
|
|
874
|
-
}, [blogStatus]);
|
|
875
|
-
|
|
876
|
-
useEffect(() => { if (me && (me.role === 'super_admin' || me.role === 'admin')) fetchBlogPosts(); }, [me, fetchBlogPosts]);
|
|
877
|
-
|
|
878
|
-
useEffect(() => {
|
|
879
|
-
if (!me) return;
|
|
880
|
-
fetch('/api/admin/plans').then(r => r.ok && r.json()).then(d => { if (d?.plans) setPlans(d.plans); });
|
|
881
|
-
}, [me]);
|
|
882
|
-
|
|
883
|
-
useEffect(() => {
|
|
884
|
-
const t = setTimeout(() => fetchBlogPosts(1), 400);
|
|
885
|
-
return () => clearTimeout(t);
|
|
886
|
-
}, [blogStatus, fetchBlogPosts]);
|
|
887
|
-
|
|
888
|
-
const doAction = async (id: string, action: string, data?: any) => {
|
|
889
|
-
setActionLoading(id + action);
|
|
890
|
-
try {
|
|
891
|
-
const r = await fetch(`/api/admin/keys/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...data }) });
|
|
892
|
-
if (r.ok) { await fetchKeys(page); await fetchStats(); }
|
|
893
|
-
} finally { setActionLoading(null); }
|
|
894
|
-
};
|
|
895
|
-
|
|
896
|
-
const doUserAction = async (id: string, action: string, data?: any) => {
|
|
897
|
-
setActionLoading(id + action);
|
|
898
|
-
try {
|
|
899
|
-
const r = await fetch(`/api/admin/users/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ...data }) });
|
|
900
|
-
if (r.ok) { await fetchUsers(userPage); await fetchStats(); }
|
|
901
|
-
} finally { setActionLoading(null); }
|
|
902
|
-
};
|
|
903
|
-
|
|
904
|
-
const handleSavePermissions = async (id: string, permissions: any) => {
|
|
905
|
-
setPermissionsUser(null);
|
|
906
|
-
setActionLoading(id + 'perms');
|
|
907
|
-
try {
|
|
908
|
-
const r = await fetch(`/api/admin/users/${id}`, {
|
|
909
|
-
method: 'PATCH',
|
|
910
|
-
headers: { 'Content-Type': 'application/json' },
|
|
911
|
-
body: JSON.stringify({ action: 'updatePermissions', permissions }),
|
|
912
|
-
});
|
|
913
|
-
if (r.ok) { await fetchUsers(userPage); }
|
|
914
|
-
} finally { setActionLoading(null); }
|
|
915
|
-
};
|
|
916
|
-
|
|
917
|
-
const doDeleteUser = async (id: string) => {
|
|
918
|
-
setConfirmModal(null);
|
|
919
|
-
setActionLoading(id + 'delete');
|
|
920
|
-
try {
|
|
921
|
-
const r = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
|
|
922
|
-
if (r.ok) { await fetchUsers(userPage); await fetchStats(); }
|
|
923
|
-
} finally { setActionLoading(null); }
|
|
924
|
-
};
|
|
925
|
-
|
|
926
|
-
const handleCreateUser = async (data: { username: string; password: string; name: string; role: string }) => {
|
|
927
|
-
setCreateUserOpen(false);
|
|
928
|
-
setActionLoading('creating');
|
|
929
|
-
try {
|
|
930
|
-
const r = await fetch('/api/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
|
931
|
-
if (r.ok) { await fetchUsers(1); await fetchStats(); }
|
|
932
|
-
} finally { setActionLoading(null); }
|
|
933
|
-
};
|
|
934
|
-
|
|
935
|
-
const handleSaveUser = async (id: string, data: any) => {
|
|
936
|
-
setEditUser(null);
|
|
937
|
-
setActionLoading(id);
|
|
938
|
-
try {
|
|
939
|
-
const r = await fetch(`/api/admin/users/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', ...data }) });
|
|
940
|
-
if (r.ok) { await fetchUsers(userPage); }
|
|
941
|
-
} finally { setActionLoading(null); }
|
|
942
|
-
};
|
|
943
|
-
|
|
944
|
-
const handleCreateKey = async (data: { name: string; planId?: string; resellerId?: string; days?: number }) => {
|
|
945
|
-
setCreateKeyOpen(false);
|
|
946
|
-
setActionLoading('creating');
|
|
947
|
-
try {
|
|
948
|
-
const r = await fetch('/api/admin/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
|
949
|
-
if (r.ok) {
|
|
950
|
-
const d = await r.json();
|
|
951
|
-
setNewlyCreated(d.key);
|
|
952
|
-
await fetchKeys(1);
|
|
953
|
-
await fetchStats();
|
|
954
|
-
}
|
|
955
|
-
} finally { setActionLoading(null); }
|
|
956
|
-
};
|
|
957
|
-
|
|
958
|
-
const handleSaveKey = async (id: string, data: { name?: string; tokenLimitOverride?: number | null; expiresAt?: string | null }) => {
|
|
959
|
-
setEditKey(null);
|
|
960
|
-
setActionLoading(id);
|
|
961
|
-
try {
|
|
962
|
-
const r = await fetch(`/api/admin/keys/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'update', ...data }) });
|
|
963
|
-
if (r.ok) { await fetchKeys(page); }
|
|
964
|
-
} finally { setActionLoading(null); }
|
|
965
|
-
};
|
|
966
|
-
|
|
967
|
-
const logout = async () => {
|
|
968
|
-
await fetch('/api/admin/logout', { method: 'POST' });
|
|
969
|
-
router.push('/admin');
|
|
970
|
-
};
|
|
971
|
-
|
|
972
|
-
if (!me) return (
|
|
973
|
-
<div className="min-h-screen bg-[#F9FAFB] flex items-center justify-center">
|
|
974
|
-
<div className="w-8 h-8 border-2 border-purple-600 border-t-transparent rounded-full animate-spin" />
|
|
975
|
-
</div>
|
|
976
|
-
);
|
|
977
|
-
|
|
978
|
-
const showUsersTab = me.role !== 'reseller';
|
|
979
|
-
const canCreateResellers = me.role === 'super_admin' || me.role === 'admin';
|
|
980
|
-
|
|
981
|
-
const tierTabs = [
|
|
982
|
-
{ label: 'All', value: '' },
|
|
983
|
-
{ label: '5x Max', value: '5x' },
|
|
984
|
-
{ label: '20x Max', value: '20x' },
|
|
985
|
-
{ label: 'Unlimited', value: 'unlimited' },
|
|
986
|
-
];
|
|
987
|
-
|
|
988
|
-
const userRoleTabs = [
|
|
989
|
-
{ label: 'All', value: '' },
|
|
990
|
-
...(me.role === 'super_admin' ? [{ label: 'Admins', value: 'admin' }] : []),
|
|
991
|
-
{ label: 'Resellers', value: 'reseller' },
|
|
992
|
-
];
|
|
993
|
-
|
|
994
|
-
return (
|
|
995
|
-
<div className="min-h-screen bg-[#F9FAFB]">
|
|
996
|
-
|
|
997
|
-
{/* Header */}
|
|
998
|
-
<header className="sticky top-0 z-40 bg-white/90 backdrop-blur-md border-b border-gray-200">
|
|
999
|
-
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
|
1000
|
-
<div className="flex items-center gap-3">
|
|
1001
|
-
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-purple-600 to-purple-500 flex items-center justify-center">
|
|
1002
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="text-white"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="currentColor"/></svg>
|
|
1003
|
-
</div>
|
|
1004
|
-
<span className="text-[#111827] font-bold text-lg">ClaudMax</span>
|
|
1005
|
-
<span className={`text-xs font-bold px-2 py-0.5 rounded-full border ${roleColor(me.role)}`}>{roleLabel(me.role)}</span>
|
|
1006
|
-
<span className="text-[#9CA3AF] text-xs">@{me.username}</span>
|
|
1007
|
-
</div>
|
|
1008
|
-
<div className="flex items-center gap-3">
|
|
1009
|
-
<button onClick={fetchStats} className="text-xs text-[#9CA3AF] hover:text-[#6B7280] transition-colors flex items-center gap-1">
|
|
1010
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 4v6h6M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10M23 14l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg>
|
|
1011
|
-
Refresh
|
|
1012
|
-
</button>
|
|
1013
|
-
<button onClick={logout} className="text-xs text-[#9CA3AF] hover:text-red-500 transition-colors">Sign Out</button>
|
|
1014
|
-
</div>
|
|
1015
|
-
</div>
|
|
1016
|
-
</header>
|
|
1017
|
-
|
|
1018
|
-
<div className="max-w-7xl mx-auto px-6 py-6">
|
|
1019
|
-
|
|
1020
|
-
{/* Tabs */}
|
|
1021
|
-
<div className="flex items-center gap-1 mb-6 bg-white rounded-xl border border-gray-200 p-1 w-fit">
|
|
1022
|
-
<button onClick={() => setActiveTab('overview')} className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'overview' ? 'bg-purple-600 text-white shadow-sm' : 'text-[#6B7280] hover:text-[#111827]'}`}>Overview</button>
|
|
1023
|
-
<button onClick={() => setActiveTab('keys')} className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'keys' ? 'bg-purple-600 text-white shadow-sm' : 'text-[#6B7280] hover:text-[#111827]'}`}>API Keys</button>
|
|
1024
|
-
{showUsersTab && (
|
|
1025
|
-
<button onClick={() => setActiveTab('users')} className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'users' ? 'bg-purple-600 text-white shadow-sm' : 'text-[#6B7280] hover:text-[#111827]'}`}>
|
|
1026
|
-
Users {me.role === 'super_admin' ? '& Admins' : ''}
|
|
1027
|
-
<span className="ml-1.5 text-xs bg-white/20 px-1.5 py-0.5 rounded-full">{totalUsers}</span>
|
|
1028
|
-
</button>
|
|
1029
|
-
)}
|
|
1030
|
-
{(me.role === 'super_admin' || me.role === 'admin') && (
|
|
1031
|
-
<button onClick={() => { setActiveTab('blog'); }} className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'blog' ? 'bg-purple-600 text-white shadow-sm' : 'text-[#6B7280] hover:text-[#111827]'}`}>
|
|
1032
|
-
Blog
|
|
1033
|
-
<span className="ml-1.5 text-xs bg-white/20 px-1.5 py-0.5 rounded-full">{totalPosts}</span>
|
|
1034
|
-
</button>
|
|
1035
|
-
)}
|
|
1036
|
-
{me.role === 'super_admin' && (
|
|
1037
|
-
<button onClick={() => { setActiveTab('health'); fetchHealth(); }} className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all flex items-center gap-1.5 ${activeTab === 'health' ? 'bg-purple-600 text-white shadow-sm' : 'text-[#6B7280] hover:text-[#111827]'}`}>
|
|
1038
|
-
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
|
1039
|
-
Health
|
|
1040
|
-
{healthData?.api?.status === 'ok' ? (
|
|
1041
|
-
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
|
1042
|
-
) : healthData?.api?.status === 'error' ? (
|
|
1043
|
-
<span className="w-2 h-2 rounded-full bg-red-500" />
|
|
1044
|
-
) : null}
|
|
1045
|
-
</button>
|
|
1046
|
-
)}
|
|
1047
|
-
{me.role === 'super_admin' && (
|
|
1048
|
-
<button onClick={() => setActiveTab('settings')} className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'settings' ? 'bg-purple-600 text-white shadow-sm' : 'text-[#6B7280] hover:text-[#111827]'}`}>
|
|
1049
|
-
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M2 12h2M20 12h2M19.07 19.07l-1.41-1.41M5.34 5.34L3.93 3.93M19.07 19.07l1.41-1.41M5.34 5.34l1.41 1.41"/></svg>
|
|
1050
|
-
Settings
|
|
1051
|
-
</button>
|
|
1052
|
-
)}
|
|
1053
|
-
</div>
|
|
1054
|
-
|
|
1055
|
-
{/* ── Overview ── */}
|
|
1056
|
-
{activeTab === 'overview' && stats && (
|
|
1057
|
-
<>
|
|
1058
|
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
1059
|
-
<StatCard label="Total Keys" value={stats.totalKeys} sub={`${stats.activeKeys} active`} icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>} color="purple" />
|
|
1060
|
-
<StatCard label="Total Requests" value={stats.totalRequests.toLocaleString()} sub="all time" icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>} color="blue" />
|
|
1061
|
-
<StatCard label="Backend Tokens" value={fmtTokens(stats.totalTokens)} sub="actual consumed" icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>} color="amber" />
|
|
1062
|
-
<StatCard label="Backend Cost" value={fmtCost(stats.totalTokens)} sub="at $3/1M tokens" icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>} color="green" />
|
|
1063
|
-
{showUsersTab && <StatCard label="Users" value={stats.totalUsers} sub="registered" icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>} color="purple" />}
|
|
1064
|
-
</div>
|
|
1065
|
-
|
|
1066
|
-
{/* Keys by tier */}
|
|
1067
|
-
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
|
1068
|
-
{['5x', '20x', 'unlimited'].map(tier => {
|
|
1069
|
-
const found = stats.keysByTier.find(k => k.tier === tier);
|
|
1070
|
-
const count = found?._count._all ?? 0;
|
|
1071
|
-
return (
|
|
1072
|
-
<div key={tier} className={`rounded-2xl border p-4 ${tierColor(tier)} card-shadow`}>
|
|
1073
|
-
<p className="text-xs font-bold uppercase tracking-wider mb-1">{tier === '5x' ? '5x Max' : tier === '20x' ? '20x Max' : tier.charAt(0).toUpperCase() + tier.slice(1)}</p>
|
|
1074
|
-
<p className="text-2xl font-black text-[#111827]">{count}</p>
|
|
1075
|
-
<p className="text-[#9CA3AF] text-xs">keys</p>
|
|
1076
|
-
</div>
|
|
1077
|
-
);
|
|
1078
|
-
})}
|
|
1079
|
-
</div>
|
|
1080
|
-
|
|
1081
|
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
1082
|
-
{/* Recent keys */}
|
|
1083
|
-
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden card-shadow">
|
|
1084
|
-
<div className="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
|
1085
|
-
<p className="text-[#9CA3AF] text-xs font-bold uppercase tracking-wider">Recent Keys</p>
|
|
1086
|
-
<button onClick={() => setActiveTab('keys')} className="text-xs text-purple-600 hover:text-purple-700 font-semibold">View all →</button>
|
|
1087
|
-
</div>
|
|
1088
|
-
<div className="divide-y divide-gray-50">
|
|
1089
|
-
{stats.recentKeys.length === 0 ? (
|
|
1090
|
-
<p className="text-center text-[#D1D5DB] text-sm py-8">No keys yet.</p>
|
|
1091
|
-
) : stats.recentKeys.slice(0, 5).map((k: any) => (
|
|
1092
|
-
<div key={k.id} className="px-4 py-3 flex items-center justify-between">
|
|
1093
|
-
<div>
|
|
1094
|
-
<p className="text-[#374151] text-sm font-medium">{k.name}</p>
|
|
1095
|
-
<p className="text-[#D1D5DB] text-xs font-mono">{k.prefix}••••</p>
|
|
1096
|
-
</div>
|
|
1097
|
-
<div className="text-right">
|
|
1098
|
-
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${k.isActive ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-500'}`}>{k.isActive ? 'Active' : 'Blocked'}</span>
|
|
1099
|
-
<p className="text-[#D1D5DB] text-xs mt-0.5">{k.windowRequestsUsed?.toLocaleString() ?? 0} req</p>
|
|
1100
|
-
</div>
|
|
1101
|
-
</div>
|
|
1102
|
-
))}
|
|
1103
|
-
</div>
|
|
1104
|
-
</div>
|
|
1105
|
-
|
|
1106
|
-
{/* Top tokens */}
|
|
1107
|
-
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden card-shadow">
|
|
1108
|
-
<div className="px-4 py-3 border-b border-gray-100">
|
|
1109
|
-
<p className="text-[#9CA3AF] text-xs font-bold uppercase tracking-wider">Top Tokens</p>
|
|
1110
|
-
</div>
|
|
1111
|
-
<div className="divide-y divide-gray-50">
|
|
1112
|
-
{stats.topTokens.length === 0 ? (
|
|
1113
|
-
<p className="text-center text-[#D1D5DB] text-sm py-8">No usage yet.</p>
|
|
1114
|
-
) : stats.topTokens.map((k: any, i: number) => (
|
|
1115
|
-
<div key={k.id} className="px-4 py-3 flex items-center justify-between">
|
|
1116
|
-
<div className="flex items-center gap-2">
|
|
1117
|
-
<span className="text-xs font-bold text-[#D1D5DB]">#{i + 1}</span>
|
|
1118
|
-
<div>
|
|
1119
|
-
<p className="text-[#374151] text-sm font-medium">{k.name}</p>
|
|
1120
|
-
<p className="text-[#D1D5DB] text-xs font-mono">{k.prefix}••••</p>
|
|
1121
|
-
</div>
|
|
1122
|
-
</div>
|
|
1123
|
-
<div className="text-right">
|
|
1124
|
-
<span className="text-purple-600 font-bold text-sm">{fmtTokens(Number(k.totalTokensUsed ?? 0))} actual</span>
|
|
1125
|
-
<span className="block text-[#D1D5DB] text-xs">{fmtTokens(Number(k.displayedTokens ?? 0))} shown ({k.displayMultiplier ?? 3}x)</span>
|
|
1126
|
-
</div>
|
|
1127
|
-
</div>
|
|
1128
|
-
))}
|
|
1129
|
-
</div>
|
|
1130
|
-
</div>
|
|
1131
|
-
</div>
|
|
1132
|
-
</>
|
|
1133
|
-
)}
|
|
1134
|
-
|
|
1135
|
-
{/* ── API Keys ── */}
|
|
1136
|
-
{activeTab === 'keys' && (
|
|
1137
|
-
<>
|
|
1138
|
-
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
1139
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
1140
|
-
<div className="relative">
|
|
1141
|
-
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#D1D5DB]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
1142
|
-
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search keys..." className="h-9 pl-9 pr-4 bg-white border border-gray-200 rounded-xl text-sm text-[#111827] placeholder:text-[#D1D5DB] focus:outline-none focus:border-purple-400 w-52 transition-all" />
|
|
1143
|
-
</div>
|
|
1144
|
-
<div className="flex bg-white border border-gray-200 rounded-xl overflow-hidden">
|
|
1145
|
-
{tierTabs.map(t => (
|
|
1146
|
-
<button key={t.value} onClick={() => setTierFilter(t.value)} className={`px-3 h-9 text-xs font-semibold transition-all ${tierFilter === t.value ? 'bg-purple-600 text-white' : 'text-[#6B7280] hover:bg-gray-50'}`}>{t.label}</button>
|
|
1147
|
-
))}
|
|
1148
|
-
</div>
|
|
1149
|
-
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="h-9 px-3 bg-white border border-gray-200 rounded-xl text-sm text-[#6B7280] focus:outline-none transition-all">
|
|
1150
|
-
<option value="">All Status</option>
|
|
1151
|
-
<option value="active">Active</option>
|
|
1152
|
-
<option value="inactive">Inactive</option>
|
|
1153
|
-
</select>
|
|
1154
|
-
</div>
|
|
1155
|
-
<div className="flex items-center gap-2">
|
|
1156
|
-
<span className="text-[#9CA3AF] text-xs">{totalKeys} keys</span>
|
|
1157
|
-
<button onClick={() => setCreateKeyOpen(true)} className="h-9 px-4 bg-[#5244F3] hover:bg-[#4338CA] text-white rounded-xl text-sm font-semibold transition-colors shadow-sm flex items-center gap-1.5">
|
|
1158
|
-
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
|
1159
|
-
Create Key
|
|
1160
|
-
</button>
|
|
1161
|
-
</div>
|
|
1162
|
-
</div>
|
|
1163
|
-
|
|
1164
|
-
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden card-shadow">
|
|
1165
|
-
<div className="overflow-x-auto">
|
|
1166
|
-
<table className="w-full text-sm">
|
|
1167
|
-
<thead>
|
|
1168
|
-
<tr className="border-b border-gray-100 bg-gray-50">
|
|
1169
|
-
{['Key', 'Name', me.role !== 'reseller' ? 'Reseller' : '', 'Tier', 'Multiplier', 'Token Limit', 'Requests', 'Actual Tok', 'Displayed Tok', 'Status', 'Actions'].filter(Boolean).map(h => (
|
|
1170
|
-
<th key={h} className="text-left text-[#9CA3AF] font-bold px-3 py-3 text-xs uppercase tracking-wider whitespace-nowrap">{h}</th>
|
|
1171
|
-
))}
|
|
1172
|
-
</tr>
|
|
1173
|
-
</thead>
|
|
1174
|
-
<tbody>
|
|
1175
|
-
{loading ? (
|
|
1176
|
-
Array.from({ length: 5 }).map((_, i) => (
|
|
1177
|
-
<tr key={i} className="border-b border-gray-100 last:border-0">
|
|
1178
|
-
{Array.from({ length: 9 }).map((_, j) => (<td key={j} className="px-4 py-3"><div className="h-4 bg-gray-100 rounded animate-pulse w-24" /></td>))}
|
|
1179
|
-
</tr>
|
|
1180
|
-
))
|
|
1181
|
-
) : keys.length === 0 ? (
|
|
1182
|
-
<tr><td colSpan={9} className="px-4 py-16 text-center text-[#D1D5DB] text-sm">No API keys found.</td></tr>
|
|
1183
|
-
) : (
|
|
1184
|
-
keys.map(k => (
|
|
1185
|
-
<tr key={k.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50/50 transition-colors">
|
|
1186
|
-
<td className="px-3 py-3">
|
|
1187
|
-
<div className="flex items-center gap-2">
|
|
1188
|
-
<code className="text-purple-600 font-mono text-xs">{k.prefix}••••</code>
|
|
1189
|
-
<button onClick={() => { navigator.clipboard.writeText(k.key); setCopiedKey(k.id); setTimeout(() => setCopiedKey(''), 2000); }} className="text-[#D1D5DB] hover:text-[#6B7280] transition-colors">
|
|
1190
|
-
{copiedKey === k.id ? <svg className="w-3.5 h-3.5 text-emerald-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5"/></svg> : <svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>}
|
|
1191
|
-
</button>
|
|
1192
|
-
</div>
|
|
1193
|
-
</td>
|
|
1194
|
-
<td className="px-3 py-3 text-[#374151] text-xs font-medium max-w-32 truncate">{k.name}</td>
|
|
1195
|
-
{me.role !== 'reseller' && <td className="px-3 py-3 text-[#6B7280] text-xs max-w-24 truncate">{k.reseller?.name ?? '—'}</td>}
|
|
1196
|
-
<td className="px-3 py-3"><span className={`text-xs font-bold px-2 py-0.5 rounded-full border ${tierColor(k.tier)}`}>{k.tier === '5x' ? '5x' : k.tier === '20x' ? '20x' : k.tier.charAt(0).toUpperCase() + k.tier.slice(1)}</span></td>
|
|
1197
|
-
<td className="px-3 py-3">
|
|
1198
|
-
<select
|
|
1199
|
-
value={k.displayMultiplier ?? 3}
|
|
1200
|
-
onChange={async (e) => {
|
|
1201
|
-
const val = parseFloat(e.target.value);
|
|
1202
|
-
await fetch(`/api/admin/keys/${k.id}`, {
|
|
1203
|
-
method: 'PATCH',
|
|
1204
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1205
|
-
body: JSON.stringify({ action: 'update', displayMultiplier: val }),
|
|
1206
|
-
});
|
|
1207
|
-
fetchKeys(page);
|
|
1208
|
-
}}
|
|
1209
|
-
className="h-6 px-1 bg-purple-50 border border-purple-200 rounded text-xs font-bold text-purple-700 focus:outline-none cursor-pointer"
|
|
1210
|
-
>
|
|
1211
|
-
<option value={1}>1x</option>
|
|
1212
|
-
<option value={2}>2x</option>
|
|
1213
|
-
<option value={3}>3x</option>
|
|
1214
|
-
<option value={4}>4x</option>
|
|
1215
|
-
<option value={5}>5x</option>
|
|
1216
|
-
</select>
|
|
1217
|
-
</td>
|
|
1218
|
-
<td className="px-3 py-3">
|
|
1219
|
-
<div className="flex items-center gap-1">
|
|
1220
|
-
<input
|
|
1221
|
-
type="number"
|
|
1222
|
-
defaultValue={k.tokenLimitOverride ? Math.floor(Number(k.tokenLimitOverride) / 1_000_000) : ''}
|
|
1223
|
-
placeholder={TIER_LIMITS[k.tier] ? `${(TIER_LIMITS[k.tier].tokensPerWindow / 1_000_000).toFixed(0)}M` : '—'}
|
|
1224
|
-
onBlur={async (e) => {
|
|
1225
|
-
const val = e.target.value;
|
|
1226
|
-
const num = val ? Math.floor(parseFloat(val) * 1_000_000) : null;
|
|
1227
|
-
await fetch(`/api/admin/keys/${k.id}`, {
|
|
1228
|
-
method: 'PATCH',
|
|
1229
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1230
|
-
body: JSON.stringify({ action: 'update', tokenLimitOverride: num }),
|
|
1231
|
-
});
|
|
1232
|
-
fetchKeys(page);
|
|
1233
|
-
}}
|
|
1234
|
-
className="w-16 h-6 px-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700 font-mono focus:outline-none focus:border-blue-400"
|
|
1235
|
-
/>
|
|
1236
|
-
<span className="text-[#9CA3AF] text-xs">M</span>
|
|
1237
|
-
</div>
|
|
1238
|
-
</td>
|
|
1239
|
-
<td className="px-3 py-3 font-mono text-xs text-[#6B7280]">{k.windowRequestsUsed?.toLocaleString() ?? 0}</td>
|
|
1240
|
-
<td className="px-3 py-3 font-mono text-xs text-[#D1D5DB]">{fmtTokens(k.windowTokensUsed ?? 0)}</td>
|
|
1241
|
-
<td className="px-3 py-3 font-mono text-xs font-bold text-purple-600">{fmtTokens(getDisplayedTokens(k))}</td>
|
|
1242
|
-
<td className="px-3 py-3">
|
|
1243
|
-
{k.blockedUntil ? (
|
|
1244
|
-
<div>
|
|
1245
|
-
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-amber-50 text-amber-600 border border-amber-100">Temp Blocked</span>
|
|
1246
|
-
<p className="text-[#9CA3AF] text-xs mt-0.5">{new Date(k.blockedUntil).toLocaleString()}</p>
|
|
1247
|
-
</div>
|
|
1248
|
-
) : (
|
|
1249
|
-
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${k.isActive ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' : 'bg-red-50 text-red-500 border border-red-100'}`}>{k.isActive ? 'Active' : 'Blocked'}</span>
|
|
1250
|
-
)}
|
|
1251
|
-
</td>
|
|
1252
|
-
<td className="px-3 py-3">
|
|
1253
|
-
<div className="flex items-center gap-0.5">
|
|
1254
|
-
{(me.role === 'super_admin' || me.role === 'admin') && (
|
|
1255
|
-
<button onClick={() => setEditKey(k)} disabled={actionLoading === k.id} className="w-7 h-7 rounded-lg hover:bg-blue-50 flex items-center justify-center text-[#9CA3AF] hover:text-blue-500 transition-colors" title="Edit">
|
|
1256
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
1257
|
-
</button>
|
|
1258
|
-
)}
|
|
1259
|
-
{k.blockedUntil ? (
|
|
1260
|
-
<button onClick={() => doAction(k.id, 'unblock')} disabled={actionLoading === k.id + 'unblock'} className="w-7 h-7 rounded-lg hover:bg-emerald-50 flex items-center justify-center text-[#9CA3AF] hover:text-emerald-500 transition-colors" title="Unblock">
|
|
1261
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>
|
|
1262
|
-
</button>
|
|
1263
|
-
) : k.isActive ? (
|
|
1264
|
-
<>
|
|
1265
|
-
<button onClick={() => setConfirmModal({ title: 'Block API Key', message: `Block "${k.name}"? It will no longer accept requests.`, onConfirm: () => { setConfirmModal(null); doAction(k.id, 'block'); }, danger: true })} disabled={actionLoading === k.id + 'block'} className="w-7 h-7 rounded-lg hover:bg-amber-50 flex items-center justify-center text-[#9CA3AF] hover:text-amber-500 transition-colors" title="Block">
|
|
1266
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M4.93 4.93l14.14 14.14"/></svg>
|
|
1267
|
-
</button>
|
|
1268
|
-
</>
|
|
1269
|
-
) : (
|
|
1270
|
-
<button onClick={() => doAction(k.id, 'unblock')} disabled={actionLoading === k.id + 'unblock'} className="w-7 h-7 rounded-lg hover:bg-emerald-50 flex items-center justify-center text-[#9CA3AF] hover:text-emerald-500 transition-colors" title="Unblock">
|
|
1271
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>
|
|
1272
|
-
</button>
|
|
1273
|
-
)}
|
|
1274
|
-
<button onClick={() => setConfirmModal({ title: 'Delete API Key', message: `Permanently delete "${k.name}"? This cannot be undone.`, onConfirm: () => { setConfirmModal(null); doAction(k.id, 'delete'); }, danger: true })} disabled={actionLoading === k.id + 'delete'} className="w-7 h-7 rounded-lg hover:bg-red-50 flex items-center justify-center text-[#9CA3AF] hover:text-red-500 transition-colors" title="Delete">
|
|
1275
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/><path d="M10,11v6M14,11v6"/><path d="M9,6V4a1,1,0,0,1,1-1h4a1,1,0,0,1,1,1V6"/></svg>
|
|
1276
|
-
</button>
|
|
1277
|
-
</div>
|
|
1278
|
-
</td>
|
|
1279
|
-
</tr>
|
|
1280
|
-
))
|
|
1281
|
-
)}
|
|
1282
|
-
</tbody>
|
|
1283
|
-
</table>
|
|
1284
|
-
</div>
|
|
1285
|
-
{totalKeys > 15 && (
|
|
1286
|
-
<div className="flex items-center justify-center gap-1 py-3 border-t border-gray-100">
|
|
1287
|
-
{Array.from({ length: Math.min(Math.ceil(totalKeys / 15), 7) }, (_, i) => (
|
|
1288
|
-
<button key={i} onClick={() => fetchKeys(i + 1)} className={`w-8 h-8 rounded-lg text-xs font-semibold transition-colors ${page === i + 1 ? 'bg-purple-600 text-white' : 'hover:bg-gray-100 text-[#6B7280]'}`}>{i + 1}</button>
|
|
1289
|
-
))}
|
|
1290
|
-
</div>
|
|
1291
|
-
)}
|
|
1292
|
-
</div>
|
|
1293
|
-
</>
|
|
1294
|
-
)}
|
|
1295
|
-
|
|
1296
|
-
{/* ── Users ── */}
|
|
1297
|
-
{activeTab === 'users' && showUsersTab && (
|
|
1298
|
-
<>
|
|
1299
|
-
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
1300
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
1301
|
-
<div className="relative">
|
|
1302
|
-
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#D1D5DB]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
|
1303
|
-
<input value={userSearch} onChange={e => setUserSearch(e.target.value)} placeholder="Search users..." className="h-9 pl-9 pr-4 bg-white border border-gray-200 rounded-xl text-sm text-[#111827] placeholder:text-[#D1D5DB] focus:outline-none focus:border-purple-400 w-52 transition-all" />
|
|
1304
|
-
</div>
|
|
1305
|
-
<div className="flex bg-white border border-gray-200 rounded-xl overflow-hidden">
|
|
1306
|
-
{userRoleTabs.map(t => (
|
|
1307
|
-
<button key={t.value} onClick={() => setUserRoleFilter(t.value)} className={`px-3 h-9 text-xs font-semibold transition-all ${userRoleFilter === t.value ? 'bg-purple-600 text-white' : 'text-[#6B7280] hover:bg-gray-50'}`}>{t.label}</button>
|
|
1308
|
-
))}
|
|
1309
|
-
</div>
|
|
1310
|
-
<select value={userStatusFilter} onChange={e => setUserStatusFilter(e.target.value)} className="h-9 px-3 bg-white border border-gray-200 rounded-xl text-sm text-[#6B7280] focus:outline-none transition-all">
|
|
1311
|
-
<option value="">All Status</option>
|
|
1312
|
-
<option value="active">Active</option>
|
|
1313
|
-
<option value="inactive">Inactive</option>
|
|
1314
|
-
</select>
|
|
1315
|
-
</div>
|
|
1316
|
-
<div className="flex items-center gap-2">
|
|
1317
|
-
<span className="text-[#9CA3AF] text-xs">{totalUsers} users</span>
|
|
1318
|
-
{canCreateResellers && (
|
|
1319
|
-
<button onClick={() => setCreateUserOpen(true)} className="h-9 px-4 bg-[#5244F3] hover:bg-[#4338CA] text-white rounded-xl text-sm font-semibold transition-colors shadow-sm flex items-center gap-1.5">
|
|
1320
|
-
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
|
1321
|
-
Create User
|
|
1322
|
-
</button>
|
|
1323
|
-
)}
|
|
1324
|
-
</div>
|
|
1325
|
-
</div>
|
|
1326
|
-
|
|
1327
|
-
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden card-shadow">
|
|
1328
|
-
<div className="overflow-x-auto">
|
|
1329
|
-
<table className="w-full text-sm">
|
|
1330
|
-
<thead>
|
|
1331
|
-
<tr className="border-b border-gray-100 bg-gray-50">
|
|
1332
|
-
{['User', 'Role', 'Status', 'API Keys', 'Created', 'Actions'].map(h => (
|
|
1333
|
-
<th key={h} className="text-left text-[#9CA3AF] font-bold px-4 py-3 text-xs uppercase tracking-wider whitespace-nowrap">{h}</th>
|
|
1334
|
-
))}
|
|
1335
|
-
</tr>
|
|
1336
|
-
</thead>
|
|
1337
|
-
<tbody>
|
|
1338
|
-
{users.length === 0 ? (
|
|
1339
|
-
<tr><td colSpan={6} className="px-4 py-16 text-center text-[#D1D5DB] text-sm">No users found.</td></tr>
|
|
1340
|
-
) : (
|
|
1341
|
-
users.map(u => (
|
|
1342
|
-
<tr key={u.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50/50 transition-colors">
|
|
1343
|
-
<td className="px-4 py-3">
|
|
1344
|
-
<div>
|
|
1345
|
-
<p className="text-[#374151] text-sm font-medium">{u.name}</p>
|
|
1346
|
-
<p className="text-[#D1D5DB] text-xs">@{u.username}</p>
|
|
1347
|
-
</div>
|
|
1348
|
-
</td>
|
|
1349
|
-
<td className="px-4 py-3"><span className={`text-xs font-bold px-2 py-0.5 rounded-full border ${roleColor(u.role)}`}>{roleLabel(u.role)}</span></td>
|
|
1350
|
-
<td className="px-4 py-3">
|
|
1351
|
-
{u.blockedUntil ? (
|
|
1352
|
-
<div>
|
|
1353
|
-
<span className="text-xs font-bold px-2 py-0.5 rounded-full bg-amber-50 text-amber-600 border border-amber-100">Blocked</span>
|
|
1354
|
-
<p className="text-[#9CA3AF] text-xs mt-0.5">{new Date(u.blockedUntil).toLocaleString()}</p>
|
|
1355
|
-
</div>
|
|
1356
|
-
) : (
|
|
1357
|
-
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${u.isActive ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' : 'bg-red-50 text-red-500 border border-red-100'}`}>{u.isActive ? 'Active' : 'Disabled'}</span>
|
|
1358
|
-
)}
|
|
1359
|
-
</td>
|
|
1360
|
-
<td className="px-4 py-3 font-mono text-xs text-[#6B7280]">{u.apiKeyCount}</td>
|
|
1361
|
-
<td className="px-4 py-3 text-xs text-[#9CA3AF]">{new Date(u.createdAt).toLocaleDateString()}</td>
|
|
1362
|
-
<td className="px-4 py-3">
|
|
1363
|
-
<div className="flex items-center gap-0.5">
|
|
1364
|
-
{me.role !== 'reseller' && u.role === 'reseller' && (
|
|
1365
|
-
<button onClick={() => setPermissionsUser(u)} className="w-7 h-7 rounded-lg hover:bg-purple-50 flex items-center justify-center text-[#9CA3AF] hover:text-purple-500 transition-colors" title="Permissions">
|
|
1366
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
1367
|
-
</button>
|
|
1368
|
-
)}
|
|
1369
|
-
<button onClick={() => setEditUser(u)} className="w-7 h-7 rounded-lg hover:bg-gray-100 flex items-center justify-center text-[#9CA3AF] hover:text-[#6B7280] transition-colors" title="Edit">
|
|
1370
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
1371
|
-
</button>
|
|
1372
|
-
{u.id !== me.id && (
|
|
1373
|
-
<>
|
|
1374
|
-
{!u.blockedUntil && u.isActive && me.role !== 'reseller' && (
|
|
1375
|
-
<button onClick={() => setTempBlockUser(u)} className="w-7 h-7 rounded-lg hover:bg-amber-50 flex items-center justify-center text-[#9CA3AF] hover:text-amber-500 transition-colors" title="Temp Block">
|
|
1376
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
|
1377
|
-
</button>
|
|
1378
|
-
)}
|
|
1379
|
-
{u.blockedUntil && (
|
|
1380
|
-
<button onClick={() => doUserAction(u.id, 'unblockTemp')} disabled={actionLoading === u.id + 'unblockTemp'} className="w-7 h-7 rounded-lg hover:bg-emerald-50 flex items-center justify-center text-[#9CA3AF] hover:text-emerald-500 transition-colors" title="Unblock">
|
|
1381
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>
|
|
1382
|
-
</button>
|
|
1383
|
-
)}
|
|
1384
|
-
{!u.blockedUntil && !u.isActive && (
|
|
1385
|
-
<button onClick={() => doUserAction(u.id, 'toggle')} disabled={actionLoading === u.id + 'toggle'} className="w-7 h-7 rounded-lg hover:bg-emerald-50 flex items-center justify-center text-[#9CA3AF] hover:text-emerald-500 transition-colors" title="Enable">
|
|
1386
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>
|
|
1387
|
-
</button>
|
|
1388
|
-
)}
|
|
1389
|
-
{!u.blockedUntil && u.isActive && me.role === 'super_admin' && (
|
|
1390
|
-
<button onClick={() => setConfirmModal({ title: 'Disable User', message: `Disable "${u.name}" (@${u.username})? They will not be able to log in.`, onConfirm: () => { setConfirmModal(null); doUserAction(u.id, 'toggle'); }, danger: true })} disabled={actionLoading === u.id + 'toggle'} className="w-7 h-7 rounded-lg hover:bg-red-50 flex items-center justify-center text-[#9CA3AF] hover:text-red-400 transition-colors" title="Disable">
|
|
1391
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M4.93 4.93l14.14 14.14"/></svg>
|
|
1392
|
-
</button>
|
|
1393
|
-
)}
|
|
1394
|
-
{me.role === 'super_admin' && (
|
|
1395
|
-
<button onClick={() => setConfirmModal({ title: 'Delete User', message: `Permanently delete "${u.name}" (@${u.username})? Their API keys will remain but become unassigned.`, onConfirm: () => doDeleteUser(u.id), danger: true })} disabled={actionLoading === u.id + 'delete'} className="w-7 h-7 rounded-lg hover:bg-red-50 flex items-center justify-center text-[#9CA3AF] hover:text-red-500 transition-colors" title="Delete">
|
|
1396
|
-
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/><path d="M10,11v6M14,11v6"/><path d="M9,6V4a1,1,0,0,1,1-1h4a1,1,0,0,1,1,1V6"/></svg>
|
|
1397
|
-
</button>
|
|
1398
|
-
)}
|
|
1399
|
-
</>
|
|
1400
|
-
)}
|
|
1401
|
-
</div>
|
|
1402
|
-
</td>
|
|
1403
|
-
</tr>
|
|
1404
|
-
))
|
|
1405
|
-
)}
|
|
1406
|
-
</tbody>
|
|
1407
|
-
</table>
|
|
1408
|
-
</div>
|
|
1409
|
-
</div>
|
|
1410
|
-
</>
|
|
1411
|
-
)}
|
|
1412
|
-
|
|
1413
|
-
{/* ── Blog ── */}
|
|
1414
|
-
{activeTab === 'blog' && (me.role === 'super_admin' || me.role === 'admin') && (
|
|
1415
|
-
<>
|
|
1416
|
-
<BlogTab
|
|
1417
|
-
posts={blogPosts}
|
|
1418
|
-
total={totalPosts}
|
|
1419
|
-
page={blogPage}
|
|
1420
|
-
onFetch={(p) => fetchBlogPosts(p)}
|
|
1421
|
-
onFetchStats={fetchStats}
|
|
1422
|
-
/>
|
|
1423
|
-
</>
|
|
1424
|
-
)}
|
|
1425
|
-
|
|
1426
|
-
{/* ── Health ── */}
|
|
1427
|
-
{activeTab === 'health' && (
|
|
1428
|
-
<div className="space-y-6">
|
|
1429
|
-
{/* API Status */}
|
|
1430
|
-
<div className="bg-white rounded-2xl border border-gray-200 card-shadow p-6">
|
|
1431
|
-
<div className="flex items-center justify-between mb-4">
|
|
1432
|
-
<h2 className="text-lg font-bold text-[#111827]">OpenRouter API Status</h2>
|
|
1433
|
-
<div className="flex items-center gap-2">
|
|
1434
|
-
<button onClick={fetchHealth} disabled={healthLoading} className="text-xs bg-gray-100 hover:bg-gray-200 text-[#6B7280] px-3 py-1.5 rounded-lg font-semibold transition-colors disabled:opacity-50">Refresh</button>
|
|
1435
|
-
<span className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold ${healthData?.api?.status === 'ok' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-red-50 text-red-600 border border-red-200'}`}>
|
|
1436
|
-
<span className={`w-2 h-2 rounded-full ${healthData?.api?.status === 'ok' ? 'bg-emerald-500 animate-pulse' : 'bg-red-500'}`} />
|
|
1437
|
-
{healthData?.api?.status === 'ok' ? 'Online' : 'Offline'}
|
|
1438
|
-
{healthData?.api?.latency > 0 && <span className="text-xs opacity-75">· {healthData.api.latency}ms</span>}
|
|
1439
|
-
</span>
|
|
1440
|
-
</div>
|
|
1441
|
-
</div>
|
|
1442
|
-
{healthData?.api?.error && (
|
|
1443
|
-
<p className="text-red-600 text-xs bg-red-50 border border-red-200 rounded-lg px-3 py-2 mb-3">Error: {healthData.api.error}</p>
|
|
1444
|
-
)}
|
|
1445
|
-
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
1446
|
-
<StatCard label="Total Keys" value={healthData?.stats?.totalKeys ?? 0} sub={`${healthData?.stats?.activeKeys ?? 0} active`} icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>} color="purple" />
|
|
1447
|
-
<StatCard label="Total Tokens" value={fmtTokens(healthData?.stats?.totalTokens ?? 0)} sub="consumed all time" icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>} color="blue" />
|
|
1448
|
-
<StatCard label="Over Limit" value={healthData?.overLimitCount ?? 0} sub="keys at limit" icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>} color="amber" />
|
|
1449
|
-
<StatCard label="Active Now" value={healthData?.activeCount ?? 0} sub="in last 5min" icon={<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>} color="green" />
|
|
1450
|
-
</div>
|
|
1451
|
-
</div>
|
|
1452
|
-
|
|
1453
|
-
{/* Top Keys by Usage */}
|
|
1454
|
-
<div className="bg-white rounded-2xl border border-gray-200 card-shadow overflow-hidden">
|
|
1455
|
-
<div className="p-6 border-b border-gray-100">
|
|
1456
|
-
<h2 className="text-lg font-bold text-[#111827]">Top Keys by Usage</h2>
|
|
1457
|
-
</div>
|
|
1458
|
-
<div className="overflow-x-auto">
|
|
1459
|
-
<table className="w-full">
|
|
1460
|
-
<thead className="bg-gray-50 border-b border-gray-100">
|
|
1461
|
-
<tr>
|
|
1462
|
-
{['Key', 'Plan', 'Window Tokens', 'Total Tokens', 'Requests', 'Reset At', 'Reseller', 'Status'].map(h => (
|
|
1463
|
-
<th key={h} className="px-4 py-3 text-left text-xs font-bold text-[#9CA3AF] uppercase tracking-wider">{h}</th>
|
|
1464
|
-
))}
|
|
1465
|
-
</tr>
|
|
1466
|
-
</thead>
|
|
1467
|
-
<tbody>
|
|
1468
|
-
{healthLoading && !healthData ? (
|
|
1469
|
-
Array.from({ length: 5 }).map((_, i) => (
|
|
1470
|
-
<tr key={i} className="border-b border-gray-100">
|
|
1471
|
-
{Array.from({ length: 8 }).map((_, j) => (<td key={j} className="px-4 py-3"><div className="h-4 bg-gray-100 rounded animate-pulse w-24" /></td>))}
|
|
1472
|
-
</tr>
|
|
1473
|
-
))
|
|
1474
|
-
) : healthData?.topKeys?.length === 0 ? (
|
|
1475
|
-
<tr><td colSpan={8} className="px-4 py-12 text-center text-[#D1D5DB] text-sm">No keys found.</td></tr>
|
|
1476
|
-
) : (
|
|
1477
|
-
healthData?.topKeys?.map((k: any) => {
|
|
1478
|
-
const resetAt = k.windowResetAt ? new Date(k.windowResetAt) : null;
|
|
1479
|
-
const now = Date.now();
|
|
1480
|
-
const resetIn = resetAt ? Math.max(0, Math.floor((resetAt.getTime() - now) / 1000)) : null;
|
|
1481
|
-
const resetStr = resetIn !== null ? (resetIn > 0 ? `${Math.floor(resetIn / 3600)}h ${Math.floor((resetIn % 3600) / 60)}m` : 'now') : 'n/a';
|
|
1482
|
-
return (
|
|
1483
|
-
<tr key={k.id} className={`border-b border-gray-100 last:border-0 ${k.isOverLimit ? 'bg-red-50/30' : ''}`}>
|
|
1484
|
-
<td className="px-4 py-3">
|
|
1485
|
-
<p className="text-sm font-bold text-[#111827]">{k.name}</p>
|
|
1486
|
-
<code className="text-[#9CA3AF] text-xs font-mono">{k.prefix}••••</code>
|
|
1487
|
-
</td>
|
|
1488
|
-
<td className="px-4 py-3"><span className={`text-xs font-bold px-2 py-1 rounded-full ${tierColor(k.tier)}`}>{k.tier}</span></td>
|
|
1489
|
-
<td className="px-4 py-3">
|
|
1490
|
-
<p className={`text-sm font-bold ${k.isOverLimit ? 'text-red-600' : 'text-[#111827]'}`}>{fmtTokens(k.windowTokensUsed)}</p>
|
|
1491
|
-
{k.isOverLimit && <span className="text-xs text-red-500 font-semibold">OVER</span>}
|
|
1492
|
-
</td>
|
|
1493
|
-
<td className="px-4 py-3 text-sm font-semibold text-[#111827]">{fmtTokens(k.totalTokensUsed)}</td>
|
|
1494
|
-
<td className="px-4 py-3 text-sm text-[#6B7280]">{k.windowRequestsUsed.toLocaleString()}</td>
|
|
1495
|
-
<td className="px-4 py-3 text-sm text-[#6B7280]">{resetStr}</td>
|
|
1496
|
-
<td className="px-4 py-3 text-sm text-[#6B7280]">{k.resellerName || '—'}</td>
|
|
1497
|
-
<td className="px-4 py-3">
|
|
1498
|
-
<span className={`text-xs font-bold px-2 py-1 rounded-full flex items-center gap-1 w-fit ${k.isActive ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-red-50 text-red-500 border border-red-100'}`}>
|
|
1499
|
-
<span className={`w-1.5 h-1.5 rounded-full ${k.isActive ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
|
1500
|
-
{k.isActive ? 'Active' : 'Inactive'}
|
|
1501
|
-
</span>
|
|
1502
|
-
</td>
|
|
1503
|
-
</tr>
|
|
1504
|
-
);
|
|
1505
|
-
})
|
|
1506
|
-
)}
|
|
1507
|
-
</tbody>
|
|
1508
|
-
</table>
|
|
1509
|
-
</div>
|
|
1510
|
-
</div>
|
|
1511
|
-
</div>
|
|
1512
|
-
)}
|
|
1513
|
-
{/* ── Settings ── */}
|
|
1514
|
-
{activeTab === 'settings' && me.role === 'super_admin' && (
|
|
1515
|
-
<SettingsTab />
|
|
1516
|
-
)}
|
|
1517
|
-
</div>
|
|
1518
|
-
|
|
1519
|
-
{/* Modals */}
|
|
1520
|
-
{confirmModal && <ConfirmModal {...confirmModal} onCancel={() => setConfirmModal(null)} />}
|
|
1521
|
-
{editUser && <EditUserModal user={editUser} onSave={(d) => handleSaveUser(editUser.id, d)} onCancel={() => setEditUser(null)} />}
|
|
1522
|
-
{createUserOpen && <CreateUserModal onCreate={handleCreateUser} onCancel={() => setCreateUserOpen(false)} myRole={me.role} />}
|
|
1523
|
-
{createKeyOpen && <CreateKeyModal onCreate={handleCreateKey} onCancel={() => setCreateKeyOpen(false)} users={users} myRole={me.role} plans={plans} allowedPlanIds={me.allowedPlanIds ? JSON.parse(me.allowedPlanIds) : []} defaultPlanId={me.defaultPlanId ?? ''} />}
|
|
1524
|
-
{editKey && <EditKeyModal apiKey={editKey} onSave={(d) => handleSaveKey(editKey.id, d)} onCancel={() => setEditKey(null)} />}
|
|
1525
|
-
{permissionsUser && <PermissionsModal user={permissionsUser} onSave={(p) => handleSavePermissions(permissionsUser.id, p)} onCancel={() => setPermissionsUser(null)} />}
|
|
1526
|
-
{tempBlockUser && <TempBlockModal user={tempBlockUser} onConfirm={(duration) => { setTempBlockUser(null); doUserAction(tempBlockUser.id, 'blockTemp', { duration }); }} onCancel={() => setTempBlockUser(null)} />}
|
|
1527
|
-
{editingPost && <BlogModal post={editingPost} onSave={async (data) => { await fetch(`/api/admin/posts/${editingPost.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); setEditingPost(null); }} onCancel={() => setEditingPost(null)} />}
|
|
1528
|
-
{creatingPost && <BlogModal onSave={async (data) => { await fetch('/api/admin/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); setCreatingPost(false); }} onCancel={() => setCreatingPost(false)} />}
|
|
1529
|
-
|
|
1530
|
-
{/* Newly created key toast */}
|
|
1531
|
-
{newlyCreated && (
|
|
1532
|
-
<div className="fixed bottom-6 right-6 z-50 bg-white rounded-2xl border border-amber-200 shadow-xl p-4 max-w-sm w-full">
|
|
1533
|
-
<div className="flex items-start justify-between gap-3">
|
|
1534
|
-
<div>
|
|
1535
|
-
<p className="text-amber-600 text-xs font-bold uppercase tracking-wider mb-1">Save your API key</p>
|
|
1536
|
-
<p className="text-[#374151] text-sm font-medium mb-2">Copy it now — you won't see it again.</p>
|
|
1537
|
-
<code className="text-purple-600 font-mono text-xs break-all">{newlyCreated}</code>
|
|
1538
|
-
</div>
|
|
1539
|
-
<button onClick={() => { navigator.clipboard.writeText(newlyCreated); setNewlyCreated(''); }} className="shrink-0 text-xs bg-purple-100 hover:bg-purple-200 text-purple-700 px-2 py-1 rounded-lg transition-colors">Copy</button>
|
|
1540
|
-
</div>
|
|
1541
|
-
<button onClick={() => setNewlyCreated('')} className="mt-3 w-full h-8 rounded-xl bg-gray-100 hover:bg-gray-200 text-[#6B7280] text-xs font-semibold transition-colors">Close</button>
|
|
1542
|
-
</div>
|
|
1543
|
-
)}
|
|
1544
|
-
</div>
|
|
1545
|
-
);
|
|
1546
|
-
}
|