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,13 +0,0 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
2
|
-
import AdminDashboardClient from './AdminDashboardClient';
|
|
3
|
-
|
|
4
|
-
export const metadata: Metadata = {
|
|
5
|
-
title: 'Dashboard | ClaudMax Admin',
|
|
6
|
-
description: 'ClaudMax admin dashboard. Manage API keys, monitor usage, view stats, and control reseller access.',
|
|
7
|
-
openGraph: { title: 'Dashboard | ClaudMax Admin', type: 'website' },
|
|
8
|
-
robots: { index: false, follow: false },
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export default function AdminDashboardPage() {
|
|
12
|
-
return <AdminDashboardClient />;
|
|
13
|
-
}
|
package/src/app/admin/page.tsx
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import Link from 'next/link';
|
|
5
|
-
import { useRouter } from 'next/navigation';
|
|
6
|
-
import Header from '@/components/Header';
|
|
7
|
-
import Footer from '@/components/Footer';
|
|
8
|
-
|
|
9
|
-
export default function AdminLogin() {
|
|
10
|
-
const [username, setUsername] = useState('');
|
|
11
|
-
const [password, setPassword] = useState('');
|
|
12
|
-
const [error, setError] = useState('');
|
|
13
|
-
const [loading, setLoading] = useState(false);
|
|
14
|
-
const [showPassword, setShowPassword] = useState(false);
|
|
15
|
-
const router = useRouter();
|
|
16
|
-
|
|
17
|
-
const login = async (e: React.FormEvent) => {
|
|
18
|
-
e.preventDefault();
|
|
19
|
-
if (!username.trim() || !password) return;
|
|
20
|
-
setLoading(true);
|
|
21
|
-
setError('');
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
const res = await fetch('/api/admin/login', {
|
|
25
|
-
method: 'POST',
|
|
26
|
-
headers: { 'Content-Type': 'application/json' },
|
|
27
|
-
body: JSON.stringify({ username: username.trim(), password }),
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
if (res.ok) {
|
|
31
|
-
router.push('/admin/dashboard');
|
|
32
|
-
} else {
|
|
33
|
-
const data = await res.json();
|
|
34
|
-
setError(data.error ?? 'Invalid credentials');
|
|
35
|
-
}
|
|
36
|
-
} catch {
|
|
37
|
-
setError('Connection error. Please try again.');
|
|
38
|
-
} finally {
|
|
39
|
-
setLoading(false);
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
<div className="min-h-screen bg-[#F9FAFB] flex flex-col">
|
|
45
|
-
<Header />
|
|
46
|
-
|
|
47
|
-
<div className="flex-1 flex items-center justify-center p-4">
|
|
48
|
-
<div className="w-full max-w-sm">
|
|
49
|
-
{/* Logo */}
|
|
50
|
-
<div className="text-center mb-8">
|
|
51
|
-
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-600 to-purple-500 flex items-center justify-center mx-auto mb-4 shadow-md">
|
|
52
|
-
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="text-white">
|
|
53
|
-
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="currentColor"/>
|
|
54
|
-
</svg>
|
|
55
|
-
</div>
|
|
56
|
-
<h1 className="text-2xl font-black text-[#111827]">ClaudMax Admin</h1>
|
|
57
|
-
<p className="text-[#9CA3AF] text-sm mt-1">Sign in to manage keys, users, and usage.</p>
|
|
58
|
-
</div>
|
|
59
|
-
|
|
60
|
-
{/* Login form */}
|
|
61
|
-
<div className="bg-white rounded-2xl border border-gray-200 p-6 card-shadow">
|
|
62
|
-
<form onSubmit={login} className="space-y-4">
|
|
63
|
-
<div>
|
|
64
|
-
<label className="text-[#6B7280] text-sm font-medium block mb-1.5">Username</label>
|
|
65
|
-
<input
|
|
66
|
-
type="text"
|
|
67
|
-
value={username}
|
|
68
|
-
onChange={(e) => setUsername(e.target.value)}
|
|
69
|
-
placeholder="Enter your username"
|
|
70
|
-
autoComplete="username"
|
|
71
|
-
className="w-full h-11 px-4 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm text-[#111827] placeholder:text-[#D1D5DB] focus:outline-none focus:border-purple-400 focus:ring-2 focus:ring-purple-100 transition-all"
|
|
72
|
-
autoFocus
|
|
73
|
-
/>
|
|
74
|
-
</div>
|
|
75
|
-
|
|
76
|
-
<div>
|
|
77
|
-
<label className="text-[#6B7280] text-sm font-medium block mb-1.5">Password</label>
|
|
78
|
-
<div className="relative">
|
|
79
|
-
<input
|
|
80
|
-
type={showPassword ? 'text' : 'password'}
|
|
81
|
-
value={password}
|
|
82
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
83
|
-
placeholder="••••••••"
|
|
84
|
-
autoComplete="current-password"
|
|
85
|
-
className="w-full h-11 px-4 pr-10 bg-[#F9FAFB] border border-gray-200 rounded-xl text-sm text-[#111827] placeholder:text-[#D1D5DB] focus:outline-none focus:border-purple-400 focus:ring-2 focus:ring-purple-100 transition-all"
|
|
86
|
-
/>
|
|
87
|
-
<button
|
|
88
|
-
type="button"
|
|
89
|
-
onClick={() => setShowPassword(!showPassword)}
|
|
90
|
-
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#9CA3AF] hover:text-[#6B7280] transition-colors"
|
|
91
|
-
>
|
|
92
|
-
{showPassword ? (
|
|
93
|
-
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
|
94
|
-
) : (
|
|
95
|
-
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
96
|
-
)}
|
|
97
|
-
</button>
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
|
|
101
|
-
{error && (
|
|
102
|
-
<div className="p-3 rounded-xl bg-red-50 border border-red-200 text-red-600 text-sm font-medium flex items-center gap-2">
|
|
103
|
-
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
104
|
-
{error}
|
|
105
|
-
</div>
|
|
106
|
-
)}
|
|
107
|
-
|
|
108
|
-
<button
|
|
109
|
-
type="submit"
|
|
110
|
-
disabled={loading || !username.trim() || !password}
|
|
111
|
-
className="w-full h-11 bg-[#5244F3] hover:bg-[#4338CA] disabled:bg-gray-300 text-white font-semibold rounded-xl text-sm transition-colors shadow-sm flex items-center justify-center gap-2"
|
|
112
|
-
>
|
|
113
|
-
{loading ? (
|
|
114
|
-
<>
|
|
115
|
-
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
|
|
116
|
-
Signing in...
|
|
117
|
-
</>
|
|
118
|
-
) : 'Sign In'}
|
|
119
|
-
</button>
|
|
120
|
-
</form>
|
|
121
|
-
</div>
|
|
122
|
-
|
|
123
|
-
<p className="text-center text-[#D1D5DB] text-xs mt-6">
|
|
124
|
-
<Link href="/" className="hover:text-[#9CA3AF] transition-colors">← Back to ClaudMax</Link>
|
|
125
|
-
</p>
|
|
126
|
-
</div>
|
|
127
|
-
</div>
|
|
128
|
-
|
|
129
|
-
<Footer />
|
|
130
|
-
</div>
|
|
131
|
-
);
|
|
132
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
2
|
-
import { getAuthUser } from '@/lib/auth';
|
|
3
|
-
import { prisma } from '@/lib/prisma';
|
|
4
|
-
|
|
5
|
-
export async function GET() {
|
|
6
|
-
const user = await getAuthUser();
|
|
7
|
-
if (!user) {
|
|
8
|
-
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
9
|
-
}
|
|
10
|
-
// Fetch fresh from DB to get latest permissions
|
|
11
|
-
const admin = await prisma.admin.findUnique({
|
|
12
|
-
where: { id: user.id },
|
|
13
|
-
select: {
|
|
14
|
-
id: true, username: true, name: true, role: true, isActive: true,
|
|
15
|
-
canCreateKey: true, canDeleteKey: true, canBlockKey: true,
|
|
16
|
-
canManageTokens: true, canCreateReseller: true, canManageResellers: true, canManageUsers: true,
|
|
17
|
-
keysGenLimit: true, keysGenToday: true, keysGenResetAt: true,
|
|
18
|
-
},
|
|
19
|
-
});
|
|
20
|
-
if (!admin) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
21
|
-
|
|
22
|
-
// Check and reset key-gen window if needed
|
|
23
|
-
if (admin.role === 'reseller' && admin.keysGenResetAt) {
|
|
24
|
-
if (Date.now() >= admin.keysGenResetAt.getTime()) {
|
|
25
|
-
await prisma.admin.update({
|
|
26
|
-
where: { id: user.id },
|
|
27
|
-
data: { keysGenToday: 0, keysGenResetAt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
|
|
28
|
-
});
|
|
29
|
-
return NextResponse.json({ ...admin, keysGenToday: 0, keysGenResetAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() });
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return NextResponse.json(admin);
|
|
34
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
2
|
-
import { prisma } from '@/lib/prisma';
|
|
3
|
-
import { getAuthUser } from '@/lib/auth';
|
|
4
|
-
|
|
5
|
-
export async function GET() {
|
|
6
|
-
const me = await getAuthUser();
|
|
7
|
-
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
8
|
-
if (me.role !== 'super_admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
9
|
-
|
|
10
|
-
// Check OpenRouter API health
|
|
11
|
-
const apiStart = Date.now();
|
|
12
|
-
let apiStatus = 'ok';
|
|
13
|
-
let apiLatency = 0;
|
|
14
|
-
let apiError = '';
|
|
15
|
-
try {
|
|
16
|
-
const r = await fetch('https://openrouter.ai/api/v1/models', {
|
|
17
|
-
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}` },
|
|
18
|
-
});
|
|
19
|
-
apiLatency = Date.now() - apiStart;
|
|
20
|
-
if (!r.ok) {
|
|
21
|
-
apiStatus = 'error';
|
|
22
|
-
apiError = `HTTP ${r.status}`;
|
|
23
|
-
} else {
|
|
24
|
-
const data = await r.json();
|
|
25
|
-
const models: string[] = Array.isArray(data) ? data.map((m: any) => m.id) : (data.data?.map((m: any) => m.id) ?? []);
|
|
26
|
-
apiStatus = 'ok';
|
|
27
|
-
}
|
|
28
|
-
} catch (err: any) {
|
|
29
|
-
apiStatus = 'error';
|
|
30
|
-
apiLatency = Date.now() - apiStart;
|
|
31
|
-
apiError = err?.message ?? 'Connection failed';
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Stats
|
|
35
|
-
const [
|
|
36
|
-
totalKeys, activeKeys, totalTokens,
|
|
37
|
-
totalUsers, totalAdmins, totalResellers,
|
|
38
|
-
] = await Promise.all([
|
|
39
|
-
prisma.apiKey.count(),
|
|
40
|
-
prisma.apiKey.count({ where: { isActive: true } }),
|
|
41
|
-
prisma.apiKey.aggregate({ _sum: { totalTokensUsed: true } }),
|
|
42
|
-
prisma.admin.count(),
|
|
43
|
-
prisma.admin.count({ where: { role: { in: ['admin', 'super_admin'] } } }),
|
|
44
|
-
prisma.admin.count({ where: { role: 'reseller' } }),
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
// Top keys by usage
|
|
48
|
-
const topKeys = await prisma.apiKey.findMany({
|
|
49
|
-
orderBy: { totalTokensUsed: 'desc' },
|
|
50
|
-
take: 10,
|
|
51
|
-
select: {
|
|
52
|
-
id: true, name: true, prefix: true, tier: true, isActive: true,
|
|
53
|
-
windowTokensUsed: true, windowRequestsUsed: true, totalTokensUsed: true,
|
|
54
|
-
windowStartAt: true, lastUsedAt: true,
|
|
55
|
-
reseller: { select: { name: true } },
|
|
56
|
-
},
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// Keys near or over limit (window-based)
|
|
60
|
-
const WINDOW_MS = 5 * 60 * 60 * 1000;
|
|
61
|
-
const TIER_LIMITS: Record<string, number> = {
|
|
62
|
-
free: 500_000,
|
|
63
|
-
'5x': 5_000_000,
|
|
64
|
-
'20x': 20_000_000,
|
|
65
|
-
unlimited: 999_999_999_999,
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const overLimitKeys = topKeys.filter(k => {
|
|
69
|
-
const limit = TIER_LIMITS[k.tier] ?? 500_000;
|
|
70
|
-
return Number(k.windowTokensUsed) >= limit;
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
// Recent usage (last 5 minutes from now)
|
|
74
|
-
const recent = topKeys.filter(k => {
|
|
75
|
-
if (!k.windowStartAt) return false;
|
|
76
|
-
return Date.now() - k.windowStartAt.getTime() < 5 * 60 * 1000;
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
return NextResponse.json({
|
|
80
|
-
api: {
|
|
81
|
-
status: apiStatus,
|
|
82
|
-
latency: apiLatency,
|
|
83
|
-
error: apiError || undefined,
|
|
84
|
-
},
|
|
85
|
-
stats: {
|
|
86
|
-
totalKeys,
|
|
87
|
-
activeKeys,
|
|
88
|
-
totalTokens: totalTokens._sum.totalTokensUsed ?? 0,
|
|
89
|
-
totalUsers,
|
|
90
|
-
totalAdmins,
|
|
91
|
-
totalResellers,
|
|
92
|
-
},
|
|
93
|
-
topKeys: topKeys.map(k => ({
|
|
94
|
-
id: k.id,
|
|
95
|
-
name: k.name,
|
|
96
|
-
prefix: k.prefix,
|
|
97
|
-
tier: k.tier,
|
|
98
|
-
isActive: k.isActive,
|
|
99
|
-
windowTokensUsed: k.windowTokensUsed,
|
|
100
|
-
windowRequestsUsed: k.windowRequestsUsed,
|
|
101
|
-
totalTokensUsed: k.totalTokensUsed,
|
|
102
|
-
windowResetAt: k.windowStartAt ? new Date(k.windowStartAt.getTime() + WINDOW_MS).toISOString() : null,
|
|
103
|
-
lastUsedAt: k.lastUsedAt?.toISOString() ?? null,
|
|
104
|
-
resellerName: k.reseller?.name ?? null,
|
|
105
|
-
isOverLimit: Number(k.windowTokensUsed) >= (TIER_LIMITS[k.tier] ?? 500_000),
|
|
106
|
-
})),
|
|
107
|
-
overLimitCount: overLimitKeys.length,
|
|
108
|
-
activeCount: recent.length,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
2
|
-
import { prisma } from '@/lib/prisma';
|
|
3
|
-
import { getAuthUser } from '@/lib/auth';
|
|
4
|
-
|
|
5
|
-
type Params = { params: Promise<{ id: string }> };
|
|
6
|
-
|
|
7
|
-
async function canAccessKey(me: any, keyId: string): Promise<boolean> {
|
|
8
|
-
const key = await prisma.apiKey.findUnique({ where: { id: keyId }, select: { resellerId: true } });
|
|
9
|
-
if (!key) return false;
|
|
10
|
-
if (me.role === 'reseller') return key.resellerId === me.id;
|
|
11
|
-
return true;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function GET(req: Request, { params }: Params) {
|
|
15
|
-
const me = await getAuthUser();
|
|
16
|
-
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
17
|
-
const { id } = await params;
|
|
18
|
-
|
|
19
|
-
if (!await canAccessKey(me, id)) {
|
|
20
|
-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const key = await prisma.apiKey.findUnique({ where: { id } });
|
|
24
|
-
if (!key) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
25
|
-
return NextResponse.json({
|
|
26
|
-
...key,
|
|
27
|
-
windowTokensUsed: key.windowTokensUsed,
|
|
28
|
-
totalTokensUsed: key.totalTokensUsed,
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function PATCH(req: Request, { params }: Params) {
|
|
33
|
-
const me = await getAuthUser();
|
|
34
|
-
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
35
|
-
const { id } = await params;
|
|
36
|
-
const body = await req.json();
|
|
37
|
-
const { action, tier, name, displayMultiplier, tokenLimitOverride, expiresAt, duration, days } = body;
|
|
38
|
-
|
|
39
|
-
if (!await canAccessKey(me, id)) {
|
|
40
|
-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (action === 'block') {
|
|
44
|
-
const key = await prisma.apiKey.update({ where: { id }, data: { isActive: false, blockedUntil: null } });
|
|
45
|
-
return NextResponse.json({ ...key, windowTokensUsed: Number(key.windowTokensUsed), totalTokensUsed: Number(key.totalTokensUsed) });
|
|
46
|
-
}
|
|
47
|
-
if (action === 'unblock') {
|
|
48
|
-
const key = await prisma.apiKey.update({ where: { id }, data: { isActive: true, blockedUntil: null } });
|
|
49
|
-
return NextResponse.json({ ...key, windowTokensUsed: Number(key.windowTokensUsed), totalTokensUsed: Number(key.totalTokensUsed) });
|
|
50
|
-
}
|
|
51
|
-
if (action === 'tempBlock') {
|
|
52
|
-
if (!duration || typeof duration !== 'number' || duration <= 0) {
|
|
53
|
-
return NextResponse.json({ error: 'Invalid duration' }, { status: 400 });
|
|
54
|
-
}
|
|
55
|
-
const blockedUntil = new Date(Date.now() + duration);
|
|
56
|
-
const key = await prisma.apiKey.update({ where: { id }, data: { blockedUntil } });
|
|
57
|
-
return NextResponse.json({ ...key, windowTokensUsed: Number(key.windowTokensUsed), totalTokensUsed: Number(key.totalTokensUsed), blockedUntil: blockedUntil.toISOString() });
|
|
58
|
-
}
|
|
59
|
-
if (action === 'extendDays') {
|
|
60
|
-
// Resellers can extend/reduce their own key plan by days
|
|
61
|
-
if (typeof days !== 'number') {
|
|
62
|
-
return NextResponse.json({ error: 'days (number) required' }, { status: 400 });
|
|
63
|
-
}
|
|
64
|
-
const key = await prisma.apiKey.findUnique({ where: { id } });
|
|
65
|
-
if (!key) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
|
66
|
-
if (me.role === 'reseller' && key.resellerId !== me.id) {
|
|
67
|
-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
68
|
-
}
|
|
69
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
70
|
-
const currentStart = key.windowStartAt ? key.windowStartAt.getTime() : Date.now();
|
|
71
|
-
const newStart = currentStart + (days * DAY_MS);
|
|
72
|
-
await prisma.apiKey.update({
|
|
73
|
-
where: { id },
|
|
74
|
-
data: { windowStartAt: new Date(newStart) },
|
|
75
|
-
});
|
|
76
|
-
return NextResponse.json({ success: true, windowStartAt: new Date(newStart).toISOString() });
|
|
77
|
-
}
|
|
78
|
-
if (action === 'update') {
|
|
79
|
-
// Only super_admin and admin can update token limit, expiresAt, and tier
|
|
80
|
-
const isAdmin = me.role === 'super_admin' || me.role === 'admin';
|
|
81
|
-
const data: any = {};
|
|
82
|
-
if (name !== undefined) data.name = name;
|
|
83
|
-
if (displayMultiplier !== undefined) data.displayMultiplier = displayMultiplier;
|
|
84
|
-
if (isAdmin) {
|
|
85
|
-
if (tier !== undefined) data.tier = tier;
|
|
86
|
-
if (tokenLimitOverride !== undefined) {
|
|
87
|
-
data.tokenLimitOverride = tokenLimitOverride === null ? null : Number(tokenLimitOverride);
|
|
88
|
-
}
|
|
89
|
-
if (expiresAt !== undefined) {
|
|
90
|
-
data.expiresAt = expiresAt === null ? null : new Date(expiresAt);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
const key = await prisma.apiKey.update({ where: { id }, data });
|
|
94
|
-
return NextResponse.json({
|
|
95
|
-
...key,
|
|
96
|
-
windowTokensUsed: Number(key.windowTokensUsed),
|
|
97
|
-
totalTokensUsed: Number(key.totalTokensUsed),
|
|
98
|
-
tokenLimitOverride: key.tokenLimitOverride,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export async function DELETE(req: Request, { params }: Params) {
|
|
106
|
-
const me = await getAuthUser();
|
|
107
|
-
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
108
|
-
const { id } = await params;
|
|
109
|
-
|
|
110
|
-
if (!await canAccessKey(me, id)) {
|
|
111
|
-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
await prisma.apiKey.delete({ where: { id } });
|
|
115
|
-
return NextResponse.json({ success: true });
|
|
116
|
-
}
|
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
2
|
-
import { prisma } from '@/lib/prisma';
|
|
3
|
-
import { getAuthUser } from '@/lib/auth';
|
|
4
|
-
import { generateApiKey } from '@/lib/utils';
|
|
5
|
-
|
|
6
|
-
const WINDOW_MS = 5 * 60 * 60 * 1000;
|
|
7
|
-
|
|
8
|
-
export async function GET(req: Request) {
|
|
9
|
-
const me = await getAuthUser();
|
|
10
|
-
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
11
|
-
|
|
12
|
-
const { searchParams } = new URL(req.url);
|
|
13
|
-
const apiKey = searchParams.get('apiKey');
|
|
14
|
-
if (!apiKey) return NextResponse.json({ error: 'API key required' }, { status: 400 });
|
|
15
|
-
|
|
16
|
-
const key = await prisma.apiKey.findUnique({ where: { key: apiKey } });
|
|
17
|
-
if (!key) return NextResponse.json({ error: 'Invalid API key' }, { status: 401 });
|
|
18
|
-
|
|
19
|
-
if (me.role === 'reseller' && key.resellerId !== me.id) {
|
|
20
|
-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Get plan details
|
|
24
|
-
let tokensPerWindow = 500_000;
|
|
25
|
-
let requestsPerWindow = 100;
|
|
26
|
-
let multiplier = key.displayMultiplier ?? 3.0;
|
|
27
|
-
|
|
28
|
-
if (key.planId) {
|
|
29
|
-
const plan = await prisma.plan.findUnique({ where: { id: key.planId } });
|
|
30
|
-
if (plan) {
|
|
31
|
-
tokensPerWindow = Number(plan.tokensPerWindow);
|
|
32
|
-
requestsPerWindow = plan.requestsPerWindow;
|
|
33
|
-
multiplier = plan.displayMultiplier;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const effectiveLimit = Math.floor(tokensPerWindow * multiplier);
|
|
38
|
-
const windowStart = key.windowStartAt?.getTime() ?? Date.now();
|
|
39
|
-
const windowResetAt = windowStart + WINDOW_MS;
|
|
40
|
-
|
|
41
|
-
return NextResponse.json({
|
|
42
|
-
key: key.id,
|
|
43
|
-
name: key.name,
|
|
44
|
-
prefix: key.prefix,
|
|
45
|
-
tier: key.tier,
|
|
46
|
-
isActive: key.isActive,
|
|
47
|
-
displayMultiplier: multiplier,
|
|
48
|
-
requestsUsed: key.windowRequestsUsed,
|
|
49
|
-
requestsLimit: requestsPerWindow,
|
|
50
|
-
tokensUsed: Math.floor(Number(key.windowTokensUsed) * multiplier),
|
|
51
|
-
tokensLimit: effectiveLimit,
|
|
52
|
-
tokensUsedActual: Number(key.windowTokensUsed),
|
|
53
|
-
windowResetAt: new Date(windowResetAt).toISOString(),
|
|
54
|
-
expiresAt: key.expiresAt?.toISOString() ?? null,
|
|
55
|
-
planId: key.planId,
|
|
56
|
-
createdAt: key.createdAt.toISOString(),
|
|
57
|
-
lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export async function POST(req: Request) {
|
|
62
|
-
const me = await getAuthUser();
|
|
63
|
-
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
64
|
-
|
|
65
|
-
const { name, planId, resellerId, days, tokenLimitOverride } = await req.json();
|
|
66
|
-
|
|
67
|
-
// Fetch the plan
|
|
68
|
-
const plan = planId
|
|
69
|
-
? await prisma.plan.findUnique({ where: { id: planId } })
|
|
70
|
-
: null;
|
|
71
|
-
|
|
72
|
-
// If planId not provided, use default plan for this user
|
|
73
|
-
let effectivePlanId = planId;
|
|
74
|
-
if (!effectivePlanId) {
|
|
75
|
-
if (me.role === 'reseller') {
|
|
76
|
-
const admin = await prisma.admin.findUnique({ where: { id: me.id } });
|
|
77
|
-
effectivePlanId = admin?.defaultPlanId ?? null;
|
|
78
|
-
if (!effectivePlanId) {
|
|
79
|
-
// Fall back to first allowed plan
|
|
80
|
-
const allowed = admin?.allowedPlanIds ? JSON.parse(admin.allowedPlanIds) : [];
|
|
81
|
-
effectivePlanId = allowed[0] ?? null;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Permission check: can this user create keys with this plan?
|
|
87
|
-
if (me.role === 'reseller' && effectivePlanId) {
|
|
88
|
-
const admin = await prisma.admin.findUnique({ where: { id: me.id } });
|
|
89
|
-
const allowed: string[] = admin?.allowedPlanIds ? JSON.parse(admin.allowedPlanIds) : [];
|
|
90
|
-
if (!allowed.includes(effectivePlanId)) {
|
|
91
|
-
return NextResponse.json({ error: 'Plan not allowed for your account' }, { status: 403 });
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Reseller assignment
|
|
96
|
-
if (resellerId) {
|
|
97
|
-
if (me.role === 'reseller') {
|
|
98
|
-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
99
|
-
}
|
|
100
|
-
if (me.role === 'admin') {
|
|
101
|
-
const reseller = await prisma.admin.findUnique({ where: { id: resellerId } });
|
|
102
|
-
if (!reseller || reseller.role !== 'reseller') {
|
|
103
|
-
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Key generation limit check for resellers
|
|
109
|
-
if (me.role === 'reseller') {
|
|
110
|
-
const admin = await prisma.admin.findUnique({ where: { id: me.id } });
|
|
111
|
-
if (admin) {
|
|
112
|
-
const now = Date.now();
|
|
113
|
-
const resetAt = admin.keysGenResetAt ? admin.keysGenResetAt.getTime() : 0;
|
|
114
|
-
if (resetAt === 0 || now >= resetAt) {
|
|
115
|
-
await prisma.admin.update({
|
|
116
|
-
where: { id: me.id },
|
|
117
|
-
data: { keysGenToday: 0, keysGenResetAt: new Date(now + 24 * 60 * 60 * 1000) },
|
|
118
|
-
});
|
|
119
|
-
} else if (admin.keysGenToday >= admin.keysGenLimit) {
|
|
120
|
-
const resetAtTime = admin.keysGenResetAt ? new Date(admin.keysGenResetAt).toLocaleTimeString() : 'midnight';
|
|
121
|
-
return NextResponse.json({
|
|
122
|
-
error: `Daily key limit reached (${admin.keysGenLimit}/${admin.keysGenLimit}). Resets at ${resetAtTime}.`
|
|
123
|
-
}, { status: 429 });
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const { key: rawKey, prefix } = generateApiKey();
|
|
129
|
-
const effectiveTier = plan?.tier ?? 'free';
|
|
130
|
-
const multiplier = plan?.displayMultiplier ?? 3.0;
|
|
131
|
-
const effectiveLimit = Math.floor(Number(plan?.tokensPerWindow ?? 500_000) * multiplier);
|
|
132
|
-
const now = Date.now();
|
|
133
|
-
|
|
134
|
-
// Calculate expiration
|
|
135
|
-
let expiresAt: Date | null = null;
|
|
136
|
-
if (plan && plan.durationDays > 0) {
|
|
137
|
-
let durationDays = days !== undefined ? days : plan.durationDays;
|
|
138
|
-
// Superadmin/admin can override duration up to 365 days
|
|
139
|
-
const maxAllowed = me.role === 'super_admin' || me.role === 'admin' ? 365 : (plan.maxDurationDays === -1 ? durationDays : plan.maxDurationDays);
|
|
140
|
-
durationDays = Math.min(durationDays, maxAllowed);
|
|
141
|
-
const minDays = plan.minDurationDays;
|
|
142
|
-
durationDays = Math.max(minDays, durationDays);
|
|
143
|
-
expiresAt = new Date(now + durationDays * 24 * 60 * 60 * 1000);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Superadmin/admin can set custom token limit override
|
|
147
|
-
const effectiveTokenLimit = tokenLimitOverride && (me.role === 'super_admin' || me.role === 'admin')
|
|
148
|
-
? Number(tokenLimitOverride)
|
|
149
|
-
: undefined;
|
|
150
|
-
|
|
151
|
-
const apiKey = await prisma.apiKey.create({
|
|
152
|
-
data: {
|
|
153
|
-
key: rawKey,
|
|
154
|
-
prefix,
|
|
155
|
-
name: name ?? 'ClaudMax Key',
|
|
156
|
-
tier: effectiveTier,
|
|
157
|
-
resellerId: me.role === 'reseller' ? me.id : (resellerId ?? null),
|
|
158
|
-
displayMultiplier: multiplier,
|
|
159
|
-
windowStartAt: new Date(now),
|
|
160
|
-
planId: effectivePlanId ?? null,
|
|
161
|
-
expiresAt,
|
|
162
|
-
tokenLimitOverride: effectiveTokenLimit ?? null,
|
|
163
|
-
},
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
// Increment reseller's key count
|
|
167
|
-
if (me.role === 'reseller') {
|
|
168
|
-
await prisma.admin.update({
|
|
169
|
-
where: { id: me.id },
|
|
170
|
-
data: { keysGenToday: { increment: 1 } },
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return NextResponse.json({
|
|
175
|
-
key: apiKey.key,
|
|
176
|
-
id: apiKey.id,
|
|
177
|
-
name: apiKey.name,
|
|
178
|
-
prefix: apiKey.prefix,
|
|
179
|
-
tier: apiKey.tier,
|
|
180
|
-
isActive: apiKey.isActive,
|
|
181
|
-
displayMultiplier: apiKey.displayMultiplier,
|
|
182
|
-
planId: apiKey.planId,
|
|
183
|
-
expiresAt: expiresAt?.toISOString() ?? null,
|
|
184
|
-
requestsUsed: 0,
|
|
185
|
-
requestsLimit: plan?.requestsPerWindow ?? 100,
|
|
186
|
-
tokensUsed: 0,
|
|
187
|
-
tokensLimit: effectiveLimit,
|
|
188
|
-
windowResetAt: new Date(now + WINDOW_MS).toISOString(),
|
|
189
|
-
createdAt: apiKey.createdAt.toISOString(),
|
|
190
|
-
lastUsedAt: null,
|
|
191
|
-
}, { status: 201 });
|
|
192
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
2
|
-
import { prisma } from '@/lib/prisma';
|
|
3
|
-
import { getAuthUser } from '@/lib/auth';
|
|
4
|
-
|
|
5
|
-
export async function GET(req: Request) {
|
|
6
|
-
const me = await getAuthUser();
|
|
7
|
-
if (!me) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
8
|
-
|
|
9
|
-
const { searchParams } = new URL(req.url);
|
|
10
|
-
const page = parseInt(searchParams.get('page') ?? '1');
|
|
11
|
-
const limit = parseInt(searchParams.get('limit') ?? '15');
|
|
12
|
-
const search = searchParams.get('search') ?? '';
|
|
13
|
-
const tier = searchParams.get('tier') ?? '';
|
|
14
|
-
const status = searchParams.get('status') ?? '';
|
|
15
|
-
const skip = (page - 1) * limit;
|
|
16
|
-
|
|
17
|
-
// Role-based key filter
|
|
18
|
-
const where: any = {};
|
|
19
|
-
if (me.role === 'reseller') {
|
|
20
|
-
where.resellerId = me.id;
|
|
21
|
-
} else if (me.role === 'admin') {
|
|
22
|
-
where.resellerId = { not: null };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (search) {
|
|
26
|
-
where.OR = [
|
|
27
|
-
{ name: { contains: search } },
|
|
28
|
-
{ key: { contains: search } },
|
|
29
|
-
{ prefix: { contains: search } },
|
|
30
|
-
];
|
|
31
|
-
}
|
|
32
|
-
if (tier) where.tier = tier;
|
|
33
|
-
if (status === 'active') where.isActive = true;
|
|
34
|
-
if (status === 'inactive') where.isActive = false;
|
|
35
|
-
|
|
36
|
-
const [keys, total] = await Promise.all([
|
|
37
|
-
prisma.apiKey.findMany({
|
|
38
|
-
where,
|
|
39
|
-
orderBy: { createdAt: 'desc' },
|
|
40
|
-
skip,
|
|
41
|
-
take: limit,
|
|
42
|
-
select: {
|
|
43
|
-
id: true, key: true, name: true, prefix: true, tier: true,
|
|
44
|
-
isActive: true, displayMultiplier: true, blockedUntil: true,
|
|
45
|
-
windowTokensUsed: true, windowRequestsUsed: true, totalTokensUsed: true,
|
|
46
|
-
windowStartAt: true, lastUsedAt: true, resellerId: true, createdAt: true,
|
|
47
|
-
tokenLimitOverride: true, planId: true, expiresAt: true,
|
|
48
|
-
plan: { select: { id: true, name: true, tier: true, durationDays: true } },
|
|
49
|
-
},
|
|
50
|
-
}),
|
|
51
|
-
prisma.apiKey.count({ where }),
|
|
52
|
-
]);
|
|
53
|
-
|
|
54
|
-
// If reseller, show their own keys with their name as reseller
|
|
55
|
-
const resellerIds = [...new Set(keys.map(k => k.resellerId).filter(Boolean))] as string[];
|
|
56
|
-
const resellerMap: Record<string, { id: string; name: string; username: string }> = {};
|
|
57
|
-
if (resellerIds.length > 0) {
|
|
58
|
-
const resellers = await prisma.admin.findMany({
|
|
59
|
-
where: { id: { in: resellerIds } },
|
|
60
|
-
select: { id: true, name: true, username: true },
|
|
61
|
-
});
|
|
62
|
-
for (const r of resellers) resellerMap[r.id] = r;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return NextResponse.json({
|
|
66
|
-
keys: keys.map(k => ({
|
|
67
|
-
...k,
|
|
68
|
-
windowTokensUsed: k.windowTokensUsed,
|
|
69
|
-
totalTokensUsed: k.totalTokensUsed,
|
|
70
|
-
displayMultiplier: k.displayMultiplier ?? 3.0,
|
|
71
|
-
tokenLimitOverride: k.tokenLimitOverride,
|
|
72
|
-
blockedUntil: k.blockedUntil?.toISOString() ?? null,
|
|
73
|
-
reseller: resellerMap[k.resellerId ?? ''] ?? null,
|
|
74
|
-
expiresAt: k.expiresAt?.toISOString() ?? null,
|
|
75
|
-
plan: k.plan ?? null,
|
|
76
|
-
})),
|
|
77
|
-
total,
|
|
78
|
-
pages: Math.ceil(total / limit),
|
|
79
|
-
page,
|
|
80
|
-
});
|
|
81
|
-
}
|