@nordsym/apiclaw 1.3.3 → 1.3.4

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.
@@ -0,0 +1,108 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, Suspense } from "react";
4
+ import { useRouter, useSearchParams } from "next/navigation";
5
+ import { Loader2, AlertCircle, Check } from "lucide-react";
6
+ import Link from "next/link";
7
+
8
+ function VerifyContent() {
9
+ const router = useRouter();
10
+ const searchParams = useSearchParams();
11
+ const token = searchParams.get("token");
12
+
13
+ const [status, setStatus] = useState<"verifying" | "success" | "error">("verifying");
14
+ const [error, setError] = useState<string | null>(null);
15
+
16
+ useEffect(() => {
17
+ if (!token) {
18
+ setStatus("error");
19
+ setError("No verification token provided");
20
+ return;
21
+ }
22
+
23
+ const verify = async () => {
24
+ try {
25
+ const response = await fetch("/api/workspace-auth/verify", {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({ token }),
29
+ });
30
+
31
+ const data = await response.json();
32
+
33
+ if (!response.ok || !data.success) {
34
+ throw new Error(data.error || "Verification failed");
35
+ }
36
+
37
+ // Store session token in localStorage as backup
38
+ if (data.sessionToken) {
39
+ localStorage.setItem("apiclaw_workspace_session", data.sessionToken);
40
+ }
41
+
42
+ setStatus("success");
43
+
44
+ // Redirect to dashboard after short delay
45
+ setTimeout(() => {
46
+ router.push("/dashboard");
47
+ }, 1500);
48
+ } catch (err) {
49
+ setStatus("error");
50
+ setError(err instanceof Error ? err.message : "Verification failed");
51
+ }
52
+ };
53
+
54
+ verify();
55
+ }, [token, router]);
56
+
57
+ return (
58
+ <main className="min-h-screen flex items-center justify-center px-6 bg-[var(--background)]">
59
+ <div className="max-w-md w-full text-center">
60
+ {status === "verifying" && (
61
+ <>
62
+ <Loader2 className="w-16 h-16 text-accent animate-spin mx-auto mb-6" />
63
+ <h1 className="text-2xl font-bold mb-2">Verifying...</h1>
64
+ <p className="text-[var(--text-muted)]">Please wait while we sign you in.</p>
65
+ </>
66
+ )}
67
+
68
+ {status === "success" && (
69
+ <>
70
+ <div className="w-20 h-20 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-6">
71
+ <Check className="w-10 h-10 text-green-500" />
72
+ </div>
73
+ <h1 className="text-2xl font-bold mb-2">Success!</h1>
74
+ <p className="text-[var(--text-muted)]">Redirecting to your dashboard...</p>
75
+ </>
76
+ )}
77
+
78
+ {status === "error" && (
79
+ <>
80
+ <div className="w-20 h-20 rounded-full bg-red-500/20 flex items-center justify-center mx-auto mb-6">
81
+ <AlertCircle className="w-10 h-10 text-red-500" />
82
+ </div>
83
+ <h1 className="text-2xl font-bold mb-2">Verification Failed</h1>
84
+ <p className="text-red-500 mb-6">{error}</p>
85
+ <Link href="/login" className="btn-primary">
86
+ Try Again
87
+ </Link>
88
+ </>
89
+ )}
90
+ </div>
91
+ </main>
92
+ );
93
+ }
94
+
95
+ export default function VerifyPage() {
96
+ return (
97
+ <Suspense fallback={
98
+ <main className="min-h-screen flex items-center justify-center px-6 bg-[var(--background)]">
99
+ <div className="text-center">
100
+ <Loader2 className="w-16 h-16 text-accent animate-spin mx-auto mb-6" />
101
+ <p className="text-[var(--text-muted)]">Loading...</p>
102
+ </div>
103
+ </main>
104
+ }>
105
+ <VerifyContent />
106
+ </Suspense>
107
+ );
108
+ }
@@ -0,0 +1,204 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import { Mail, Loader2, Check, ArrowRight, Sun, Moon, Zap } from "lucide-react";
5
+ import Link from "next/link";
6
+ import { useRouter } from "next/navigation";
7
+
8
+ export default function LoginPage() {
9
+ const router = useRouter();
10
+ const [isDark, setIsDark] = useState(true);
11
+ const [email, setEmail] = useState("");
12
+ const [isLoading, setIsLoading] = useState(false);
13
+ const [isSent, setIsSent] = useState(false);
14
+ const [error, setError] = useState<string | null>(null);
15
+
16
+ useEffect(() => {
17
+ // Check if already logged in
18
+ const checkSession = async () => {
19
+ try {
20
+ const res = await fetch("/api/workspace-auth/session");
21
+ const data = await res.json();
22
+ if (data.session) {
23
+ router.push("/dashboard");
24
+ return;
25
+ }
26
+ } catch {
27
+ // Not logged in, continue
28
+ }
29
+ };
30
+ checkSession();
31
+
32
+ const saved = localStorage.getItem("theme");
33
+ const prefersDark = saved ? saved === "dark" : true;
34
+ setIsDark(prefersDark);
35
+ document.documentElement.classList.toggle("dark", prefersDark);
36
+ }, [router]);
37
+
38
+ const toggleTheme = () => {
39
+ const newTheme = !isDark;
40
+ setIsDark(newTheme);
41
+ document.documentElement.classList.toggle("dark", newTheme);
42
+ localStorage.setItem("theme", newTheme ? "dark" : "light");
43
+ };
44
+
45
+ const handleSubmit = async (e: React.FormEvent) => {
46
+ e.preventDefault();
47
+ setIsLoading(true);
48
+ setError(null);
49
+
50
+ try {
51
+ const response = await fetch("/api/workspace-auth/magic-link", {
52
+ method: "POST",
53
+ headers: { "Content-Type": "application/json" },
54
+ body: JSON.stringify({ email }),
55
+ });
56
+
57
+ if (!response.ok) {
58
+ const data = await response.json();
59
+ throw new Error(data.error || "Failed to send magic link");
60
+ }
61
+
62
+ setIsSent(true);
63
+ } catch (err) {
64
+ setError(err instanceof Error ? err.message : "Something went wrong");
65
+ } finally {
66
+ setIsLoading(false);
67
+ }
68
+ };
69
+
70
+ if (isSent) {
71
+ return (
72
+ <main className="min-h-screen flex items-center justify-center px-6 bg-[var(--background)]">
73
+ <div className="max-w-md w-full text-center">
74
+ <div className="w-20 h-20 rounded-full bg-accent/20 flex items-center justify-center mx-auto mb-8">
75
+ <Check className="w-10 h-10 text-accent" />
76
+ </div>
77
+ <h1 className="text-3xl font-bold mb-4">Check Your Email</h1>
78
+ <p className="text-[var(--text-secondary)] mb-2">
79
+ We&apos;ve sent a magic link to:
80
+ </p>
81
+ <p className="font-semibold text-lg mb-8">{email}</p>
82
+ <p className="text-sm text-[var(--text-muted)]">
83
+ Click the link in the email to sign in. The link expires in 15 minutes.
84
+ </p>
85
+ <button
86
+ onClick={() => setIsSent(false)}
87
+ className="mt-8 text-accent hover:underline"
88
+ >
89
+ Use a different email
90
+ </button>
91
+ </div>
92
+ </main>
93
+ );
94
+ }
95
+
96
+ return (
97
+ <main className="min-h-screen bg-[var(--background)]">
98
+ {/* Header */}
99
+ <header className="fixed top-0 w-full z-50 bg-[var(--background)]/90 backdrop-blur-xl border-b border-[var(--border-subtle)]">
100
+ <div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
101
+ <Link href="/" className="flex items-center gap-3">
102
+ <div className="w-9 h-9 rounded-xl bg-accent/20 flex items-center justify-center text-xl">
103
+ 🦞
104
+ </div>
105
+ <span className="font-bold text-lg tracking-tight">APIClaw</span>
106
+ </Link>
107
+ <button
108
+ onClick={toggleTheme}
109
+ className="p-2 rounded-lg hover:bg-[var(--surface)] transition"
110
+ aria-label="Toggle theme"
111
+ >
112
+ {isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
113
+ </button>
114
+ </div>
115
+ </header>
116
+
117
+ <div className="pt-32 pb-24 px-6">
118
+ <div className="max-w-md mx-auto">
119
+ <div className="text-center mb-8">
120
+ <div className="w-16 h-16 rounded-2xl bg-accent/20 flex items-center justify-center mx-auto mb-6">
121
+ <Zap className="w-8 h-8 text-accent" />
122
+ </div>
123
+ <h1 className="text-3xl font-bold mb-2">Agent Workspace</h1>
124
+ <p className="text-[var(--text-secondary)]">
125
+ Sign in to manage your AI agents and view usage
126
+ </p>
127
+ </div>
128
+
129
+ <div className="rounded-2xl bg-[var(--surface-elevated)] border border-[var(--border)] p-8">
130
+ <form onSubmit={handleSubmit} className="space-y-6">
131
+ <div>
132
+ <label className="block text-sm font-medium mb-2">Email</label>
133
+ <div className="relative">
134
+ <Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-muted)]" />
135
+ <input
136
+ type="email"
137
+ value={email}
138
+ onChange={(e) => {
139
+ setEmail(e.target.value);
140
+ setError(null);
141
+ }}
142
+ placeholder="you@company.com"
143
+ required
144
+ className="w-full pl-12 pr-4 py-3 rounded-xl bg-[var(--surface)] border border-[var(--border)] focus:border-accent focus:outline-none transition"
145
+ />
146
+ </div>
147
+ </div>
148
+
149
+ {error && (
150
+ <div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-sm">
151
+ {error}
152
+ </div>
153
+ )}
154
+
155
+ <button
156
+ type="submit"
157
+ disabled={isLoading || !email}
158
+ className="btn-primary w-full justify-center disabled:opacity-50"
159
+ >
160
+ {isLoading ? (
161
+ <>
162
+ <Loader2 className="w-5 h-5 animate-spin" />
163
+ Sending...
164
+ </>
165
+ ) : (
166
+ <>
167
+ Send Magic Link
168
+ <ArrowRight className="w-5 h-5" />
169
+ </>
170
+ )}
171
+ </button>
172
+ </form>
173
+
174
+ <div className="mt-6 pt-6 border-t border-[var(--border)]">
175
+ <div className="space-y-3 text-sm text-[var(--text-muted)]">
176
+ <div className="flex items-center gap-2">
177
+ <Check className="w-4 h-4 text-green-500" />
178
+ <span>1,000 free API calls per month</span>
179
+ </div>
180
+ <div className="flex items-center gap-2">
181
+ <Check className="w-4 h-4 text-green-500" />
182
+ <span>Connect unlimited AI agents</span>
183
+ </div>
184
+ <div className="flex items-center gap-2">
185
+ <Check className="w-4 h-4 text-green-500" />
186
+ <span>Usage analytics & monitoring</span>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <div className="mt-6 text-center">
193
+ <p className="text-sm text-[var(--text-muted)]">
194
+ Are you an API provider?{" "}
195
+ <Link href="/providers/dashboard/login" className="text-accent hover:underline">
196
+ Provider Dashboard
197
+ </Link>
198
+ </p>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </main>
203
+ );
204
+ }
@@ -0,0 +1,288 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useSearchParams, useRouter } from "next/navigation";
5
+ import { Suspense } from "react";
6
+
7
+ const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
8
+
9
+ interface BillingStatus {
10
+ tier: string;
11
+ status: string;
12
+ usageCount: number;
13
+ usageLimit: number;
14
+ usageRemaining: number;
15
+ usagePercent: number;
16
+ hasStripe: boolean;
17
+ email: string;
18
+ }
19
+
20
+ async function queryConvex<T>(path: string, args: Record<string, unknown>): Promise<T> {
21
+ const response = await fetch(`${CONVEX_URL}/api/query`, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({ path, args }),
25
+ });
26
+ const result = await response.json();
27
+ return result.value !== undefined ? result.value : result;
28
+ }
29
+
30
+ async function actionConvex<T>(path: string, args: Record<string, unknown>): Promise<T> {
31
+ const response = await fetch(`${CONVEX_URL}/api/action`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ path, args }),
35
+ });
36
+ const result = await response.json();
37
+ return result.value !== undefined ? result.value : result;
38
+ }
39
+
40
+ function UpgradeContent() {
41
+ const searchParams = useSearchParams();
42
+ const router = useRouter();
43
+ const workspaceId = searchParams.get("ws");
44
+ const success = searchParams.get("success");
45
+
46
+ const [billing, setBilling] = useState<BillingStatus | null>(null);
47
+ const [loading, setLoading] = useState(true);
48
+ const [upgrading, setUpgrading] = useState(false);
49
+ const [error, setError] = useState<string | null>(null);
50
+
51
+ useEffect(() => {
52
+ if (!workspaceId) {
53
+ setError("Missing workspace ID");
54
+ setLoading(false);
55
+ return;
56
+ }
57
+
58
+ async function loadBilling() {
59
+ try {
60
+ const status = await queryConvex<BillingStatus | null>("billing:getBillingStatus", {
61
+ workspaceId,
62
+ });
63
+ setBilling(status);
64
+ } catch (err) {
65
+ setError("Failed to load billing status");
66
+ } finally {
67
+ setLoading(false);
68
+ }
69
+ }
70
+
71
+ loadBilling();
72
+ }, [workspaceId]);
73
+
74
+ const handleUpgrade = async () => {
75
+ if (!workspaceId) return;
76
+
77
+ setUpgrading(true);
78
+ setError(null);
79
+
80
+ try {
81
+ const result = await actionConvex<{
82
+ success: boolean;
83
+ url?: string;
84
+ error?: string;
85
+ }>("billing:createCheckoutSession", {
86
+ workspaceId,
87
+ successUrl: `${window.location.origin}/upgrade?ws=${workspaceId}&success=true`,
88
+ cancelUrl: `${window.location.origin}/upgrade?ws=${workspaceId}`,
89
+ });
90
+
91
+ if (result.success && result.url) {
92
+ window.location.href = result.url;
93
+ } else {
94
+ setError(result.error || "Failed to create checkout session");
95
+ setUpgrading(false);
96
+ }
97
+ } catch (err) {
98
+ setError("Failed to start upgrade process");
99
+ setUpgrading(false);
100
+ }
101
+ };
102
+
103
+ if (loading) {
104
+ return (
105
+ <div className="min-h-screen bg-gray-950 flex items-center justify-center">
106
+ <div className="animate-pulse text-gray-400">Loading...</div>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ if (error && !billing) {
112
+ return (
113
+ <div className="min-h-screen bg-gray-950 flex items-center justify-center">
114
+ <div className="bg-red-900/20 border border-red-500/30 rounded-lg p-6 max-w-md text-center">
115
+ <div className="text-red-400 text-lg font-medium mb-2">Error</div>
116
+ <div className="text-gray-400">{error}</div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ const isPro = billing?.tier === "pro" || billing?.tier === "enterprise";
123
+
124
+ return (
125
+ <div className="min-h-screen bg-gray-950 py-12 px-4">
126
+ <div className="max-w-2xl mx-auto">
127
+ {/* Header */}
128
+ <div className="text-center mb-12">
129
+ <h1 className="text-3xl font-bold text-white mb-2">
130
+ APIClaw Workspace
131
+ </h1>
132
+ <p className="text-gray-400">
133
+ {billing?.email}
134
+ </p>
135
+ </div>
136
+
137
+ {/* Success Message */}
138
+ {success && (
139
+ <div className="bg-green-900/20 border border-green-500/30 rounded-lg p-4 mb-8 text-center">
140
+ <div className="text-green-400 font-medium">
141
+ 🎉 Upgrade successful! Welcome to APIClaw Pro.
142
+ </div>
143
+ </div>
144
+ )}
145
+
146
+ {/* Current Plan Card */}
147
+ <div className="bg-gray-900 border border-gray-800 rounded-xl p-6 mb-8">
148
+ <div className="flex items-center justify-between mb-6">
149
+ <div>
150
+ <div className="text-sm text-gray-400 mb-1">Current Plan</div>
151
+ <div className="text-2xl font-bold text-white capitalize">
152
+ {billing?.tier || "Free"}
153
+ {isPro && <span className="ml-2 text-sm text-emerald-400">✓ Active</span>}
154
+ </div>
155
+ </div>
156
+ <div className={`px-4 py-2 rounded-full text-sm font-medium ${
157
+ isPro ? "bg-emerald-500/20 text-emerald-400" : "bg-gray-800 text-gray-400"
158
+ }`}>
159
+ {isPro ? "Pro" : "Free Tier"}
160
+ </div>
161
+ </div>
162
+
163
+ {/* Usage Bar */}
164
+ <div className="mb-6">
165
+ <div className="flex justify-between text-sm mb-2">
166
+ <span className="text-gray-400">API Calls This Month</span>
167
+ <span className="text-white font-medium">
168
+ {billing?.usageCount.toLocaleString()} / {billing?.usageLimit === -1 ? "∞" : billing?.usageLimit.toLocaleString()}
169
+ </span>
170
+ </div>
171
+ <div className="h-3 bg-gray-800 rounded-full overflow-hidden">
172
+ <div
173
+ className={`h-full transition-all ${
174
+ (billing?.usagePercent || 0) > 80 ? "bg-red-500" :
175
+ (billing?.usagePercent || 0) > 50 ? "bg-yellow-500" : "bg-emerald-500"
176
+ }`}
177
+ style={{ width: `${Math.min(billing?.usagePercent || 0, 100)}%` }}
178
+ />
179
+ </div>
180
+ {!isPro && billing && billing.usageRemaining > 0 && billing.usageRemaining < 20 && (
181
+ <div className="text-sm text-yellow-400 mt-2">
182
+ ⚠️ Only {billing.usageRemaining} calls remaining
183
+ </div>
184
+ )}
185
+ </div>
186
+
187
+ {/* Plan Features */}
188
+ <div className="grid grid-cols-2 gap-4 text-sm">
189
+ <div className="flex items-center gap-2 text-gray-400">
190
+ <span className={isPro ? "text-emerald-400" : "text-gray-600"}>✓</span>
191
+ <span>{isPro ? "10,000" : "100"} API calls/month</span>
192
+ </div>
193
+ <div className="flex items-center gap-2 text-gray-400">
194
+ <span className={isPro ? "text-emerald-400" : "text-gray-600"}>✓</span>
195
+ <span>Priority support</span>
196
+ </div>
197
+ <div className="flex items-center gap-2 text-gray-400">
198
+ <span className={isPro ? "text-emerald-400" : "text-gray-600"}>✓</span>
199
+ <span>Advanced analytics</span>
200
+ </div>
201
+ <div className="flex items-center gap-2 text-gray-400">
202
+ <span className={isPro ? "text-emerald-400" : "text-gray-600"}>✓</span>
203
+ <span>All providers</span>
204
+ </div>
205
+ </div>
206
+ </div>
207
+
208
+ {/* Upgrade Card (only show for free tier) */}
209
+ {!isPro && (
210
+ <div className="bg-gradient-to-r from-red-600/20 to-orange-600/20 border border-red-500/30 rounded-xl p-6">
211
+ <div className="flex items-center justify-between mb-4">
212
+ <div>
213
+ <div className="text-xl font-bold text-white mb-1">
214
+ Upgrade to Pro
215
+ </div>
216
+ <div className="text-gray-400">
217
+ 100x more API calls, priority support
218
+ </div>
219
+ </div>
220
+ <div className="text-right">
221
+ <div className="text-3xl font-bold text-white">$99</div>
222
+ <div className="text-gray-400 text-sm">/month</div>
223
+ </div>
224
+ </div>
225
+
226
+ {error && (
227
+ <div className="text-red-400 text-sm mb-4 bg-red-900/20 p-3 rounded">
228
+ {error}
229
+ </div>
230
+ )}
231
+
232
+ <button
233
+ onClick={handleUpgrade}
234
+ disabled={upgrading}
235
+ className={`w-full py-3 px-6 rounded-lg font-medium transition-all ${
236
+ upgrading
237
+ ? "bg-gray-700 text-gray-400 cursor-not-allowed"
238
+ : "bg-red-600 hover:bg-red-500 text-white"
239
+ }`}
240
+ >
241
+ {upgrading ? (
242
+ <span className="flex items-center justify-center gap-2">
243
+ <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
244
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
245
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
246
+ </svg>
247
+ Processing...
248
+ </span>
249
+ ) : (
250
+ "Add Payment Method →"
251
+ )}
252
+ </button>
253
+
254
+ <div className="text-center text-gray-500 text-xs mt-4">
255
+ Secure payment via Stripe. Cancel anytime.
256
+ </div>
257
+ </div>
258
+ )}
259
+
260
+ {/* Already Pro Message */}
261
+ {isPro && !success && (
262
+ <div className="text-center text-gray-400">
263
+ You&apos;re on the Pro plan. Enjoy unlimited API calls!
264
+ </div>
265
+ )}
266
+
267
+ {/* Back Link */}
268
+ <div className="text-center mt-8">
269
+ <a href="/" className="text-gray-500 hover:text-gray-400 text-sm">
270
+ ← Back to APIClaw
271
+ </a>
272
+ </div>
273
+ </div>
274
+ </div>
275
+ );
276
+ }
277
+
278
+ export default function UpgradePage() {
279
+ return (
280
+ <Suspense fallback={
281
+ <div className="min-h-screen bg-gray-950 flex items-center justify-center">
282
+ <div className="animate-pulse text-gray-400">Loading...</div>
283
+ </div>
284
+ }>
285
+ <UpgradeContent />
286
+ </Suspense>
287
+ );
288
+ }
@@ -1,22 +1,21 @@
1
1
  {
2
2
  "apiCount": 22392,
3
- "categoryCount": 14,
4
- "lastUpdated": "2026-02-27T12:05:00.000Z",
5
- "generatedAt": "2026-02-27T12:05:00.000Z",
3
+ "categoryCount": 13,
4
+ "lastUpdated": "2026-02-27T09:10:41.344767",
5
+ "generatedAt": "2026-02-28T09:32:15.722Z",
6
6
  "categoryBreakdown": {
7
- "Utilities": 7069,
8
- "Analytics": 2600,
7
+ "Finance": 1179,
8
+ "Auth & Security": 491,
9
+ "Cloud & Infrastructure": 1463,
9
10
  "Development": 2278,
10
- "Cloud": 1463,
11
+ "Business": 923,
12
+ "Commerce": 1151,
13
+ "Utilities": 7069,
11
14
  "AI & ML": 1259,
15
+ "Location & Maps": 976,
16
+ "Social & Communication": 1051,
17
+ "Data & Analytics": 2600,
12
18
  "Entertainment": 1212,
13
- "Finance": 1179,
14
- "Commerce": 1151,
15
- "Location": 976,
16
- "Communication": 939,
17
- "Business": 923,
18
- "Health": 740,
19
- "Security": 491,
20
- "Social": 112
19
+ "Health & Fitness": 740
21
20
  }
22
- }
21
+ }
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { NextRequest } from "next/server";
3
+
4
+ const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
5
+
6
+ export async function middleware(request: NextRequest) {
7
+ const pathname = request.nextUrl.pathname;
8
+
9
+ // Only protect /dashboard routes (not /dashboard/verify)
10
+ if (pathname.startsWith("/dashboard") && !pathname.startsWith("/dashboard/verify")) {
11
+ const sessionToken = request.cookies.get("apiclaw_workspace_session")?.value;
12
+
13
+ if (!sessionToken) {
14
+ // Redirect to login if no session
15
+ return NextResponse.redirect(new URL("/login", request.url));
16
+ }
17
+
18
+ // Optionally verify session with Convex
19
+ // For performance, we do a lightweight check - full validation happens in the page
20
+ try {
21
+ const response = await fetch(`${CONVEX_URL}/api/query`, {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({
25
+ path: "workspaces:getSession",
26
+ args: { token: sessionToken },
27
+ }),
28
+ });
29
+
30
+ const result = await response.json();
31
+ const session = result.value || result;
32
+
33
+ if (!session) {
34
+ // Invalid session - clear cookie and redirect
35
+ const res = NextResponse.redirect(new URL("/login", request.url));
36
+ res.cookies.delete("apiclaw_workspace_session");
37
+ return res;
38
+ }
39
+ } catch {
40
+ // On error, let the page handle it
41
+ return NextResponse.next();
42
+ }
43
+ }
44
+
45
+ return NextResponse.next();
46
+ }
47
+
48
+ export const config = {
49
+ matcher: ["/dashboard/:path*"],
50
+ };