diff --git a/docs/plans/2026-02-28-login-modal-design.md b/docs/plans/2026-02-28-login-modal-design.md new file mode 100644 index 0000000..563cc27 --- /dev/null +++ b/docs/plans/2026-02-28-login-modal-design.md @@ -0,0 +1,73 @@ +# Login Modal Design + +**Date:** 2026-02-28 +**Status:** Approved + +## Problem + +The Navbar currently shows two separate login buttons (Google + GitHub) side by side when the user is not logged in. This is visually cluttered and inconsistent with common login UX patterns. + +## Solution + +Replace the two inline login buttons with a single "Log In" button. Clicking it opens a centered modal where users choose their login provider. + +## UI Design + +### Navbar (unauthenticated state) + +Before: +``` +[ ไธญๆ–‡ ] [ GitHub ] [ ๐Ÿ”ต Google ] [ โšซ GitHub ] +``` + +After: +``` +[ ไธญๆ–‡ ] [ GitHub ] [ Log In ] +``` + +The "Log In" button uses the brand accent color (orange). + +### Modal + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Welcome to MoltMarket [ร—] โ”‚ +โ”‚ โ”‚ +โ”‚ Choose a login method โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ ๐Ÿ”ต Continue with Google โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ โšซ Continue with GitHub โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +- Semi-transparent black overlay; clicking overlay closes modal +- Centered card with rounded corners and light shadow +- Close button (ร—) in top-right corner +- Google button: border style (existing style) +- GitHub button: dark fill style (existing style) + +## Technical Plan + +### Files to change + +1. **`src/components/Navbar.tsx`** + - Remove the two inline login `` tags + - Add a "Log In" ` +``` + +**Step 3: Add the modal JSX** + +Immediately before the closing `` tag, add: + +```tsx +{loginOpen && ( + <> + {/* Overlay */} +
setLoginOpen(false)} + /> + {/* Modal card */} +
+
+ {/* Header */} +
+
+

+ {t("loginTitle")} +

+

+ {t("loginSubtitle")} +

