@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,178 @@
|
|
|
1
|
+
# PRD: Workspace Navigation & Error Fixes
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-02-28
|
|
4
|
+
**Status:** Draft
|
|
5
|
+
**Priority:** P0 (blocking UX)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Problem Summary
|
|
10
|
+
|
|
11
|
+
Three issues identified after workspace merge:
|
|
12
|
+
|
|
13
|
+
### Issue 1: Header "Add Your API" should be "Workspace"
|
|
14
|
+
**Location:** Landing page header (logged-in state)
|
|
15
|
+
**Current:** Shows "Add Your API" button
|
|
16
|
+
**Expected:** Should show "Workspace" button for logged-in users
|
|
17
|
+
|
|
18
|
+
### Issue 2: APIs Tab Client-Side Error
|
|
19
|
+
**Location:** `/workspace?tab=apis`
|
|
20
|
+
**Current:** "Application error: a client-side exception has occurred"
|
|
21
|
+
**Expected:** Should render APIs list for providers
|
|
22
|
+
|
|
23
|
+
### Issue 3: Navigation Disharmony
|
|
24
|
+
**Location:** `/providers/dashboard`
|
|
25
|
+
**Current:** Renders old Provider Dashboard with its own tabs (Overview, APIs, Analytics) inside a sidebar that links to /workspace
|
|
26
|
+
**Expected:** Should redirect to `/workspace` OR be removed entirely
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Root Cause Analysis
|
|
31
|
+
|
|
32
|
+
### Issue 1: Header
|
|
33
|
+
The header component checks for session but still shows provider-centric CTA. Needs conditional rendering:
|
|
34
|
+
- Logged out: "Add Your API" / "Connect Your Agent"
|
|
35
|
+
- Logged in: "Workspace" button
|
|
36
|
+
|
|
37
|
+
### Issue 2: APIs Tab Error
|
|
38
|
+
Likely cause: `ApisTab` component tries to render `providerApis` but:
|
|
39
|
+
- Provider session might not be properly loaded
|
|
40
|
+
- Or `isProvider` check passes but `providerApis` fetch fails
|
|
41
|
+
- Or missing null check on API data
|
|
42
|
+
|
|
43
|
+
### Issue 3: Navigation Disharmony
|
|
44
|
+
`/providers/dashboard/page.tsx` still exists and renders full dashboard. The layout wraps it with sidebar pointing to /workspace, creating two competing navigation systems.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Proposed Fixes
|
|
49
|
+
|
|
50
|
+
### Fix 1: Header Update
|
|
51
|
+
|
|
52
|
+
**File:** `landing/src/components/Header.tsx` (or equivalent)
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
// Pseudo-code
|
|
56
|
+
const isLoggedIn = checkSession();
|
|
57
|
+
|
|
58
|
+
{isLoggedIn ? (
|
|
59
|
+
<Link href="/workspace" className="btn-primary">
|
|
60
|
+
Workspace
|
|
61
|
+
</Link>
|
|
62
|
+
) : (
|
|
63
|
+
<>
|
|
64
|
+
<Link href="/workspace">Connect Your Agent</Link>
|
|
65
|
+
<Link href="/providers/register">Add Your API</Link>
|
|
66
|
+
</>
|
|
67
|
+
)}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Fix 2: APIs Tab Error Fix
|
|
71
|
+
|
|
72
|
+
**File:** `landing/src/app/workspace/page.tsx`
|
|
73
|
+
|
|
74
|
+
1. Add error boundary around ApisTab
|
|
75
|
+
2. Add null checks for providerApis
|
|
76
|
+
3. Ensure isProvider only true when providerApis successfully loaded
|
|
77
|
+
4. Add try-catch in fetchProviderData
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
// In fetchProviderData
|
|
81
|
+
const fetchProviderData = useCallback(async () => {
|
|
82
|
+
try {
|
|
83
|
+
const providerData = localStorage.getItem("apiclaw_provider");
|
|
84
|
+
const providerSession = localStorage.getItem("apiclaw_session");
|
|
85
|
+
|
|
86
|
+
if (!providerData || !providerSession) {
|
|
87
|
+
setIsProvider(false);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ... fetch APIs
|
|
92
|
+
|
|
93
|
+
if (apisData.value) {
|
|
94
|
+
setProviderApis(apisData.value);
|
|
95
|
+
setIsProvider(true); // Only set true AFTER successful fetch
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error("Fetch provider error:", err);
|
|
99
|
+
setIsProvider(false); // Fail safe
|
|
100
|
+
}
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
// In ApisTab, add defensive check
|
|
104
|
+
function ApisTab({ apis }: { apis: ProviderAPI[] }) {
|
|
105
|
+
if (!apis) {
|
|
106
|
+
return <LoadingState />;
|
|
107
|
+
}
|
|
108
|
+
// ...
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Fix 3: Remove Old Provider Dashboard
|
|
113
|
+
|
|
114
|
+
**Option A: Hard Redirect (Recommended)**
|
|
115
|
+
|
|
116
|
+
Replace `/providers/dashboard/page.tsx` content with redirect:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
"use client";
|
|
120
|
+
import { useEffect } from "react";
|
|
121
|
+
import { useRouter } from "next/navigation";
|
|
122
|
+
|
|
123
|
+
export default function ProviderDashboardRedirect() {
|
|
124
|
+
const router = useRouter();
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
router.replace("/workspace?tab=apis");
|
|
127
|
+
}, [router]);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Option B: Remove entirely**
|
|
133
|
+
|
|
134
|
+
Delete `/providers/dashboard/page.tsx` and update layout to not render when at root path.
|
|
135
|
+
|
|
136
|
+
**Note:** Keep `/providers/dashboard/[apiId]/*` routes for API detail pages.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Migration Path
|
|
141
|
+
|
|
142
|
+
1. **Phase 1:** Fix APIs tab error (unblocks testing)
|
|
143
|
+
2. **Phase 2:** Redirect /providers/dashboard → /workspace
|
|
144
|
+
3. **Phase 3:** Update header for logged-in state
|
|
145
|
+
4. **Phase 4:** Clean up unused provider dashboard components
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Files to Modify
|
|
150
|
+
|
|
151
|
+
| File | Change |
|
|
152
|
+
|------|--------|
|
|
153
|
+
| `workspace/page.tsx` | Add null checks, error handling for APIs tab |
|
|
154
|
+
| `providers/dashboard/page.tsx` | Replace with redirect to /workspace?tab=apis |
|
|
155
|
+
| `components/Header.tsx` or `page.tsx` (landing) | Add logged-in state check, show "Workspace" button |
|
|
156
|
+
| `providers/dashboard/layout.tsx` | Optional: simplify since main page redirects |
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Acceptance Criteria
|
|
161
|
+
|
|
162
|
+
- [ ] Logged-in user sees "Workspace" in header (not "Add Your API")
|
|
163
|
+
- [ ] Clicking "APIs" tab in /workspace shows API list without error
|
|
164
|
+
- [ ] Navigating to /providers/dashboard redirects to /workspace?tab=apis
|
|
165
|
+
- [ ] API detail pages (/providers/dashboard/{id}) still work
|
|
166
|
+
- [ ] No duplicate sidebars/navigation visible
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Estimated Effort
|
|
171
|
+
|
|
172
|
+
| Task | Time |
|
|
173
|
+
|------|------|
|
|
174
|
+
| Fix APIs tab error | 15 min |
|
|
175
|
+
| Redirect provider dashboard | 10 min |
|
|
176
|
+
| Update header | 20 min |
|
|
177
|
+
| Testing | 15 min |
|
|
178
|
+
| **Total** | **~1 hour** |
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"next": "^14.2.0",
|
|
15
15
|
"react": "^18.2.0",
|
|
16
16
|
"react-dom": "^18.2.0",
|
|
17
|
-
"recharts": "^3.7.0"
|
|
17
|
+
"recharts": "^3.7.0",
|
|
18
|
+
"stripe": "^20.4.0"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
20
21
|
"@types/node": "^20",
|
|
@@ -924,7 +925,7 @@
|
|
|
924
925
|
"version": "20.19.33",
|
|
925
926
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
|
|
926
927
|
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
|
|
927
|
-
"
|
|
928
|
+
"devOptional": true,
|
|
928
929
|
"license": "MIT",
|
|
929
930
|
"dependencies": {
|
|
930
931
|
"undici-types": "~6.21.0"
|
|
@@ -2539,6 +2540,23 @@
|
|
|
2539
2540
|
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
|
|
2540
2541
|
"license": "MIT"
|
|
2541
2542
|
},
|
|
2543
|
+
"node_modules/stripe": {
|
|
2544
|
+
"version": "20.4.0",
|
|
2545
|
+
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz",
|
|
2546
|
+
"integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==",
|
|
2547
|
+
"license": "MIT",
|
|
2548
|
+
"engines": {
|
|
2549
|
+
"node": ">=16"
|
|
2550
|
+
},
|
|
2551
|
+
"peerDependencies": {
|
|
2552
|
+
"@types/node": ">=16"
|
|
2553
|
+
},
|
|
2554
|
+
"peerDependenciesMeta": {
|
|
2555
|
+
"@types/node": {
|
|
2556
|
+
"optional": true
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
},
|
|
2542
2560
|
"node_modules/styled-jsx": {
|
|
2543
2561
|
"version": "5.1.1",
|
|
2544
2562
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
|
|
@@ -2763,7 +2781,7 @@
|
|
|
2763
2781
|
"version": "6.21.0",
|
|
2764
2782
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
|
2765
2783
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
|
2766
|
-
"
|
|
2784
|
+
"devOptional": true,
|
|
2767
2785
|
"license": "MIT"
|
|
2768
2786
|
},
|
|
2769
2787
|
"node_modules/unicode-trie": {
|
package/landing/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import Stripe from "stripe";
|
|
3
|
+
|
|
4
|
+
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
|
|
5
|
+
|
|
6
|
+
// Initialize Stripe
|
|
7
|
+
const stripeSecretKey = process.env.STRIPE_SECRET_KEY;
|
|
8
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
|
9
|
+
|
|
10
|
+
const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null;
|
|
11
|
+
|
|
12
|
+
// Helper to call Convex mutations
|
|
13
|
+
async function callConvex(path: string, args: Record<string, unknown>) {
|
|
14
|
+
const response = await fetch(`${CONVEX_URL}/api/mutation`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: JSON.stringify({ path, args }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const result = await response.json();
|
|
21
|
+
return result.value || result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper to call Convex queries
|
|
25
|
+
async function queryConvex(path: string, args: Record<string, unknown>) {
|
|
26
|
+
const response = await fetch(`${CONVEX_URL}/api/query`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ path, args }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const result = await response.json();
|
|
33
|
+
return result.value || result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function POST(req: NextRequest) {
|
|
37
|
+
if (!stripe || !webhookSecret) {
|
|
38
|
+
console.error("Stripe not configured");
|
|
39
|
+
return NextResponse.json({ error: "Stripe not configured" }, { status: 500 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// Get raw body for signature verification
|
|
44
|
+
const body = await req.text();
|
|
45
|
+
const signature = req.headers.get("stripe-signature");
|
|
46
|
+
|
|
47
|
+
if (!signature) {
|
|
48
|
+
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Verify webhook signature
|
|
52
|
+
let event: Stripe.Event;
|
|
53
|
+
try {
|
|
54
|
+
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error("Webhook signature verification failed:", err);
|
|
57
|
+
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(`[Stripe Webhook] Event: ${event.type}`);
|
|
61
|
+
|
|
62
|
+
// Handle events
|
|
63
|
+
switch (event.type) {
|
|
64
|
+
case "checkout.session.completed": {
|
|
65
|
+
const session = event.data.object as Stripe.Checkout.Session;
|
|
66
|
+
await handleCheckoutCompleted(session);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case "invoice.payment_failed": {
|
|
71
|
+
const invoice = event.data.object as Stripe.Invoice;
|
|
72
|
+
await handlePaymentFailed(invoice);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
case "customer.subscription.deleted": {
|
|
77
|
+
const subscription = event.data.object as Stripe.Subscription;
|
|
78
|
+
await handleSubscriptionCanceled(subscription);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
default:
|
|
83
|
+
console.log(`[Stripe Webhook] Unhandled event: ${event.type}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return NextResponse.json({ received: true });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error("Webhook error:", error);
|
|
89
|
+
return NextResponse.json({ error: "Webhook failed" }, { status: 500 });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Handle successful checkout
|
|
94
|
+
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
|
|
95
|
+
const workspaceId = session.metadata?.workspaceId;
|
|
96
|
+
const type = session.metadata?.type;
|
|
97
|
+
|
|
98
|
+
console.log(`[Stripe] Checkout completed: ${session.id}, workspace: ${workspaceId}, type: ${type}`);
|
|
99
|
+
|
|
100
|
+
if (!workspaceId) {
|
|
101
|
+
// Try to find workspace by customer ID
|
|
102
|
+
if (session.customer) {
|
|
103
|
+
const workspace = await queryConvex("billing:getWorkspaceByStripeCustomer", {
|
|
104
|
+
stripeCustomerId: session.customer as string,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (workspace) {
|
|
108
|
+
await callConvex("billing:upgradeWorkspace", {
|
|
109
|
+
workspaceId: workspace._id,
|
|
110
|
+
tier: "pro",
|
|
111
|
+
});
|
|
112
|
+
console.log(`[Stripe] Upgraded workspace ${workspace._id} to pro`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
console.error("[Stripe] No workspace found for checkout session");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Upgrade workspace to pro
|
|
121
|
+
const result = await callConvex("billing:upgradeWorkspace", {
|
|
122
|
+
workspaceId,
|
|
123
|
+
tier: "pro",
|
|
124
|
+
stripeSubscriptionId: session.subscription as string,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
console.log(`[Stripe] Upgrade result:`, result);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Handle failed payment
|
|
131
|
+
async function handlePaymentFailed(invoice: Stripe.Invoice) {
|
|
132
|
+
const customerId = invoice.customer as string;
|
|
133
|
+
|
|
134
|
+
console.log(`[Stripe] Payment failed for customer: ${customerId}`);
|
|
135
|
+
|
|
136
|
+
// Find workspace by customer ID
|
|
137
|
+
const workspace = await queryConvex("billing:getWorkspaceByStripeCustomer", {
|
|
138
|
+
stripeCustomerId: customerId,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!workspace) {
|
|
142
|
+
console.error("[Stripe] No workspace found for customer:", customerId);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Suspend workspace
|
|
147
|
+
await callConvex("billing:suspendWorkspace", {
|
|
148
|
+
workspaceId: workspace._id,
|
|
149
|
+
reason: "payment_failed",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
console.log(`[Stripe] Suspended workspace ${workspace._id} due to payment failure`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Handle subscription cancellation
|
|
156
|
+
async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
|
|
157
|
+
const customerId = subscription.customer as string;
|
|
158
|
+
|
|
159
|
+
console.log(`[Stripe] Subscription canceled for customer: ${customerId}`);
|
|
160
|
+
|
|
161
|
+
// Find workspace by customer ID
|
|
162
|
+
const workspace = await queryConvex("billing:getWorkspaceByStripeCustomer", {
|
|
163
|
+
stripeCustomerId: customerId,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (!workspace) {
|
|
167
|
+
console.error("[Stripe] No workspace found for customer:", customerId);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Downgrade to free tier
|
|
172
|
+
await callConvex("billing:upgradeWorkspace", {
|
|
173
|
+
workspaceId: workspace._id,
|
|
174
|
+
tier: "free",
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
console.log(`[Stripe] Downgraded workspace ${workspace._id} to free tier`);
|
|
178
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
const { email, fingerprint } = await req.json();
|
|
8
|
+
|
|
9
|
+
if (!email || !email.includes("@")) {
|
|
10
|
+
return NextResponse.json({ error: "Valid email required" }, { status: 400 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Create magic link in Convex
|
|
14
|
+
const response = await fetch(`${CONVEX_URL}/api/mutation`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: JSON.stringify({
|
|
18
|
+
path: "workspaces:createMagicLink",
|
|
19
|
+
args: { email: email.toLowerCase(), fingerprint },
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error("Failed to create magic link");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = await response.json();
|
|
28
|
+
|
|
29
|
+
// Convex wraps response in { status: "success", value: {...} }
|
|
30
|
+
const token = result.value?.token || result.token;
|
|
31
|
+
|
|
32
|
+
if (!token) {
|
|
33
|
+
throw new Error("Failed to get token from Convex");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Send magic link email via n8n
|
|
37
|
+
const magicLinkUrl = `${process.env.NEXT_PUBLIC_APP_URL || "https://apiclaw.nordsym.com"}/dashboard/verify?token=${token}`;
|
|
38
|
+
|
|
39
|
+
await fetch("https://nordsym.app.n8n.cloud/webhook/symbot-gmail", {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
action: "smtp",
|
|
44
|
+
to: email,
|
|
45
|
+
subject: "🦞 Sign in to APIClaw Workspace",
|
|
46
|
+
message: `
|
|
47
|
+
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 500px; margin: 0 auto; padding: 40px 20px;">
|
|
48
|
+
<div style="text-align: center; margin-bottom: 32px;">
|
|
49
|
+
<span style="font-size: 48px;">🦞</span>
|
|
50
|
+
<h1 style="margin: 16px 0 8px; font-size: 24px; font-weight: 700;">Sign in to APIClaw</h1>
|
|
51
|
+
<p style="color: #737373; margin: 0;">Agent Workspace Dashboard</p>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<p style="color: #525252; font-size: 16px; line-height: 1.6; text-align: center;">
|
|
55
|
+
Click the button below to sign in to your workspace dashboard and manage your AI agents.
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
<div style="text-align: center; margin: 32px 0;">
|
|
59
|
+
<a href="${magicLinkUrl}" style="display: inline-block; background: #ef4444; color: white; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-weight: 600; font-size: 16px;">
|
|
60
|
+
Sign In to Workspace
|
|
61
|
+
</a>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<p style="color: #737373; font-size: 14px; text-align: center;">
|
|
65
|
+
This link expires in 15 minutes.
|
|
66
|
+
</p>
|
|
67
|
+
|
|
68
|
+
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e5e5;">
|
|
69
|
+
<p style="color: #a3a3a3; font-size: 12px; text-align: center;">
|
|
70
|
+
If you didn't request this email, you can safely ignore it.<br/>
|
|
71
|
+
APIClaw by NordSym
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
`,
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return NextResponse.json({ success: true });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error("Magic link error:", error);
|
|
82
|
+
return NextResponse.json({ error: "Failed to send magic link" }, { status: 500 });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
|
|
4
|
+
|
|
5
|
+
export async function GET(req: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
// Get session from cookie or Authorization header
|
|
8
|
+
const cookieToken = req.cookies.get("apiclaw_workspace_session")?.value;
|
|
9
|
+
const headerToken = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
10
|
+
const token = cookieToken || headerToken;
|
|
11
|
+
|
|
12
|
+
if (!token) {
|
|
13
|
+
return NextResponse.json({ session: null }, { status: 200 });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Verify session in Convex
|
|
17
|
+
const response = await fetch(`${CONVEX_URL}/api/query`, {
|
|
18
|
+
method: "POST",
|
|
19
|
+
headers: { "Content-Type": "application/json" },
|
|
20
|
+
body: JSON.stringify({
|
|
21
|
+
path: "workspaces:getSession",
|
|
22
|
+
args: { token },
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
return NextResponse.json({ session: null }, { status: 200 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = await response.json();
|
|
31
|
+
const session = result.value || result;
|
|
32
|
+
|
|
33
|
+
if (!session) {
|
|
34
|
+
// Clear invalid cookie
|
|
35
|
+
const res = NextResponse.json({ session: null }, { status: 200 });
|
|
36
|
+
res.cookies.delete("apiclaw_workspace_session");
|
|
37
|
+
return res;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return NextResponse.json({ session });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error("Session check error:", error);
|
|
43
|
+
return NextResponse.json({ session: null }, { status: 200 });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function DELETE(req: NextRequest) {
|
|
48
|
+
try {
|
|
49
|
+
const cookieToken = req.cookies.get("apiclaw_workspace_session")?.value;
|
|
50
|
+
const headerToken = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
51
|
+
const token = cookieToken || headerToken;
|
|
52
|
+
|
|
53
|
+
if (token) {
|
|
54
|
+
// Logout in Convex
|
|
55
|
+
await fetch(`${CONVEX_URL}/api/mutation`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
path: "workspaces:logout",
|
|
60
|
+
args: { token },
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Clear cookie
|
|
66
|
+
const res = NextResponse.json({ success: true });
|
|
67
|
+
res.cookies.delete("apiclaw_workspace_session");
|
|
68
|
+
return res;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error("Logout error:", error);
|
|
71
|
+
return NextResponse.json({ error: "Logout failed" }, { status: 500 });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL || "https://adventurous-avocet-799.convex.cloud";
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
const { token, fingerprint } = await req.json();
|
|
8
|
+
|
|
9
|
+
if (!token) {
|
|
10
|
+
return NextResponse.json({ error: "Token required" }, { status: 400 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Verify magic link in Convex
|
|
14
|
+
const response = await fetch(`${CONVEX_URL}/api/mutation`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { "Content-Type": "application/json" },
|
|
17
|
+
body: JSON.stringify({
|
|
18
|
+
path: "workspaces:verifyMagicLink",
|
|
19
|
+
args: { token, fingerprint },
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error("Verification failed");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = await response.json();
|
|
28
|
+
|
|
29
|
+
// Convex wraps response in { status: "success", value: {...} }
|
|
30
|
+
const data = result.value || result;
|
|
31
|
+
|
|
32
|
+
if (!data.success) {
|
|
33
|
+
return NextResponse.json({ error: data.error || "Invalid token" }, { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create response with session cookie
|
|
37
|
+
const res = NextResponse.json({
|
|
38
|
+
success: true,
|
|
39
|
+
sessionToken: data.sessionToken,
|
|
40
|
+
workspace: data.workspace,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Set httpOnly cookie for session
|
|
44
|
+
res.cookies.set("apiclaw_workspace_session", data.sessionToken, {
|
|
45
|
+
httpOnly: true,
|
|
46
|
+
secure: process.env.NODE_ENV === "production",
|
|
47
|
+
sameSite: "lax",
|
|
48
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
49
|
+
path: "/",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return res;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error("Verify error:", error);
|
|
55
|
+
return NextResponse.json({ error: "Verification failed" }, { status: 500 });
|
|
56
|
+
}
|
|
57
|
+
}
|