@nordsym/apiclaw 1.3.3 → 1.3.5
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/convex/_generated/api.d.ts +6 -0
- package/convex/billing.ts +341 -0
- package/convex/email.ts +276 -0
- package/convex/http.ts +154 -0
- package/convex/schema.ts +43 -0
- package/convex/workspaces.ts +663 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +272 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +396 -4
- package/dist/index.js.map +1 -1
- package/dist/session.d.ts +29 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +87 -0
- package/dist/session.js.map +1 -0
- package/docs/PRD-agent-first-billing.md +525 -0
- package/docs/PRD-workspace-fixes.md +178 -0
- package/landing/package-lock.json +21 -3
- package/landing/package.json +2 -1
- package/landing/src/app/api/stripe/webhook/route.ts +178 -0
- package/landing/src/app/api/workspace-auth/magic-link/route.ts +84 -0
- package/landing/src/app/api/workspace-auth/session/route.ts +73 -0
- package/landing/src/app/api/workspace-auth/verify/route.ts +57 -0
- package/landing/src/app/auth/verify/page.tsx +292 -0
- package/landing/src/app/dashboard/layout.tsx +22 -0
- package/landing/src/app/dashboard/page.tsx +22 -0
- package/landing/src/app/dashboard/verify/page.tsx +108 -0
- package/landing/src/app/login/page.tsx +204 -0
- package/landing/src/app/page.tsx +23 -7
- package/landing/src/app/providers/dashboard/layout.tsx +5 -4
- package/landing/src/app/providers/dashboard/page.tsx +11 -641
- package/landing/src/app/upgrade/page.tsx +288 -0
- package/landing/src/app/workspace/layout.tsx +30 -0
- package/landing/src/app/workspace/page.tsx +1637 -0
- package/landing/src/lib/stats.json +14 -15
- package/landing/src/middleware.ts +50 -0
- package/landing/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/cli.ts +320 -0
- package/src/index.ts +444 -4
- package/src/session.ts +103 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useSearchParams } from "next/navigation";
|
|
5
|
+
import { Suspense } from "react";
|
|
6
|
+
|
|
7
|
+
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://brilliant-puffin-712.eu-west-1.convex.cloud";
|
|
8
|
+
|
|
9
|
+
interface VerifyResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
workspace?: {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
status: string;
|
|
16
|
+
tier: string;
|
|
17
|
+
usageCount: number;
|
|
18
|
+
usageLimit: number;
|
|
19
|
+
};
|
|
20
|
+
sessionToken?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function VerifyContent() {
|
|
24
|
+
const searchParams = useSearchParams();
|
|
25
|
+
const token = searchParams.get("token");
|
|
26
|
+
|
|
27
|
+
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
|
|
28
|
+
const [error, setError] = useState<string>("");
|
|
29
|
+
const [workspace, setWorkspace] = useState<VerifyResult["workspace"] | null>(null);
|
|
30
|
+
const [sessionToken, setSessionToken] = useState<string>("");
|
|
31
|
+
|
|
32
|
+
// Password form state
|
|
33
|
+
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
|
34
|
+
const [password, setPassword] = useState("");
|
|
35
|
+
const [confirmPassword, setConfirmPassword] = useState("");
|
|
36
|
+
const [passwordError, setPasswordError] = useState("");
|
|
37
|
+
const [passwordSaved, setPasswordSaved] = useState(false);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!token) {
|
|
41
|
+
setStatus("error");
|
|
42
|
+
setError("No verification token provided");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
verifyToken(token);
|
|
47
|
+
}, [token]);
|
|
48
|
+
|
|
49
|
+
async function verifyToken(token: string) {
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(`${CONVEX_URL}/api/mutation`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/json" },
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
path: "workspaces:verifyMagicLink",
|
|
56
|
+
args: { token },
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error("Verification failed");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await response.json();
|
|
65
|
+
const data: VerifyResult = result.value || result;
|
|
66
|
+
|
|
67
|
+
if (data.success) {
|
|
68
|
+
setStatus("success");
|
|
69
|
+
setWorkspace(data.workspace || null);
|
|
70
|
+
setSessionToken(data.sessionToken || "");
|
|
71
|
+
} else {
|
|
72
|
+
setStatus("error");
|
|
73
|
+
switch (data.error) {
|
|
74
|
+
case "invalid_token":
|
|
75
|
+
setError("This verification link is invalid.");
|
|
76
|
+
break;
|
|
77
|
+
case "already_used":
|
|
78
|
+
setError("This verification link has already been used.");
|
|
79
|
+
break;
|
|
80
|
+
case "expired":
|
|
81
|
+
setError("This verification link has expired. Please request a new one.");
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
setError("Verification failed. Please try again.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
setStatus("error");
|
|
89
|
+
setError("Something went wrong. Please try again.");
|
|
90
|
+
console.error("Verification error:", err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function handleSetPassword(e: React.FormEvent) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
setPasswordError("");
|
|
97
|
+
|
|
98
|
+
if (password.length < 8) {
|
|
99
|
+
setPasswordError("Password must be at least 8 characters");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (password !== confirmPassword) {
|
|
104
|
+
setPasswordError("Passwords don't match");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetch(`${CONVEX_URL}/api/mutation`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "Content-Type": "application/json" },
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
path: "workspaces:setPassword",
|
|
114
|
+
args: { sessionToken, password },
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = await response.json();
|
|
119
|
+
const data = result.value || result;
|
|
120
|
+
|
|
121
|
+
if (data.success) {
|
|
122
|
+
setPasswordSaved(true);
|
|
123
|
+
setShowPasswordForm(false);
|
|
124
|
+
} else {
|
|
125
|
+
setPasswordError("Failed to set password. Please try again.");
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
setPasswordError("Something went wrong. Please try again.");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="min-h-screen bg-[var(--background)] flex items-center justify-center p-4">
|
|
134
|
+
<div className="w-full max-w-md">
|
|
135
|
+
{/* Logo */}
|
|
136
|
+
<div className="text-center mb-8">
|
|
137
|
+
<span className="text-6xl">🦞</span>
|
|
138
|
+
<h1 className="text-2xl font-bold mt-4">APIClaw</h1>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Loading state */}
|
|
142
|
+
{status === "loading" && (
|
|
143
|
+
<div className="bg-[var(--surface)] rounded-xl p-8 text-center border border-[var(--border)]">
|
|
144
|
+
<div className="animate-spin w-8 h-8 border-4 border-[var(--accent)] border-t-transparent rounded-full mx-auto mb-4" />
|
|
145
|
+
<p className="text-[var(--text-secondary)]">Verifying your email...</p>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{/* Success state */}
|
|
150
|
+
{status === "success" && (
|
|
151
|
+
<div className="bg-[var(--surface)] rounded-xl p-8 border border-[var(--border)]">
|
|
152
|
+
<div className="text-center mb-6">
|
|
153
|
+
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
154
|
+
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
155
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
156
|
+
</svg>
|
|
157
|
+
</div>
|
|
158
|
+
<h2 className="text-xl font-bold mb-2">Email Verified!</h2>
|
|
159
|
+
<p className="text-[var(--text-secondary)]">
|
|
160
|
+
Your workspace is now active. Your AI agent can start using APIClaw.
|
|
161
|
+
</p>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{workspace && (
|
|
165
|
+
<div className="bg-[var(--background)] rounded-lg p-4 mb-6">
|
|
166
|
+
<div className="space-y-2 text-sm">
|
|
167
|
+
<div className="flex justify-between">
|
|
168
|
+
<span className="text-[var(--text-muted)]">Email</span>
|
|
169
|
+
<span className="font-medium">{workspace.email}</span>
|
|
170
|
+
</div>
|
|
171
|
+
<div className="flex justify-between">
|
|
172
|
+
<span className="text-[var(--text-muted)]">Plan</span>
|
|
173
|
+
<span className="font-medium capitalize">{workspace.tier}</span>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="flex justify-between">
|
|
176
|
+
<span className="text-[var(--text-muted)]">API Calls</span>
|
|
177
|
+
<span className="font-medium">{workspace.usageCount} / {workspace.usageLimit}</span>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{/* Password form */}
|
|
184
|
+
{!passwordSaved && !showPasswordForm && (
|
|
185
|
+
<button
|
|
186
|
+
onClick={() => setShowPasswordForm(true)}
|
|
187
|
+
className="w-full py-3 px-4 rounded-lg border border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--background)] transition-colors text-sm mb-4"
|
|
188
|
+
>
|
|
189
|
+
Set a password (optional)
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{showPasswordForm && (
|
|
194
|
+
<form onSubmit={handleSetPassword} className="mb-4">
|
|
195
|
+
<div className="space-y-3">
|
|
196
|
+
<input
|
|
197
|
+
type="password"
|
|
198
|
+
placeholder="Password (min. 8 characters)"
|
|
199
|
+
value={password}
|
|
200
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
201
|
+
className="w-full px-4 py-3 rounded-lg bg-[var(--background)] border border-[var(--border)] focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)] outline-none transition-colors"
|
|
202
|
+
/>
|
|
203
|
+
<input
|
|
204
|
+
type="password"
|
|
205
|
+
placeholder="Confirm password"
|
|
206
|
+
value={confirmPassword}
|
|
207
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
208
|
+
className="w-full px-4 py-3 rounded-lg bg-[var(--background)] border border-[var(--border)] focus:border-[var(--accent)] focus:ring-1 focus:ring-[var(--accent)] outline-none transition-colors"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
{passwordError && (
|
|
212
|
+
<p className="text-red-500 text-sm mt-2">{passwordError}</p>
|
|
213
|
+
)}
|
|
214
|
+
<div className="flex gap-2 mt-3">
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={() => setShowPasswordForm(false)}
|
|
218
|
+
className="flex-1 py-2 px-4 rounded-lg border border-[var(--border)] text-[var(--text-secondary)] hover:bg-[var(--background)] transition-colors text-sm"
|
|
219
|
+
>
|
|
220
|
+
Cancel
|
|
221
|
+
</button>
|
|
222
|
+
<button
|
|
223
|
+
type="submit"
|
|
224
|
+
className="flex-1 py-2 px-4 rounded-lg bg-[var(--accent)] text-white font-medium hover:opacity-90 transition-opacity text-sm"
|
|
225
|
+
>
|
|
226
|
+
Save Password
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</form>
|
|
230
|
+
)}
|
|
231
|
+
|
|
232
|
+
{passwordSaved && (
|
|
233
|
+
<div className="bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 rounded-lg p-3 text-sm text-center mb-4">
|
|
234
|
+
✓ Password saved
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
<div className="text-center pt-4 border-t border-[var(--border)]">
|
|
239
|
+
<p className="text-[var(--text-muted)] text-sm">
|
|
240
|
+
✓ You can close this tab now
|
|
241
|
+
</p>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{/* Error state */}
|
|
247
|
+
{status === "error" && (
|
|
248
|
+
<div className="bg-[var(--surface)] rounded-xl p-8 border border-[var(--border)]">
|
|
249
|
+
<div className="text-center">
|
|
250
|
+
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
251
|
+
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
252
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
253
|
+
</svg>
|
|
254
|
+
</div>
|
|
255
|
+
<h2 className="text-xl font-bold mb-2">Verification Failed</h2>
|
|
256
|
+
<p className="text-[var(--text-secondary)] mb-6">{error}</p>
|
|
257
|
+
<a
|
|
258
|
+
href="/"
|
|
259
|
+
className="inline-block py-3 px-6 rounded-lg bg-[var(--accent)] text-white font-medium hover:opacity-90 transition-opacity"
|
|
260
|
+
>
|
|
261
|
+
Go to Homepage
|
|
262
|
+
</a>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{/* Footer */}
|
|
268
|
+
<p className="text-center text-[var(--text-muted)] text-xs mt-6">
|
|
269
|
+
Having trouble? Contact{" "}
|
|
270
|
+
<a href="mailto:support@apiclaw.com" className="text-[var(--accent)] hover:underline">
|
|
271
|
+
support@apiclaw.com
|
|
272
|
+
</a>
|
|
273
|
+
</p>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default function VerifyPage() {
|
|
280
|
+
return (
|
|
281
|
+
<Suspense fallback={
|
|
282
|
+
<div className="min-h-screen bg-[var(--background)] flex items-center justify-center">
|
|
283
|
+
<div className="text-center">
|
|
284
|
+
<span className="text-6xl">🦞</span>
|
|
285
|
+
<p className="mt-4 text-[var(--text-secondary)]">Loading...</p>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
}>
|
|
289
|
+
<VerifyContent />
|
|
290
|
+
</Suspense>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Suspense } from "react";
|
|
2
|
+
|
|
3
|
+
export const metadata = {
|
|
4
|
+
title: "Dashboard | APIClaw",
|
|
5
|
+
description: "Manage your AI agents and view API usage analytics",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export default function DashboardLayout({
|
|
9
|
+
children,
|
|
10
|
+
}: {
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<Suspense fallback={
|
|
15
|
+
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
|
|
16
|
+
<div className="w-8 h-8 border-4 border-accent border-t-transparent rounded-full animate-spin" />
|
|
17
|
+
</div>
|
|
18
|
+
}>
|
|
19
|
+
{children}
|
|
20
|
+
</Suspense>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { Loader2 } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
export default function DashboardRedirect() {
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
router.replace("/workspace");
|
|
12
|
+
}, [router]);
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="min-h-screen flex items-center justify-center bg-[var(--background)]">
|
|
16
|
+
<div className="text-center">
|
|
17
|
+
<Loader2 className="w-12 h-12 text-[#ef4444] animate-spin mx-auto mb-4" />
|
|
18
|
+
<p className="text-[var(--text-muted)]">Redirecting to workspace...</p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -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("/workspace");
|
|
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("/workspace");
|
|
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'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
|
+
}
|