+
+ +
+ + {/* Login options */} +
+ {/* Google */} + + + + + + + + Continue with Google + + + {/* GitHub */} + + + + + Continue with GitHub + +
+
+
+ +)} +``` + +**Step 4: Verify the file compiles** + +```bash +npx tsc --noEmit +``` + +Expected: no errors + +**Step 5: Commit** + +```bash +git add src/components/Navbar.tsx +git commit -m "feat: replace login buttons with Log In modal in Navbar" +``` + +--- + +### Task 3: Manual smoke test + +**Step 1: Start dev server** + +```bash +npm run dev +``` + +**Step 2: Verify checklist** + +- [ ] Navbar shows single "Log In" button when logged out +- [ ] Clicking "Log In" opens the modal with Google and GitHub options +- [ ] Clicking the overlay (outside the card) closes the modal +- [ ] Clicking the ร— button closes the modal +- [ ] Clicking "Continue with Google" redirects to `/api/auth/login?provider=google` +- [ ] Clicking "Continue with GitHub" redirects to `/api/auth/login?provider=github` +- [ ] Switching locale (EN โ†” ไธญๆ–‡) shows correct translated text in modal title/subtitle +- [ ] Logged-in state still shows username + Logout (unchanged) diff --git a/docs/plans/2026-02-28-supabase-oauth-design.md b/docs/plans/2026-02-28-supabase-oauth-design.md new file mode 100644 index 0000000..dfb3fd9 --- /dev/null +++ b/docs/plans/2026-02-28-supabase-oauth-design.md @@ -0,0 +1,95 @@ +# Supabase OAuth ๆ›ฟๆข SecondMe OAuth ่ฎพ่ฎกๆ–‡ๆกฃ + +**ๆ—ฅๆœŸ๏ผš** 2026-02-28 +**ๅˆ†ๆ”ฏ๏ผš** feature/OAuth + +## ่ƒŒๆ™ฏ + +ๅฐ†็Žฐๆœ‰็š„ SecondMe OAuth ่ฎค่ฏๆ›ฟๆขไธบ Supabase OAuth๏ผŒๆ”ฏๆŒ Google ๅ’Œ GitHub ็™ปๅฝ•ใ€‚ๅŒๆ—ถๅˆ ้™คๆ‰€ๆœ‰ไพ่ต– SecondMe access token ็š„ API ่ทฏ็”ฑใ€‚ + +## ๆŠ€ๆœฏ้€‰ๅž‹ + +- **่ฎค่ฏๅบ“๏ผš** `@supabase/ssr`๏ผˆๅฎ˜ๆ–นๆŽจ่็š„ Next.js App Router ้›†ๆˆๆ–นๆกˆ๏ผ‰ +- **ๆ•ฐๆฎๅบ“๏ผš** ไฟ็•™ Prisma + PostgreSQL๏ผŒๆ–ฐๅขž `supabaseUserId` ๅญ—ๆฎตๆ›ฟๆข `secondmeUserId` +- **Session ็ฎก็†๏ผš** Supabase cookie-based session๏ผˆ`@supabase/ssr` ่‡ชๅŠจๅค„็†ๅˆทๆ–ฐ๏ผ‰+ ๅ†…้ƒจ `session_user_id` cookie ๆŒ‡ๅ‘ Prisma User + +## Auth ๆต็จ‹ + +``` +็”จๆˆท็‚นๅ‡ป "Sign in with Google/GitHub" + โ†’ GET /api/auth/login?provider=google|github + โ†’ supabase.auth.signInWithOAuth({ provider, redirectTo }) + โ†’ 302 ๅˆฐ Google/GitHub ๆŽˆๆƒ้กต + โ†’ ๆŽˆๆƒๆˆๅŠŸ โ†’ 302 ๅˆฐ /api/auth/callback?code=... + โ†’ supabase.auth.exchangeCodeForSession(code) + โ†’ ่Žทๅ– supabase user (email, name, avatar_url) + โ†’ upsert Prisma User (where: supabaseUserId) + โ†’ ่ฎพ็ฝฎ session_user_id cookie (Prisma user ID) + โ†’ 302 ๅˆฐ / +``` + +## ๆ–‡ไปถๆ”นๅŠจ + +### ๆ–ฐๅขž +- `src/lib/supabase.ts` โ€” Supabase browser client + server client ๅทฅๅŽ‚๏ผˆๅŸบไบŽ `@supabase/ssr`๏ผ‰ + +### ไฟฎๆ”น +| ๆ–‡ไปถ | ๆ”นๅŠจๆ่ฟฐ | +|------|---------| +| `package.json` | ๆทปๅŠ  `@supabase/ssr` | +| `src/lib/auth.ts` | ๅˆ ้™ค `getValidAccessToken`๏ผˆSecondMe token ๅˆทๆ–ฐ้€ป่พ‘๏ผ‰๏ผŒไฟ็•™ `getSessionUserId / setSessionUserId / clearSession / getCurrentUser` | +| `src/app/api/auth/login/route.ts` | ่ฏปๅ– `provider` query param๏ผŒ่ฐƒ็”จ `supabase.auth.signInWithOAuth()` | +| `src/app/api/auth/callback/route.ts` | ่ฐƒ็”จ `exchangeCodeForSession`๏ผŒupsert Prisma User๏ผŒไฟ็•™ claimCode ้€ป่พ‘ | +| `src/app/api/auth/logout/route.ts` | ่ฐƒ็”จ `supabase.auth.signOut()`๏ผŒๆธ…้™ค session cookie | +| `src/middleware.ts` | ๆทปๅŠ  Supabase session ๅˆทๆ–ฐ๏ผˆcreateServerClient + getUser๏ผ‰ | +| `prisma/schema.prisma` | User ๆจกๅž‹๏ผš`secondmeUserId` โ†’ `supabaseUserId`๏ผŒๅˆ ้™ค `accessToken/refreshToken/tokenExpiresAt`๏ผ›ๅˆ ้™ค `ChatSession/ChatMessage/Note` ๆจกๅž‹ | +| `src/components/Navbar.tsx` | ็™ปๅฝ•ๅŒบๅŸŸๆ”นไธบ Google / GitHub ไธคไธชๆŒ‰้’ฎ๏ผˆ`/api/auth/login?provider=google` ๅ’Œ `?provider=github`๏ผ‰ | + +### ๅˆ ้™ค +- `src/app/api/user/info/route.ts` +- `src/app/api/user/shades/route.ts` +- `src/app/api/chat/route.ts` +- `src/app/api/act/route.ts` +- `src/app/api/note/route.ts` +- `src/app/api/sessions/route.ts` + +## Prisma Schema ๅ˜ๆ›ด + +```prisma +model User { + id String @id @default(cuid()) + supabaseUserId String @unique @map("supabase_user_id") // ๆ›ฟๆข secondmeUserId + email String? + name String? + avatarUrl String? @map("avatar_url") + // ๅˆ ้™ค: accessToken, refreshToken, tokenExpiresAt + // ๅ…ถไฝ™ๅญ—ๆฎตไฟๆŒไธๅ˜ + ... +} +// ๅˆ ้™ค: ChatSession, ChatMessage, Note ๆจกๅž‹ +``` + +## ็Žฏๅขƒๅ˜้‡ + +ๆ–ฐๅขž๏ผš +``` +NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... +``` + +ๅˆ ้™ค๏ผš +``` +SECONDME_CLIENT_ID +SECONDME_CLIENT_SECRET +SECONDME_OAUTH_URL +SECONDME_REDIRECT_URI +SECONDME_TOKEN_ENDPOINT +SECONDME_API_BASE_URL +``` + +## Supabase ๆŽงๅˆถๅฐ้…็ฝฎ + +ๅœจ Supabase Dashboard ไธญ้œ€่ฆ้…็ฝฎ๏ผš +1. Authentication โ†’ Providers โ†’ ๅฏ็”จ Google๏ผˆๅกซๅ†™ Client ID + Secret๏ผ‰ +2. Authentication โ†’ Providers โ†’ ๅฏ็”จ GitHub๏ผˆๅกซๅ†™ Client ID + Secret๏ผ‰ +3. Authentication โ†’ URL Configuration โ†’ ๆทปๅŠ  Redirect URL๏ผš`{APP_URL}/api/auth/callback` diff --git a/docs/plans/2026-02-28-supabase-oauth.md b/docs/plans/2026-02-28-supabase-oauth.md new file mode 100644 index 0000000..bad937e --- /dev/null +++ b/docs/plans/2026-02-28-supabase-oauth.md @@ -0,0 +1,846 @@ +# Supabase OAuth Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace SecondMe OAuth with Supabase OAuth (Google + GitHub), delete all SecondMe-dependent API routes, and update the Prisma User model accordingly. + +**Architecture:** Use `@supabase/ssr` for Next.js App Router compatible session management. Supabase handles the OAuth handshake; our callback upserts a Prisma User keyed on `supabaseUserId`. We continue to maintain a `session_user_id` cookie pointing at the Prisma User for all downstream app logic. + +**Tech Stack:** Next.js 16 App Router, `@supabase/ssr`, `@supabase/supabase-js` (already installed), Prisma 7 + Neon PostgreSQL, next-intl, TypeScript. + +--- + +## Prerequisites (manual โ€“ not code tasks) + +In the **Supabase Dashboard** before running the code: +1. **Authentication โ†’ Providers** โ€“ enable Google (paste Client ID + Secret from Google Cloud Console) +2. **Authentication โ†’ Providers** โ€“ enable GitHub (paste Client ID + Secret from GitHub OAuth App) +3. **Authentication โ†’ URL Configuration โ†’ Redirect URLs** โ€“ add `http://localhost:3000/api/auth/callback` (dev) and your production URL + +New env vars to add to `.env.local` (and Vercel): +``` +NEXT_PUBLIC_SUPABASE_URL=https://.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... +``` + +--- + +### Task 1: Install @supabase/ssr + +**Files:** +- Modify: `package.json` (via npm) + +**Step 1: Install the package** + +```bash +npm install @supabase/ssr +``` + +Expected: package-lock.json updated, `@supabase/ssr` appears in `dependencies`. + +**Step 2: Commit** + +```bash +git add package.json package-lock.json +git commit -m "feat: add @supabase/ssr dependency" +``` + +--- + +### Task 2: Create Supabase client factory + +**Files:** +- Create: `src/lib/supabase.ts` + +**Step 1: Create the file** + +```typescript +// src/lib/supabase.ts +import { createServerClient } from "@supabase/ssr"; +import { createBrowserClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import type { NextRequest, NextResponse } from "next/server"; + +/** Client components */ +export function createSupabaseBrowserClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} + +/** Server components and API route handlers */ +export async function createSupabaseServerClient() { + const cookieStore = await cookies(); + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch { + // Called from a Server Component โ€“ read-only, ignore + } + }, + }, + } + ); +} + +/** Middleware โ€“ takes both request and mutable response */ +export function createSupabaseMiddlewareClient( + request: NextRequest, + response: NextResponse +) { + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ); + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options) + ); + }, + }, + } + ); +} +``` + +**Step 2: Verify TypeScript compiles** + +```bash +npx tsc --noEmit +``` + +Expected: no errors for the new file (env vars are non-null asserted). + +**Step 3: Commit** + +```bash +git add src/lib/supabase.ts +git commit -m "feat: add Supabase client factory (browser/server/middleware)" +``` + +--- + +### Task 3: Update Prisma schema + +**Files:** +- Modify: `prisma/schema.prisma` + +**Step 1: Replace the User model and remove ChatSession/ChatMessage/Note** + +Replace the entire content of `prisma/schema.prisma` with: + +```prisma +generator client { + provider = "prisma-client-js" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +model User { + id String @id @default(cuid()) + supabaseUserId String @unique @map("supabase_user_id") + email String? + name String? + avatarUrl String? @map("avatar_url") + + // Wallet fields + balance Decimal @default(100.00) @db.Decimal(10, 2) + totalEarned Decimal @default(0.00) @map("total_earned") @db.Decimal(10, 2) + totalSpent Decimal @default(0.00) @map("total_spent") @db.Decimal(10, 2) + + // Statistics + completedTasks Int @default(0) @map("completed_tasks") + rating Decimal? @db.Decimal(3, 2) + + // Status + status String @default("active") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + agents Agent[] + + @@map("users") +} + +model Task { + id String @id @default(cuid()) + title String + description String @db.Text + context Json? + estimatedTokens Int @map("estimated_tokens") + estimatedCredits Int @map("estimated_credits") + priority String @default("medium") + status String @default("pending") + + publisherAgentId String @map("publisher_agent_id") + publisherAgent Agent @relation("publisher", fields: [publisherAgentId], references: [id], onDelete: Cascade) + + workerAgentId String? @map("worker_agent_id") + workerAgent Agent? @relation("worker", fields: [workerAgentId], references: [id], onDelete: SetNull) + + result String? @db.Text + actualTokens Int? @map("actual_tokens") + rating Int? + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + acceptedAt DateTime? @map("accepted_at") + completedAt DateTime? @map("completed_at") + + creditTransactions CreditTransaction[] + activityFeeds ActivityFeed[] + + @@index([status]) + @@index([publisherAgentId]) + @@index([workerAgentId]) + @@index([priority]) + @@index([createdAt]) + @@map("tasks") +} + +model CreditTransaction { + id String @id @default(cuid()) + taskId String @map("task_id") + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + agentId String @map("agent_id") + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + type String + credits Int + tokens Int + balanceAfter Int @map("balance_after") + status String @default("completed") + description String? @db.Text + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + @@index([agentId]) + @@index([taskId]) + @@index([type]) + @@index([createdAt]) + @@map("credit_transactions") +} + +model ActivityFeed { + id String @id @default(cuid()) + eventType String @map("event_type") + agentId String @map("agent_id") + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) + taskId String? @map("task_id") + task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade) + title String + description String? @db.Text + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + + @@index([eventType]) + @@index([agentId]) + @@index([taskId]) + @@index([createdAt]) + @@map("activity_feeds") +} + +model Agent { + id String @id @default(cuid()) + apiKey String @unique @map("api_key") + apiKeyHash String @map("api_key_hash") + name String + + claimCode String @unique @map("claim_code") + verificationCode String @map("verification_code") + + userId String? @map("user_id") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + claimedAt DateTime? @map("claimed_at") + + credits Int @default(100) + totalEarned Int @default(0) @map("total_earned") + totalSpent Int @default(0) @map("total_spent") + tokensSaved Int @default(0) @map("tokens_saved") + tokensContributed Int @default(0) @map("tokens_contributed") + tasksPublished Int @default(0) @map("tasks_published") + tasksCompleted Int @default(0) @map("tasks_completed") + reputation Int @default(0) + + status String @default("unclaimed") + lastActive DateTime @default(now()) @map("last_active") + lastHeartbeat DateTime? @map("last_heartbeat") + + capabilities Json? + preferences Json? + userAgent String? @map("user_agent") + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + publishedTasks Task[] @relation("publisher") + workerTasks Task[] @relation("worker") + creditTransactions CreditTransaction[] + activityFeeds ActivityFeed[] + + @@index([claimCode]) + @@index([userId]) + @@index([status]) + @@index([lastHeartbeat]) + @@map("agents") +} +``` + +**Step 2: Run migration** + +```bash +npx prisma migrate dev --name supabase-auth +``` + +Expected output: migration file created in `prisma/migrations/`, Prisma client regenerated. If prompted about dropped tables (`chat_sessions`, `chat_messages`, `notes`), confirm โ€” this is intentional. + +**Step 3: Verify generated types** + +```bash +npx tsc --noEmit +``` + +Expected: errors about `secondmeUserId` / `accessToken` etc. in auth files โ€” that's fine, we'll fix them in the next tasks. + +**Step 4: Commit** + +```bash +git add prisma/schema.prisma prisma/migrations/ src/generated/ +git commit -m "feat: migrate User model to supabase_user_id, drop chat/note tables" +``` + +--- + +### Task 4: Update src/lib/auth.ts + +**Files:** +- Modify: `src/lib/auth.ts` + +**Step 1: Replace file contents** + +Remove `getValidAccessToken` entirely. Keep only session cookie helpers and `getCurrentUser`: + +```typescript +// src/lib/auth.ts +import { cookies } from "next/headers"; +import { prisma } from "./prisma"; + +const COOKIE_NAME = "session_user_id"; + +export async function getSessionUserId(): Promise { + const cookieStore = await cookies(); + return cookieStore.get(COOKIE_NAME)?.value ?? null; +} + +export async function setSessionUserId(userId: string) { + const cookieStore = await cookies(); + cookieStore.set(COOKIE_NAME, userId, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, + path: "/", + }); +} + +export async function clearSession() { + const cookieStore = await cookies(); + cookieStore.delete(COOKIE_NAME); +} + +export async function getCurrentUser() { + const userId = await getSessionUserId(); + if (!userId) return null; + return prisma.user.findUnique({ where: { id: userId } }); +} +``` + +**Step 2: Verify no remaining references to getValidAccessToken** + +```bash +grep -r "getValidAccessToken" src/ +``` + +Expected: no output (zero matches). + +**Step 3: Commit** + +```bash +git add src/lib/auth.ts +git commit -m "feat: remove SecondMe token refresh from auth lib" +``` + +--- + +### Task 5: Update login route + +**Files:** +- Modify: `src/app/api/auth/login/route.ts` + +**Step 1: Replace file contents** + +```typescript +// src/app/api/auth/login/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseServerClient } from "@/lib/supabase"; + +export async function GET(request: NextRequest) { + const provider = request.nextUrl.searchParams.get("provider") as + | "google" + | "github" + | null; + const claimCode = request.nextUrl.searchParams.get("claimCode"); + + if (!provider || !["google", "github"].includes(provider)) { + return NextResponse.redirect( + new URL("/?error=invalid_provider", request.url) + ); + } + + const supabase = await createSupabaseServerClient(); + + const callbackUrl = new URL("/api/auth/callback", request.url); + if (claimCode) { + callbackUrl.searchParams.set("claimCode", claimCode); + } + + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { redirectTo: callbackUrl.toString() }, + }); + + if (error || !data.url) { + return NextResponse.redirect( + new URL("/?error=oauth_failed", request.url) + ); + } + + return NextResponse.redirect(data.url); +} +``` + +**Step 2: Verify TypeScript** + +```bash +npx tsc --noEmit +``` + +**Step 3: Commit** + +```bash +git add src/app/api/auth/login/route.ts +git commit -m "feat: replace SecondMe OAuth redirect with Supabase signInWithOAuth" +``` + +--- + +### Task 6: Update callback route + +**Files:** +- Modify: `src/app/api/auth/callback/route.ts` + +**Step 1: Replace file contents** + +```typescript +// src/app/api/auth/callback/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { createSupabaseServerClient } from "@/lib/supabase"; + +export async function GET(request: NextRequest) { + const code = request.nextUrl.searchParams.get("code"); + const claimCode = request.nextUrl.searchParams.get("claimCode"); + + if (!code) { + return NextResponse.redirect(new URL("/?error=no_code", request.url)); + } + + try { + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + + if (error || !data.user) { + console.error("Session exchange failed:", error); + return NextResponse.redirect( + new URL("/?error=token_failed", request.url) + ); + } + + const supabaseUser = data.user; + const email = supabaseUser.email ?? null; + const name = + supabaseUser.user_metadata?.full_name ?? + supabaseUser.user_metadata?.name ?? + email ?? + null; + const avatarUrl = supabaseUser.user_metadata?.avatar_url ?? null; + + const user = await prisma.user.upsert({ + where: { supabaseUserId: supabaseUser.id }, + create: { supabaseUserId: supabaseUser.id, email, name, avatarUrl }, + update: { email, name, avatarUrl }, + }); + + const response = NextResponse.redirect(new URL("/", request.url)); + response.cookies.set("session_user_id", user.id, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, + path: "/", + }); + + // Handle agent claiming + if (claimCode) { + try { + const agent = await prisma.agent.findUnique({ where: { claimCode } }); + if (agent && agent.status === "unclaimed") { + await prisma.agent.update({ + where: { id: agent.id }, + data: { userId: user.id, status: "active", claimedAt: new Date() }, + }); + await prisma.activityFeed.create({ + data: { + eventType: "agent_claimed", + agentId: agent.id, + title: `${agent.name} was claimed`, + description: `Agent ${agent.name} has been successfully claimed and linked to your account.`, + metadata: { + userId: user.id, + claimCode, + claimedAt: new Date().toISOString(), + }, + }, + }); + } + } catch (claimError) { + console.error("Error claiming agent:", claimError); + // Don't block login if claiming fails + } + } + + return response; + } catch (error) { + console.error("OAuth callback error:", error); + return NextResponse.redirect( + new URL("/?error=callback_failed", request.url) + ); + } +} +``` + +**Step 2: Verify TypeScript** + +```bash +npx tsc --noEmit +``` + +Expected: no errors for this file. + +**Step 3: Commit** + +```bash +git add src/app/api/auth/callback/route.ts +git commit -m "feat: use Supabase exchangeCodeForSession in auth callback" +``` + +--- + +### Task 7: Update logout route + +**Files:** +- Modify: `src/app/api/auth/logout/route.ts` + +**Step 1: Replace file contents** + +```typescript +// src/app/api/auth/logout/route.ts +import { NextResponse } from "next/server"; +import { clearSession } from "@/lib/auth"; +import { createSupabaseServerClient } from "@/lib/supabase"; + +export async function GET(request: Request) { + const supabase = await createSupabaseServerClient(); + await supabase.auth.signOut(); + await clearSession(); + return NextResponse.redirect(new URL("/", request.url)); +} +``` + +**Step 2: Commit** + +```bash +git add src/app/api/auth/logout/route.ts +git commit -m "feat: add Supabase signOut to logout route" +``` + +--- + +### Task 8: Update middleware to refresh Supabase session + +**Files:** +- Modify: `src/middleware.ts` + +**Step 1: Replace file contents** + +```typescript +// src/middleware.ts +import { type NextRequest, NextResponse } from "next/server"; +import createIntlMiddleware from "next-intl/middleware"; +import { createServerClient } from "@supabase/ssr"; +import { routing } from "./i18n/routing"; + +const intlMiddleware = createIntlMiddleware(routing); + +export async function middleware(request: NextRequest) { + // 1. Create a base response so Supabase can write refreshed session cookies + let supabaseResponse = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + // Write new cookie values into the request for downstream handlers + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ); + // Rebuild supabaseResponse with updated request + supabaseResponse = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ); + }, + }, + } + ); + + // Refresh session if expired โ€” must not run any logic before this + await supabase.auth.getUser(); + + // 2. Run next-intl middleware on the (potentially cookie-updated) request + const intlResponse = intlMiddleware(request); + + // 3. Copy any Supabase session cookies onto the intl response + supabaseResponse.cookies.getAll().forEach((cookie) => { + intlResponse.cookies.set(cookie.name, cookie.value, { path: "/" }); + }); + + return intlResponse; +} + +export const config = { + matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], +}; +``` + +**Step 2: Verify TypeScript** + +```bash +npx tsc --noEmit +``` + +**Step 3: Commit** + +```bash +git add src/middleware.ts +git commit -m "feat: add Supabase session refresh to middleware" +``` + +--- + +### Task 9: Update Navbar โ€“ Google & GitHub login buttons + +**Files:** +- Modify: `src/components/Navbar.tsx` + +**Step 1: Replace the unauthenticated login section (lines 109-116)** + +Find this block in `src/components/Navbar.tsx`: + +```tsx + // eslint-disable-next-line @next/next/no-html-link-for-pages + + {t("startEarning")} + +``` + +Replace with: + +```tsx +
+ {/* Google */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + + + + + + + Google + + {/* GitHub */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + + + + GitHub + +
+``` + +**Step 2: Verify TypeScript** + +```bash +npx tsc --noEmit +``` + +**Step 3: Commit** + +```bash +git add src/components/Navbar.tsx +git commit -m "feat: replace single login button with Google/GitHub OAuth buttons" +``` + +--- + +### Task 10: Delete SecondMe-only API routes + +**Files to delete:** +- `src/app/api/user/info/route.ts` +- `src/app/api/user/shades/route.ts` +- `src/app/api/chat/route.ts` +- `src/app/api/act/route.ts` +- `src/app/api/note/route.ts` +- `src/app/api/sessions/route.ts` + +**Step 1: Delete the files** + +```bash +rm src/app/api/user/info/route.ts +rm src/app/api/user/shades/route.ts +rm src/app/api/chat/route.ts +rm src/app/api/act/route.ts +rm src/app/api/note/route.ts +rm src/app/api/sessions/route.ts +``` + +**Step 2: Check for broken imports** + +```bash +grep -r "api/user/info\|api/user/shades\|api/chat\|api/act\|api/note\|api/sessions" src/ --include="*.ts" --include="*.tsx" +``` + +Expected: no matches (these were self-contained routes, not imported elsewhere). + +**Step 3: Also remove lib/act.ts if only used by deleted routes** + +```bash +grep -r "from.*lib/act\|from.*@/lib/act" src/ --include="*.ts" --include="*.tsx" +``` + +If only `src/app/api/act/route.ts` imported it (now deleted), run: + +```bash +rm src/lib/act.ts +``` + +**Step 4: Verify TypeScript** + +```bash +npx tsc --noEmit +``` + +Expected: no errors. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat: delete SecondMe-dependent API routes (chat, note, act, user/info, user/shades, sessions)" +``` + +--- + +### Task 11: Build verification + +**Step 1: Run full build** + +```bash +npm run build +``` + +Expected: build completes with no TypeScript errors, no missing module errors. + +**Step 2: If errors about SECONDME env vars in other files, find and clean them up** + +```bash +grep -r "SECONDME" src/ --include="*.ts" --include="*.tsx" +``` + +Delete or update any remaining references. + +**Step 3: Final commit if any cleanup was needed** + +```bash +git add -A +git commit -m "chore: clean up remaining SecondMe references" +``` + +--- + +## Supabase Dashboard Checklist (one-time manual setup) + +- [ ] Google provider enabled with Client ID + Secret +- [ ] GitHub provider enabled with Client ID + Secret +- [ ] Redirect URL `{APP_URL}/api/auth/callback` added to allowed list +- [ ] `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` added to `.env.local` and Vercel environment variables diff --git a/messages/en.json b/messages/en.json index bacfc5f..3030340 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8,7 +8,10 @@ "overview": "Overview", "tokenMarket": "Token Recycling Market", "logout": "Logout", - "startEarning": "Start Earning" + "startEarning": "Start Earning", + "login": "Log In", + "loginTitle": "Welcome to MoltMarket", + "loginSubtitle": "Choose a login method" }, "Hero": { "tagline": "Claude tokens reset monthly, unused ones go to waste", diff --git a/messages/zh.json b/messages/zh.json index 28c3fb9..738eeeb 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -8,7 +8,10 @@ "overview": "ไป‹็ป", "tokenMarket": "Token ๅ›žๆ”ถๅธ‚ๅœบ", "logout": "้€€ๅ‡บ", - "startEarning": "ๅผ€ๅง‹่ตšๅ–" + "startEarning": "ๅผ€ๅง‹่ตšๅ–", + "login": "็™ปๅฝ•", + "loginTitle": "ๆฌข่ฟŽๆฅๅˆฐ MoltMarket", + "loginSubtitle": "้€‰ๆ‹ฉ็™ปๅฝ•ๆ–นๅผ" }, "Hero": { "tagline": "Claude token ๆฏๆœˆๆธ…้›ถ๏ผŒๆฒก็”จๅฎŒๅฐฑๆตช่ดน", diff --git a/package-lock.json b/package-lock.json index 1677629..adc3f56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "credit-trader-secondme", + "name": "clawcycle", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "credit-trader-secondme", + "name": "clawcycle", "version": "0.1.0", "hasInstallScript": true, "dependencies": { @@ -16,6 +16,7 @@ "@prisma/adapter-neon": "^7.3.0", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", + "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.95.3", "@types/bcrypt": "^6.0.0", "@types/mdx": "^2.0.13", @@ -2070,6 +2071,18 @@ "node": ">=20.0.0" } }, + "node_modules/@supabase/ssr": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@supabase/ssr/-/ssr-0.8.0.tgz", + "integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.76.1" + } + }, "node_modules/@supabase/storage-js": { "version": "2.98.0", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.98.0.tgz", @@ -3975,6 +3988,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 36e8d11..9ca9990 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "credit-trader-secondme", + "name": "clawcycle", "version": "0.1.0", "private": true, "scripts": { @@ -8,7 +8,11 @@ "start": "next start", "lint": "eslint", "postinstall": "prisma generate", - "prepare": "husky" + "prepare": "husky", + "seed": "dotenv -e .env.local -- ts-node --compiler-options '{\"module\":\"CommonJS\"}' prisma/seed.ts" + }, + "prisma": { + "seed": "npm run seed" }, "dependencies": { "@mdx-js/loader": "^3.1.1", @@ -18,6 +22,7 @@ "@prisma/adapter-neon": "^7.3.0", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", + "@supabase/ssr": "^0.8.0", "@supabase/supabase-js": "^2.95.3", "@types/bcrypt": "^6.0.0", "@types/mdx": "^2.0.13", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f5e362b..6156778 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,13 +9,10 @@ datasource db { model User { id String @id @default(cuid()) - secondmeUserId String @unique @map("secondme_user_id") + supabaseUserId String @unique @map("supabase_user_id") email String? name String? avatarUrl String? @map("avatar_url") - accessToken String @map("access_token") - refreshToken String @map("refresh_token") - tokenExpiresAt DateTime @map("token_expires_at") // Wallet fields balance Decimal @default(100.00) @db.Decimal(10, 2) @@ -32,52 +29,11 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - chatSessions ChatSession[] - notes Note[] agents Agent[] @@map("users") } -model ChatSession { - id String @id @default(cuid()) - secondmeSession String? @map("secondme_session") - title String? - userId String @map("user_id") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - messages ChatMessage[] - - @@map("chat_sessions") -} - -model ChatMessage { - id String @id @default(cuid()) - sessionId String @map("session_id") - role String - content String - createdAt DateTime @default(now()) @map("created_at") - - session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) - - @@map("chat_messages") -} - -model Note { - id String @id @default(cuid()) - userId String @map("user_id") - content String - noteId Int? @map("secondme_note_id") - createdAt DateTime @default(now()) @map("created_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("notes") -} - -// Task table for Agent Labor Market model Task { id String @id @default(cuid()) title String diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..c4412d7 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,283 @@ +import { PrismaClient } from "../src/generated/prisma/client"; +import { Pool } from "pg"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { randomBytes } from "crypto"; +import * as bcrypt from "bcrypt"; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +function makeApiKey() { + const full = `ct_${randomBytes(32).toString("base64url")}`; + return { full, prefix: full.slice(0, 11) }; +} + +function makeClaimCode() { + return randomBytes(4).toString("hex").toUpperCase().slice(0, 8); +} + +function makeVerificationCode() { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +async function main() { + console.log("๐ŸŒฑ Seeding database..."); + + // โ”€โ”€ Users โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [alice, bob, carol] = await Promise.all([ + prisma.user.create({ + data: { + supabaseUserId: "test-supabase-uid-alice-0001", + email: "alice@example.com", + name: "Alice", + avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=alice", + balance: 850, + totalEarned: 1200, + totalSpent: 350, + completedTasks: 12, + }, + }), + prisma.user.create({ + data: { + supabaseUserId: "test-supabase-uid-bob-0002", + email: "bob@example.com", + name: "Bob", + avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=bob", + balance: 420, + totalEarned: 600, + totalSpent: 180, + completedTasks: 5, + }, + }), + prisma.user.create({ + data: { + supabaseUserId: "test-supabase-uid-carol-0003", + email: "carol@example.com", + name: "Carol", + avatarUrl: "https://api.dicebear.com/7.x/avataaars/svg?seed=carol", + balance: 100, + totalEarned: 0, + totalSpent: 0, + completedTasks: 0, + }, + }), + ]); + console.log("โœ… Users created:", alice.name, bob.name, carol.name); + + // โ”€โ”€ Agents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + async function createAgent( + name: string, + userId: string | null, + status: string, + overrides: Record = {} + ) { + const { full, prefix } = makeApiKey(); + const hash = await bcrypt.hash(full, 10); + return prisma.agent.create({ + data: { + name, + apiKey: prefix, + apiKeyHash: hash, + claimCode: makeClaimCode(), + verificationCode: makeVerificationCode(), + userId, + status, + claimedAt: userId ? new Date() : null, + credits: 100, + ...overrides, + }, + }); + } + + const [aliceAgent, aliceAgent2, bobAgent, unclaimedA, unclaimedB] = + await Promise.all([ + createAgent("Alice-Coder", alice.id, "active", { + credits: 340, + totalEarned: 500, + totalSpent: 160, + tasksPublished: 8, + tasksCompleted: 10, + tokensSaved: 24000, + tokensContributed: 18000, + reputation: 42, + }), + createAgent("Alice-Researcher", alice.id, "active", { + credits: 180, + totalEarned: 200, + totalSpent: 20, + tasksPublished: 3, + tasksCompleted: 2, + tokensSaved: 8000, + tokensContributed: 5000, + reputation: 18, + }), + createAgent("Bob-Analyst", bob.id, "active", { + credits: 220, + totalEarned: 300, + totalSpent: 80, + tasksPublished: 5, + tasksCompleted: 5, + tokensSaved: 12000, + tokensContributed: 9000, + reputation: 25, + }), + createAgent("Wanderer-7", null, "unclaimed", {}), + createAgent("Scout-Alpha", null, "unclaimed", {}), + ]); + console.log("โœ… Agents created (3 claimed, 2 unclaimed)"); + + // โ”€โ”€ Tasks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const tasksData = [ + // completed + { + title: "Summarize quarterly earnings report", + description: "Parse the Q3 PDF and produce a 3-bullet executive summary.", + estimatedTokens: 4000, + estimatedCredits: 40, + priority: "high", + status: "completed", + publisherAgentId: aliceAgent.id, + workerAgentId: bobAgent.id, + result: "Revenue up 12 %, operating margin 18 %, headcount stable.", + actualTokens: 3800, + rating: 5, + acceptedAt: new Date(Date.now() - 1000 * 60 * 60 * 3), + completedAt: new Date(Date.now() - 1000 * 60 * 60 * 2), + }, + { + title: "Translate README to Chinese", + description: "Translate the project README.md from English to Simplified Chinese.", + estimatedTokens: 2000, + estimatedCredits: 20, + priority: "medium", + status: "completed", + publisherAgentId: bobAgent.id, + workerAgentId: aliceAgent.id, + result: "Translation complete. 98 % confidence score.", + actualTokens: 1950, + rating: 4, + acceptedAt: new Date(Date.now() - 1000 * 60 * 60 * 10), + completedAt: new Date(Date.now() - 1000 * 60 * 60 * 9), + }, + // executing + { + title: "Generate unit tests for auth module", + description: "Write Jest tests covering all branches in src/lib/auth.ts.", + estimatedTokens: 6000, + estimatedCredits: 60, + priority: "high", + status: "executing", + publisherAgentId: aliceAgent2.id, + workerAgentId: bobAgent.id, + acceptedAt: new Date(Date.now() - 1000 * 60 * 30), + }, + // accepted + { + title: "Review PR #42 for security issues", + description: "Check the OAuth callback changes for CSRF or token-leak vulnerabilities.", + estimatedTokens: 3000, + estimatedCredits: 30, + priority: "high", + status: "accepted", + publisherAgentId: aliceAgent.id, + workerAgentId: aliceAgent2.id, + acceptedAt: new Date(Date.now() - 1000 * 60 * 10), + }, + // pending + { + title: "Create onboarding email copy", + description: "Write 3 onboarding email templates (welcome, day-3, day-7) in a friendly tone.", + estimatedTokens: 2500, + estimatedCredits: 25, + priority: "medium", + status: "pending", + publisherAgentId: bobAgent.id, + }, + { + title: "Analyze competitor pricing page", + description: "Screenshot and summarize pricing tiers from top 5 competitors.", + estimatedTokens: 5000, + estimatedCredits: 50, + priority: "low", + status: "pending", + publisherAgentId: aliceAgent.id, + }, + { + title: "Debug slow API response on /api/stats", + description: "Profile the endpoint and identify the N+1 query causing latency.", + estimatedTokens: 4500, + estimatedCredits: 45, + priority: "high", + status: "pending", + publisherAgentId: aliceAgent2.id, + }, + ]; + + const tasks = await Promise.all( + tasksData.map((t) => prisma.task.create({ data: t as Parameters[0]["data"] })) + ); + console.log(`โœ… Tasks created: ${tasks.length} (2 completed, 1 executing, 1 accepted, 3 pending)`); + + // โ”€โ”€ Activity Feed โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [completedTask1, completedTask2] = tasks; + + await Promise.all([ + prisma.activityFeed.create({ + data: { + eventType: "task_completed", + agentId: bobAgent.id, + taskId: completedTask1.id, + title: "Bob-Analyst completed a task", + description: "Summarized quarterly earnings report for Alice-Coder.", + metadata: { creditsEarned: 40, tokensUsed: 3800 }, + }, + }), + prisma.activityFeed.create({ + data: { + eventType: "task_completed", + agentId: aliceAgent.id, + taskId: completedTask2.id, + title: "Alice-Coder completed a task", + description: "Translated README to Chinese for Bob-Analyst.", + metadata: { creditsEarned: 20, tokensUsed: 1950 }, + }, + }), + prisma.activityFeed.create({ + data: { + eventType: "agent_claimed", + agentId: aliceAgent.id, + title: "Alice-Coder was claimed", + description: "Agent Alice-Coder has been successfully claimed and linked to your account.", + metadata: { userId: alice.id }, + }, + }), + prisma.activityFeed.create({ + data: { + eventType: "task_published", + agentId: aliceAgent.id, + taskId: tasks[5].id, + title: "Alice-Coder published a new task", + description: "Analyze competitor pricing page", + metadata: { estimatedCredits: 50 }, + }, + }), + ]); + console.log("โœ… Activity feed created"); + + console.log("\n๐ŸŽ‰ Seed complete!"); + console.log(` Users: 3 (alice / bob / carol)`); + console.log(` Agents: 5 (3 active, 2 unclaimed)`); + console.log(` Tasks: ${tasks.length} (2 completed, 1 executing, 1 accepted, 3 pending)`); + console.log(` Feed: 4 entries`); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + await pool.end(); + }); diff --git a/src/app/[locale]/claim/ClaimClient.tsx b/src/app/[locale]/claim/ClaimClient.tsx deleted file mode 100644 index 8bb1e98..0000000 --- a/src/app/[locale]/claim/ClaimClient.tsx +++ /dev/null @@ -1,175 +0,0 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -import { useEffect, useState, useMemo } from "react"; -import { useTranslations } from "next-intl"; - -import { Link } from "@/i18n/routing"; - -interface AgentInfo { - id: string; - name: string; - createdAt: string; -} - -export default function ClaimClient() { - const t = useTranslations("Claim"); - const searchParams = useSearchParams(); - const token = searchParams.get("token"); - const [agent, setAgent] = useState(null); - const [loading, setLoading] = useState(() => !!token); - const [error, setError] = useState(() => token ? null : t("missingToken")); - const [claiming, setClaiming] = useState(false); - - useEffect(() => { - if (!token) { - return; - } - - // Fetch agent info by claim token - fetch(`/api/agents/claim?token=${encodeURIComponent(token)}`) - .then((res) => { - if (!res.ok) throw new Error(t("invalidToken")); - return res.json(); - }) - .then((data) => setAgent(data)) - .catch((err) => setError(err.message)) - .finally(() => setLoading(false)); - }, [token, t]); - - function handleClaim() { - // Redirect to OAuth login with claim token in state - setClaiming(true); - const loginUrl = `/api/auth/login?claim_token=${encodeURIComponent(token || "")}`; - window.location.href = loginUrl; - } - - const [currentTime, setCurrentTime] = useState(() => Date.now()); - - useEffect(() => { - const timer = setInterval(() => setCurrentTime(Date.now()), 60000); - return () => clearInterval(timer); - }, []); - - const timeAgo = useMemo(() => { - if (!agent) return ""; - const diff = currentTime - new Date(agent.createdAt).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; - }, [agent, currentTime]); - - if (loading) { - return ( -
-
-
- ); - } - - if (error || !agent) { - return ( -
-
- โŒ -

- {t("invalidTitle")} -

-

- {error || t("invalidDescription")} -

- - {t("backHome")} - -
-
- ); - } - - - return ( -
-
- {/* Card Header */} -
- - {t("brandTitle")} - - ๐ŸŽ‰ -

- {t("claimOpenClaw")} -

-

- {t("completeAuth")} -

-
- -
- - {/* Agent Info */} -
- - {t("agentInfo")} - -
- - {t("nameLabel")} - - - {agent.name} - -
-
- - {t("registeredLabel")} - - - {timeAgo} - -
-
- - {t("idLabel")} - - - {agent.id.slice(0, 8)}...{agent.id.slice(-4)} - -
-
- -
- - {/* Auth Section */} -
- - -
- - {t("secureOAuth")} - - - {t("basicProfile")} - - - {t("revokeAccess")} - -
-
-
-
- ); -} diff --git a/src/app/[locale]/claim/page.tsx b/src/app/[locale]/claim/page.tsx deleted file mode 100644 index 411a674..0000000 --- a/src/app/[locale]/claim/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Suspense } from "react"; -import ClaimClient from "./ClaimClient"; - -export default function ClaimPage() { - return ( - -
-
- } - > - -
- ); -} diff --git a/src/app/api/act/route.ts b/src/app/api/act/route.ts deleted file mode 100644 index 7891e38..0000000 --- a/src/app/api/act/route.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getSessionUserId, getValidAccessToken } from "@/lib/auth"; - -export async function POST(request: NextRequest) { - const userId = await getSessionUserId(); - if (!userId) { - return NextResponse.json({ error: "ๆœช็™ปๅฝ•" }, { status: 401 }); - } - - const accessToken = await getValidAccessToken(userId); - if (!accessToken) { - return NextResponse.json({ error: "Token ๅทฒ่ฟ‡ๆœŸ" }, { status: 401 }); - } - - const body = await request.json(); - const { message, actionControl, sessionId, systemPrompt } = body; - - if (!message || !actionControl) { - return NextResponse.json( - { error: "message ๅ’Œ actionControl ไธบๅฟ…ๅกซๅ‚ๆ•ฐ" }, - { status: 400 } - ); - } - - const res = await fetch( - `${process.env.SECONDME_API_BASE_URL}/api/secondme/act/stream`, - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - message, - actionControl, - sessionId, - systemPrompt, - }), - } - ); - - if (!res.ok) { - const errText = await res.text(); - return NextResponse.json( - { error: "Act ่ฏทๆฑ‚ๅคฑ่ดฅ", detail: errText }, - { status: res.status } - ); - } - - // ่ฝฌๅ‘ SSE ๆต - return new NextResponse(res.body, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); -} diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index 6c630ca..72205d8 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -1,90 +1,40 @@ +// src/app/api/auth/callback/route.ts import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { createSupabaseServerClient } from "@/lib/supabase"; export async function GET(request: NextRequest) { const code = request.nextUrl.searchParams.get("code"); - const stateParam = request.nextUrl.searchParams.get("state"); + const claimCode = request.nextUrl.searchParams.get("claimCode"); if (!code) { return NextResponse.redirect(new URL("/?error=no_code", request.url)); } - // Parse state to extract claimCode if present - let claimCode: string | null = null; - if (stateParam) { - try { - const state = JSON.parse(stateParam); - claimCode = state.claimCode || null; - } catch { - // Invalid state, ignore - } - } - try { - // ็”จ authorization_code ๆขๅ– access_token๏ผˆๅฟ…้กป็”จ x-www-form-urlencoded๏ผ‰ - const params = new URLSearchParams({ - grant_type: "authorization_code", - client_id: process.env.SECONDME_CLIENT_ID!, - client_secret: process.env.SECONDME_CLIENT_SECRET!, - code, - redirect_uri: process.env.SECONDME_REDIRECT_URI!, - }); - - const tokenRes = await fetch(process.env.SECONDME_TOKEN_ENDPOINT!, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - if (!tokenRes.ok) { - const errText = await tokenRes.text(); - console.error("Token exchange failed:", errText); - return NextResponse.redirect(new URL("/?error=token_failed", request.url)); - } - - const tokenResult = await tokenRes.json(); - const tokenData = tokenResult.data ?? tokenResult; - - // ๅ…ผๅฎน camelCase ๅ’Œ snake_case ไธค็งๅ“ๅบ”ๆ ผๅผ - const accessToken = tokenData.accessToken ?? tokenData.access_token; - const refreshToken = tokenData.refreshToken ?? tokenData.refresh_token; - const expiresIn = tokenData.expiresIn ?? tokenData.expires_in ?? 7200; - - // ่Žทๅ–็”จๆˆทไฟกๆฏ - const userRes = await fetch( - `${process.env.SECONDME_API_BASE_URL}/api/secondme/user/info`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); - - if (!userRes.ok) { - return NextResponse.redirect(new URL("/?error=user_info_failed", request.url)); + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase.auth.exchangeCodeForSession(code); + + if (error || !data.user) { + console.error("Session exchange failed:", error); + return NextResponse.redirect( + new URL("/?error=token_failed", request.url) + ); } - const userResult = await userRes.json(); - const userData = userResult.data; + const supabaseUser = data.user; + const email = supabaseUser.email ?? null; + const name = + supabaseUser.user_metadata?.full_name ?? + supabaseUser.user_metadata?.name ?? + email ?? + null; + const avatarUrl = supabaseUser.user_metadata?.avatar_url ?? null; - // ๅˆ›ๅปบๆˆ–ๆ›ดๆ–ฐ็”จๆˆท const user = await prisma.user.upsert({ - where: { secondmeUserId: userData.route ?? userData.id ?? userData.email }, - create: { - secondmeUserId: userData.route ?? userData.id ?? userData.email, - email: userData.email, - name: userData.name, - avatarUrl: userData.avatarUrl, - accessToken, - refreshToken, - tokenExpiresAt: new Date(Date.now() + expiresIn * 1000), - }, - update: { - email: userData.email, - name: userData.name, - avatarUrl: userData.avatarUrl, - accessToken, - refreshToken, - tokenExpiresAt: new Date(Date.now() + expiresIn * 1000), - }, + where: { supabaseUserId: supabaseUser.id }, + create: { supabaseUserId: supabaseUser.id, email, name, avatarUrl }, + update: { email, name, avatarUrl }, }); // ๆŠŠ session cookie ็›ดๆŽฅ่ฎพๅˆฐ redirect ๅ“ๅบ”ไธŠ๏ผˆVercel serverless ็Žฏๅขƒไธ‹ cookies().set ไธŽ NextResponse.redirect ็š„ cookie ไธไผšๅˆๅนถ๏ผ‰ diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 18e1576..598c001 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,25 +1,38 @@ +// src/app/api/auth/login/route.ts import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseServerClient } from "@/lib/supabase"; export async function GET(request: NextRequest) { - const clientId = process.env.SECONDME_CLIENT_ID!; - const redirectUri = process.env.SECONDME_REDIRECT_URI!; - const oauthUrl = process.env.SECONDME_OAUTH_URL!; - - // Get optional claimCode from query parameters + const provider = request.nextUrl.searchParams.get("provider") as + | "google" + | "github" + | null; const claimCode = request.nextUrl.searchParams.get("claimCode"); - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - response_type: "code", - scope: "user.info user.info.shades user.info.softmemory chat note.add", - }); + if (!provider || !["google", "github"].includes(provider)) { + return NextResponse.redirect( + new URL("/?error=invalid_provider", request.url) + ); + } + + const supabase = await createSupabaseServerClient(); - // Include claimCode in state if present + const callbackUrl = new URL("/api/auth/callback", request.url); + // Don't lose the claimcode if (claimCode) { - const state = JSON.stringify({ claimCode }); - params.set("state", state); + callbackUrl.searchParams.set("claimCode", claimCode); + } + + const { data, error } = await supabase.auth.signInWithOAuth({ + provider, + options: { redirectTo: callbackUrl.toString() }, + }); + + if (error || !data.url) { + return NextResponse.redirect( + new URL("/?error=oauth_failed", request.url) + ); } - return NextResponse.redirect(`${oauthUrl}?${params.toString()}`); + return NextResponse.redirect(data.url); } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index eb5aeff..a37a284 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,7 +1,11 @@ +// src/app/api/auth/logout/route.ts import { NextResponse } from "next/server"; import { clearSession } from "@/lib/auth"; +import { createSupabaseServerClient } from "@/lib/supabase"; export async function GET(request: Request) { + const supabase = await createSupabaseServerClient(); + await supabase.auth.signOut(); await clearSession(); return NextResponse.redirect(new URL("/", request.url)); } diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts deleted file mode 100644 index 3881e65..0000000 --- a/src/app/api/chat/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getSessionUserId, getValidAccessToken } from "@/lib/auth"; - -export async function POST(request: NextRequest) { - const userId = await getSessionUserId(); - if (!userId) { - return NextResponse.json({ error: "ๆœช็™ปๅฝ•" }, { status: 401 }); - } - - const accessToken = await getValidAccessToken(userId); - if (!accessToken) { - return NextResponse.json({ error: "Token ๅทฒ่ฟ‡ๆœŸ" }, { status: 401 }); - } - - const body = await request.json(); - const { message, sessionId, systemPrompt } = body; - - const res = await fetch( - `${process.env.SECONDME_API_BASE_URL}/api/secondme/chat/stream`, - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - message, - sessionId, - systemPrompt, - }), - } - ); - - if (!res.ok) { - const errText = await res.text(); - return NextResponse.json( - { error: "่Šๅคฉ่ฏทๆฑ‚ๅคฑ่ดฅ", detail: errText }, - { status: res.status } - ); - } - - // ่ฝฌๅ‘ SSE ๆต - return new NextResponse(res.body, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); -} diff --git a/src/app/api/note/route.ts b/src/app/api/note/route.ts deleted file mode 100644 index 29d9999..0000000 --- a/src/app/api/note/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getSessionUserId, getValidAccessToken } from "@/lib/auth"; - -export async function POST(request: NextRequest) { - const userId = await getSessionUserId(); - if (!userId) { - return NextResponse.json({ error: "ๆœช็™ปๅฝ•" }, { status: 401 }); - } - - const accessToken = await getValidAccessToken(userId); - if (!accessToken) { - return NextResponse.json({ error: "Token ๅทฒ่ฟ‡ๆœŸ" }, { status: 401 }); - } - - const body = await request.json(); - const { content } = body; - - if (!content) { - return NextResponse.json({ error: "็ฌ”่ฎฐๅ†…ๅฎนไธ่ƒฝไธบ็ฉบ" }, { status: 400 }); - } - - const res = await fetch( - `${process.env.SECONDME_API_BASE_URL}/api/secondme/note/add`, - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ content }), - } - ); - - if (!res.ok) { - const errText = await res.text(); - return NextResponse.json( - { error: "ๆทปๅŠ ็ฌ”่ฎฐๅคฑ่ดฅ", detail: errText }, - { status: res.status } - ); - } - - const result = await res.json(); - return NextResponse.json(result); -} diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts deleted file mode 100644 index 3380dd2..0000000 --- a/src/app/api/sessions/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSessionUserId, getValidAccessToken } from "@/lib/auth"; - -export async function GET() { - const userId = await getSessionUserId(); - if (!userId) { - return NextResponse.json({ error: "ๆœช็™ปๅฝ•" }, { status: 401 }); - } - - const accessToken = await getValidAccessToken(userId); - if (!accessToken) { - return NextResponse.json({ error: "Token ๅทฒ่ฟ‡ๆœŸ" }, { status: 401 }); - } - - const res = await fetch( - `${process.env.SECONDME_API_BASE_URL}/api/secondme/chat/session/list`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); - - if (!res.ok) { - return NextResponse.json({ error: "่Žทๅ–ไผš่ฏๅˆ—่กจๅคฑ่ดฅ" }, { status: res.status }); - } - - const result = await res.json(); - return NextResponse.json(result); -} diff --git a/src/app/api/user/info/route.ts b/src/app/api/user/info/route.ts deleted file mode 100644 index e04b69d..0000000 --- a/src/app/api/user/info/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSessionUserId, getValidAccessToken } from "@/lib/auth"; - -export async function GET() { - const userId = await getSessionUserId(); - if (!userId) { - return NextResponse.json({ error: "ๆœช็™ปๅฝ•" }, { status: 401 }); - } - - const accessToken = await getValidAccessToken(userId); - if (!accessToken) { - return NextResponse.json({ error: "Token ๅทฒ่ฟ‡ๆœŸ" }, { status: 401 }); - } - - const res = await fetch( - `${process.env.SECONDME_API_BASE_URL}/api/secondme/user/info`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); - - if (!res.ok) { - return NextResponse.json({ error: "่Žทๅ–็”จๆˆทไฟกๆฏๅคฑ่ดฅ" }, { status: res.status }); - } - - const result = await res.json(); - return NextResponse.json(result); -} diff --git a/src/app/api/user/shades/route.ts b/src/app/api/user/shades/route.ts deleted file mode 100644 index 3178bc3..0000000 --- a/src/app/api/user/shades/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from "next/server"; -import { getSessionUserId, getValidAccessToken } from "@/lib/auth"; - -export async function GET() { - const userId = await getSessionUserId(); - if (!userId) { - return NextResponse.json({ error: "ๆœช็™ปๅฝ•" }, { status: 401 }); - } - - const accessToken = await getValidAccessToken(userId); - if (!accessToken) { - return NextResponse.json({ error: "Token ๅทฒ่ฟ‡ๆœŸ" }, { status: 401 }); - } - - const res = await fetch( - `${process.env.SECONDME_API_BASE_URL}/api/secondme/user/shades`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - } - ); - - if (!res.ok) { - return NextResponse.json({ error: "่Žทๅ–ๅ…ด่ถฃๆ ‡็ญพๅคฑ่ดฅ" }, { status: res.status }); - } - - const result = await res.json(); - return NextResponse.json(result); -} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index cae71cc..db3f84f 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,8 +1,10 @@ "use client"; +import { useState } from "react"; import { useTranslations } from "next-intl"; import { Link, usePathname, useRouter } from "@/i18n/routing"; import { useLocale } from "next-intl"; +import { GoogleIcon, GitHubIcon } from "@/components/icons"; export default function Navbar({ userName, @@ -15,6 +17,7 @@ export default function Navbar({ const locale = useLocale(); const router = useRouter(); const pathname = usePathname(); + const [loginOpen, setLoginOpen] = useState(false); const navLinks = [ { href: "/" as const, label: t("home") }, @@ -27,6 +30,7 @@ export default function Navbar({ } return ( + <>
) : ( - // eslint-disable-next-line @next/next/no-html-link-for-pages - setLoginOpen(true)} + className="font-inter text-[15px] font-semibold text-white rounded-lg px-4 py-2 bg-[var(--accent)] hover:opacity-90 transition-opacity cursor-pointer" > - {t("startEarning")} - + {t("login")} + )}
+ + {loginOpen && ( + <> + {/* Overlay */} +
setLoginOpen(false)} + /> + {/* Modal card */} +
+
+ {/* Header */} +
+
+

+ {t("loginTitle")} +

+

+ {t("loginSubtitle")} +

+
+ +
+ + {/* Login options */} +
+ {/* Google */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + + Continue with Google + + + {/* GitHub */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} + + + Continue with GitHub + +
+
+
+ + )} + ); } diff --git a/src/components/icons.tsx b/src/components/icons.tsx new file mode 100644 index 0000000..258f5e3 --- /dev/null +++ b/src/components/icons.tsx @@ -0,0 +1,18 @@ +export function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +export function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/src/lib/act.ts b/src/lib/act.ts deleted file mode 100644 index c9fd45d..0000000 --- a/src/lib/act.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Act API ๅทฅๅ…ทๅ‡ฝๆ•ฐ - ๅ‘้€ actionControl ๅนถ่งฃๆž SSE JSON ็ป“ๆžœ - */ -export async function callActStream( - accessToken: string, - message: string, - actionControl: string, - options?: { - sessionId?: string; - systemPrompt?: string; - } -): Promise<{ sessionId: string; result: unknown }> { - const baseUrl = process.env.SECONDME_API_BASE_URL!; - - const res = await fetch(`${baseUrl}/api/secondme/act/stream`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - message, - actionControl, - sessionId: options?.sessionId, - systemPrompt: options?.systemPrompt, - }), - }); - - if (!res.ok) { - throw new Error(`Act API error: ${res.status}`); - } - - const text = await res.text(); - const lines = text.split("\n"); - - let sessionId = ""; - let content = ""; - - for (const line of lines) { - if (line.startsWith("event: session")) { - const nextLine = lines[lines.indexOf(line) + 1]; - if (nextLine?.startsWith("data: ")) { - const sessionData = JSON.parse(nextLine.slice(6)); - sessionId = sessionData.sessionId; - } - } else if (line.startsWith("data: ") && !line.includes("[DONE]")) { - try { - const data = JSON.parse(line.slice(6)); - const delta = data?.choices?.[0]?.delta?.content; - if (delta) content += delta; - } catch { - // skip non-JSON lines - } - } - } - - let result: unknown; - try { - result = JSON.parse(content); - } catch { - result = content; - } - - return { sessionId, result }; -} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e391356..4e114ce 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,3 +1,4 @@ +// src/lib/auth.ts import { cookies } from "next/headers"; import { prisma } from "./prisma"; @@ -27,62 +28,5 @@ export async function clearSession() { export async function getCurrentUser() { const userId = await getSessionUserId(); if (!userId) return null; - - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - - return user; -} - -export async function getValidAccessToken(userId: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - - if (!user) return null; - - // Token ไป็„ถๆœ‰ๆ•ˆ - if (user.tokenExpiresAt > new Date()) { - return user.accessToken; - } - - // ๅฐ่ฏ•ๅˆทๆ–ฐ Token - try { - const params = new URLSearchParams({ - grant_type: "refresh_token", - client_id: process.env.SECONDME_CLIENT_ID!, - client_secret: process.env.SECONDME_CLIENT_SECRET!, - refresh_token: user.refreshToken, - }); - - const refreshEndpoint = process.env.SECONDME_API_BASE_URL + "/api/oauth/token/refresh"; - const res = await fetch(refreshEndpoint, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: params.toString(), - }); - - if (!res.ok) return null; - - const data = await res.json(); - const tokenData = data.data ?? data; - - const newAccessToken = tokenData.accessToken ?? tokenData.access_token; - const newRefreshToken = tokenData.refreshToken ?? tokenData.refresh_token ?? user.refreshToken; - const expiresIn = tokenData.expiresIn ?? tokenData.expires_in ?? 7200; - - await prisma.user.update({ - where: { id: userId }, - data: { - accessToken: newAccessToken, - refreshToken: newRefreshToken, - tokenExpiresAt: new Date(Date.now() + expiresIn * 1000), - }, - }); - - return newAccessToken; - } catch { - return null; - } + return prisma.user.findUnique({ where: { id: userId } }); } diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..6289047 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,64 @@ +// src/lib/supabase.ts +import { createServerClient } from "@supabase/ssr"; +import { createBrowserClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import type { NextRequest, NextResponse } from "next/server"; + +/** Client components */ +export function createSupabaseBrowserClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} + +/** Server components and API route handlers */ +export async function createSupabaseServerClient() { + const cookieStore = await cookies(); + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch { + // Called from a Server Component โ€“ read-only, ignore + } + }, + }, + } + ); +} + +/** Middleware โ€“ takes both request and mutable response */ +export function createSupabaseMiddlewareClient( + request: NextRequest, + response: NextResponse +) { + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ); + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options) + ); + }, + }, + } + ); +} diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 3fae024..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,14 +0,0 @@ -import createMiddleware from "next-intl/middleware"; -import { routing } from "./i18n/routing"; - -export default createMiddleware(routing); - -export const config = { - // Match all pathnames except for - // - /api (API routes) - // - /_next (Next.js internals) - // - /_vercel (Vercel internals) - // - /favicon.ico, /sitemap.xml, /robots.txt (static files) - // - files with extensions (e.g. .js, .css, .png) - matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], -}; diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..db1c9e5 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,64 @@ +// src/proxy.ts +// server behavior +import { type NextRequest, NextResponse } from "next/server"; +import createIntlMiddleware from "next-intl/middleware"; +import { createServerClient } from "@supabase/ssr"; +import { routing } from "./i18n/routing"; + +const intlMiddleware = createIntlMiddleware(routing); + +export async function proxy(request: NextRequest) { + // 1. Create a base response so Supabase can write refreshed session cookies + let supabaseResponse = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + // Write new cookie values into the request for downstream handlers + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value) + ); + // Rebuild supabaseResponse with updated request + supabaseResponse = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ); + }, + }, + } + ); + + // Refresh session if expired โ€” must not run any logic before this. + // getUser() internally checks whether the access_token has expired. + // If so, it uses the refresh_token from the cookie to obtain a new + // access_token / refresh_token pair from Supabase, then writes the + // new tokens back to supabaseResponse via the setAll callback above. + // Token values are never exposed to application code. + await supabase.auth.getUser(); + + // 2. Run next-intl middleware on the (potentially cookie-updated) request + const intlResponse = intlMiddleware(request); + + // 3. Copy any Supabase session cookies onto the intl response + supabaseResponse.cookies.getAll().forEach((cookie) => { + intlResponse.cookies.set(cookie.name, cookie.value, { path: "/" }); + }); + + return intlResponse; +} + +export const config = { + // Match all pathnames except for + // - /api (API routes) + // - /_next (Next.js internals) + // - /_vercel (Vercel internals) + // - /favicon.ico, /sitemap.xml, /robots.txt (static files) + // - files with extensions (e.g. .js, .css, .png) + matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], +};