diff --git a/.env b/.env index b454ca74..0ae69d85 100644 --- a/.env +++ b/.env @@ -1 +1,7 @@ +AUTH_DISABLED_FOR_DEV=false DATABASE_URL="postgresql://user:password@host:port/db" +SERVER_ACTIONS_ALLOWED_ORIGINS=* +STANDARD_TIER_BILLING_CYCLE="yearly" +STANDARD_TIER_CREDITS=8000 +STANDARD_TIER_MONTHLY_PRICE=41 +STANDARD_TIER_PRICE_ID="price_standard_41_yearly" diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..dca6ed41 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +STANDARD_TIER_BILLING_CYCLE="yearly" +STANDARD_TIER_CREDITS=8000 +STANDARD_TIER_MONTHLY_PRICE=41 +STANDARD_TIER_PRICE_ID="price_placeholder" diff --git a/.gitignore b/.gitignore index 36a24b98..6ef8344c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,59 +1,44 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build +# Dependency directories +node_modules/ +.bun/ + +# Build outputs +.next/ +dist/ +build/ +out/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.*.local -# misc +# IDE/Editor +.vscode/ +.idea/ +*.swp +*.swo .DS_Store -*.pem -# debug +# Logs npm-debug.log* yarn-debug.log* yarn-error.log* -# local env files -.env*.local +# Lock files +bun.lockb -# log files -dev_server.log -server.log +# Testing +playwright-report/ +test-results/ +coverage/ -# vercel -.vercel +# Supabase local CLI state +supabase/.temp/ -# typescript +# Misc +.vercel/ *.tsbuildinfo -next-env.d.ts - -# Playwright -/playwright-report/ -/test-results/ -/dev.log -# AlphaEarth Embeddings - Sensitive Files -# Add these lines to your main .gitignore - -# GCP Service Account Credentials (NEVER commit) -gcp_credentials.json -**/gcp_credentials.json - -# AlphaEarth Index File (large, should be downloaded separately) -aef_index.csv - -# Environment variables with GCP credentials -.env.local -.env.production.local -*.log diff --git a/.vscode/settings.json b/.vscode/settings.json index 89d1965f..6b736623 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode" -} \ No newline at end of file +} diff --git a/FIXES_SUMMARY.md b/FIXES_SUMMARY.md new file mode 100644 index 00000000..bc1f623b --- /dev/null +++ b/FIXES_SUMMARY.md @@ -0,0 +1,74 @@ +# Auth Backend Schema Fixes - PR #327 + +## Summary of Changes + +This commit addresses critical security vulnerabilities and auth backend schema issues identified in the CodeRabbit review. + +## Critical Security Fixes + +### 1. ✅ Deleted RLS Disable Migration +**File:** `supabase/migrations/0002_disable_rls_for_testing.sql` (DELETED) +- **Issue:** This migration disabled Row Level Security on all tables, creating a critical security vulnerability +- **Risk:** Anyone could read, modify, or delete ANY user's chats, messages, and participants +- **Fix:** Completely removed this migration file to ensure RLS remains enabled in production + +### 2. ✅ Added pgcrypto Extension +**File:** `supabase/migrations/0000_init.sql` +- **Issue:** Used `gen_random_uuid()` without enabling the pgcrypto extension +- **Risk:** Migration would fail on typical Supabase setups +- **Fix:** Added `CREATE EXTENSION IF NOT EXISTS "pgcrypto";` at the start of the migration + +### 3. ✅ Fixed User Lookup in Collaboration +**File:** `lib/actions/collaboration.ts` +- **Issue:** Queried non-existent `public.users` table instead of `auth.users` +- **Risk:** User invitation flow always failed +- **Fix:** Updated `inviteUserToChat()` to use `auth.admin.listUsers()` via the service client to properly look up users by email + +### 4. ✅ Added Auth Check to RAG Function +**File:** `lib/actions/rag.ts` +- **Issue:** `retrieveContext()` had no authentication check +- **Risk:** Unauthorized users could access message embeddings +- **Fix:** Added authentication validation at the start of the function using `getCurrentUserIdOnServer()` + +### 5. ✅ Added Environment Validation +**File:** `lib/supabase/client.ts` +- **Issue:** Service client creation didn't validate required environment variables +- **Risk:** Service client could fail silently, bypassing RLS checks +- **Fix:** Added proper validation with descriptive error messages for missing `NEXT_PUBLIC_SUPABASE_URL` or `SUPABASE_SERVICE_ROLE_KEY` + +### 6. ✅ Improved INSERT Policy Security +**File:** `supabase/migrations/0002_add_insert_policy_for_chats.sql` +- **Issue:** Policy allowed any authenticated user to insert chats with any user_id +- **Risk:** Users could create chats impersonating other users +- **Fix:** Updated policy to enforce `auth.uid() = user_id`, ensuring users can only create chats where they are the owner + +## Files Modified + +1. `lib/actions/collaboration.ts` - Fixed user lookup to use auth.admin API +2. `lib/actions/rag.ts` - Added authentication check +3. `lib/supabase/client.ts` - Added environment variable validation +4. `supabase/migrations/0000_init.sql` - Added pgcrypto extension +5. `supabase/migrations/0002_add_insert_policy_for_chats.sql` - Improved security policy +6. `supabase/migrations/0002_disable_rls_for_testing.sql` - DELETED (critical security issue) + +## Security Improvements + +- ✅ RLS remains enabled on all tables +- ✅ All server actions now validate authentication +- ✅ User lookup uses proper Supabase auth APIs +- ✅ Environment variables are validated before use +- ✅ INSERT policies enforce proper ownership +- ✅ Database migrations will run successfully on standard Supabase setups + +## Testing Recommendations + +1. Verify RLS policies are active: Check Supabase dashboard +2. Test user invitation flow: Ensure users can be invited by email +3. Test RAG context retrieval: Verify auth check prevents unauthorized access +4. Test chat creation: Ensure users can only create chats as themselves +5. Run migrations on a test Supabase project to verify they execute without errors + +## Related Issues + +Addresses CodeRabbit review comments: +- [CodeRabbit Review Comment](https://github.com/QueueLab/QCX/pull/327#issuecomment-3714336689) diff --git a/README.md b/README.md index d090ea1f..3eb5e187 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ -[**Pricing**](https://buy.stripe.com/14A3cv7K72TR3go14Nasg02)  |  [**Land**](https://wwww.queue.cx)  |  [**X**](https://x.com/tryqcx) +[**Pricing**]  |  [**Land**](https://wwww.queue.cx)  |  [**X**](https://x.com/tryqcx) QCX - Artificial General Intelligence. | Product Hunt diff --git a/app/actions.tsx b/app/actions.tsx index 9840ce04..929644ba 100644 --- a/app/actions.tsx +++ b/app/actions.tsx @@ -17,6 +17,8 @@ import { inquire, researcher, taskManager, querySuggestor, resolutionSearch } fr // The geospatialTool (if used by agents like researcher) now manages its own MCP client. import { writer } from '@/lib/agents/writer' import { saveChat, getSystemPrompt } from '@/lib/actions/chat' // Added getSystemPrompt +import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user' +import { retrieveContext } from '@/lib/actions/rag' import { Chat, AIMessage } from '@/lib/types' import { UserMessage } from '@/components/user-message' import { BotMessage } from '@/components/message' @@ -75,7 +77,7 @@ async function submit(formData?: FormData, skip?: boolean) { ...aiState.get(), messages: [ ...aiState.get().messages, - { id: nanoid(), role: 'user', content } + { id: nanoid(), role: 'user', content: JSON.stringify(content) } ] }); messages.push({ role: 'user', content }); @@ -312,7 +314,7 @@ async function submit(formData?: FormData, skip?: boolean) { { id: nanoid(), role: 'user', - content, + content: typeof content === 'string' ? content : JSON.stringify(content), type } ] @@ -323,9 +325,18 @@ async function submit(formData?: FormData, skip?: boolean) { } as CoreMessage) } - const userId = 'anonymous' + const userId = await getCurrentUserIdOnServer() + if (!userId) { + throw new Error('Unauthorized') + } const currentSystemPrompt = (await getSystemPrompt(userId)) || '' + const retrievedContext = userInput + ? await retrieveContext(userInput, aiState.get().chatId) + : [] + const augmentedSystemPrompt = retrievedContext.length > 0 + ? `Context: ${retrievedContext.join('\n')}\n${currentSystemPrompt}` + : currentSystemPrompt const mapProvider = formData?.get('mapProvider') as 'mapbox' | 'google' async function processEvents() { @@ -369,7 +380,7 @@ async function submit(formData?: FormData, skip?: boolean) { : answer.length === 0 && !errorOccurred ) { const { fullResponse, hasError, toolResponses } = await researcher( - currentSystemPrompt, + augmentedSystemPrompt, uiStream, streamText, messages, @@ -431,8 +442,6 @@ async function submit(formData?: FormData, skip?: boolean) { ) - await new Promise(resolve => setTimeout(resolve, 500)) - aiState.done({ ...aiState.get(), messages: [ @@ -457,6 +466,8 @@ async function submit(formData?: FormData, skip?: boolean) { } ] }) + } else { + aiState.done(aiState.get()) } isGenerating.done(false) @@ -475,19 +486,35 @@ async function submit(formData?: FormData, skip?: boolean) { async function clearChat() { 'use server' - const aiState = getMutableAIState() - aiState.done({ chatId: nanoid(), messages: [] }) } +export type Message = { + id: string + role: 'user' | 'assistant' | 'system' | 'tool' | 'function' | 'data' + content: string | any[] + name?: string + type?: + | 'response' + | 'inquiry' + | 'related' + | 'followup' + | 'input' + | 'input_related' + | 'tool' + | 'resolution_search_result' + | 'skip' + | 'end' + | 'drawing_context' +} + export type AIState = { - messages: AIMessage[] chatId: string - isSharePage?: boolean + messages: Message[] } export type UIState = { @@ -497,100 +524,68 @@ export type UIState = { isCollapsed?: StreamableValue }[] -const initialAIState: AIState = { - chatId: nanoid(), - messages: [] -} - -const initialUIState: UIState = [] - export const AI = createAI({ actions: { submit, clearChat }, - initialUIState, - initialAIState, + initialUIState: [], + initialAIState: { chatId: nanoid(), messages: [] }, onGetUIState: async () => { 'use server' - const aiState = getAIState() as AIState + const aiState = getAIState() + if (aiState) { - const uiState = getUIStateFromAIState(aiState) + const uiState = getUIStateFromAIState(aiState as Chat) return uiState + } else { + return } - return initialUIState }, - onSetAIState: async ({ state }) => { + onSetAIState: async ({ state, done }) => { 'use server' - if (!state.messages.some(e => e.type === 'response')) { - return - } - const { chatId, messages } = state - const createdAt = new Date() - const path = `/search/${chatId}` - - let title = 'Untitled Chat' - if (messages.length > 0) { - const firstMessageContent = messages[0].content - if (typeof firstMessageContent === 'string') { - try { - const parsedContent = JSON.parse(firstMessageContent) - title = parsedContent.input?.substring(0, 100) || 'Untitled Chat' - } catch (e) { - title = firstMessageContent.substring(0, 100) - } - } else if (Array.isArray(firstMessageContent)) { - const textPart = ( - firstMessageContent as { type: string; text?: string }[] - ).find(p => p.type === 'text') - title = - textPart && textPart.text - ? textPart.text.substring(0, 100) - : 'Image Message' - } - } - - const updatedMessages: AIMessage[] = [ - ...messages, - { - id: nanoid(), - role: 'assistant', - content: `end`, - type: 'end' - } - ] - const { getCurrentUserIdOnServer } = await import( - '@/lib/auth/get-current-user' - ) - const actualUserId = await getCurrentUserIdOnServer() + const userId = await getCurrentUserIdOnServer() - if (!actualUserId) { - console.error('onSetAIState: User not authenticated. Chat not saved.') + if (!userId) { return } - const chat: Chat = { - id: chatId, - createdAt, - userId: actualUserId, - path, - title, - messages: updatedMessages + const lastMessage = messages[messages.length - 1] + if (lastMessage && lastMessage.role === 'assistant' && done) { + const chat: Chat = { + id: chatId, + title: typeof messages[0].content === 'string' + ? messages[0].content.substring(0, 100) + : 'New Chat', + userId, + createdAt: new Date(), + messages: messages as any, // Cast to any to avoid type conflict with Chat interface + path: `/search/${chatId}` + } + + await saveChat(chat, userId) } - await saveChat(chat, actualUserId) } }) -export const getUIStateFromAIState = (aiState: AIState): UIState => { - const chatId = aiState.chatId - const isSharePage = aiState.isSharePage - return aiState.messages +export const getUIStateFromAIState = (aiState: Chat) => { + const chatId = aiState.id + const isSharePage = false // Defaulting to false as it's not defined + + const messages = aiState.messages + .filter( + message => + message.role !== 'system' && + message.role !== 'tool' && + message.type !== 'followup' && + message.type !== 'related' + ) .map((message, index) => { - const { role, content, id, type, name } = message + const { role, content, id, type } = message if ( !type || @@ -608,9 +603,14 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { let messageContent: string | any[] try { // For backward compatibility with old messages that stored a JSON string - const json = JSON.parse(content as string) - messageContent = - type === 'input' ? json.input : json.related_query + const parsed = JSON.parse(content as string) + if (Array.isArray(parsed)) { + messageContent = parsed + } else if (typeof parsed === 'object' && parsed !== null) { + messageContent = type === 'input' ? parsed.input : parsed.related_query + } else { + messageContent = parsed + } } catch (e) { // New messages will store the content array or string directly messageContent = content @@ -633,10 +633,10 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { } break case 'assistant': - const answer = createStreamableValue() - answer.done(content) switch (type) { case 'response': + const answer = createStreamableValue() + answer.done(content) return { id, component: ( @@ -666,13 +666,21 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { ) } case 'resolution_search_result': { - const analysisResult = JSON.parse(content as string); + let analysisResult: any = {} + try { + analysisResult = JSON.parse(content as string); + } catch (e) { + // Not JSON + } const geoJson = analysisResult.geoJson as FeatureCollection; + const summaryStream = createStreamableValue() + summaryStream.done(analysisResult.summary || 'Analysis complete.') return { id, component: ( <> + {geoJson && ( )} @@ -680,71 +688,35 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => { ) } } - } - break - case 'tool': - try { - const toolOutput = JSON.parse(content as string) - const isCollapsed = createStreamableValue() - isCollapsed.done(true) - - if ( - toolOutput.type === 'MAP_QUERY_TRIGGER' && - name === 'geospatialQueryTool' - ) { - return { - id, - component: , - isCollapsed: false - } - } - - const searchResults = createStreamableValue() - searchResults.done(JSON.stringify(toolOutput)) - switch (name) { - case 'search': - return { - id, - component: , - isCollapsed: isCollapsed.value + default: { + // Handle generic assistant messages that might not have a specific type or are 'answer' type + // Handle content that is not a string (e.g., array of parts) + let displayContent: string = '' + if (typeof content === 'string') { + displayContent = content + } else if (Array.isArray(content)) { + // Convert array content to string representation or extract text + displayContent = content.map(part => { + if ('text' in part) return part.text + return '' + }).join('\n') } - case 'retrieve': - return { - id, - component: , - isCollapsed: isCollapsed.value - } - case 'videoSearch': + + const contentStream = createStreamableValue() + contentStream.done(displayContent) + return { id, - component: ( - - ), - isCollapsed: isCollapsed.value + component: } - default: - console.warn( - `Unhandled tool result in getUIStateFromAIState: ${name}` - ) - return { id, component: null } - } - } catch (error) { - console.error( - 'Error parsing tool content in getUIStateFromAIState:', - error - ) - return { - id, - component: null } } break default: - return { - id, - component: null - } + return null } }) .filter(message => message !== null) as UIState + + return messages } diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index a8e592ee..01aa7470 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,11 +1,9 @@ import { NextResponse, NextRequest } from 'next/server'; -import { saveChat, createMessage, NewChat, NewMessage } from '@/lib/actions/chat-db'; +import { saveChat } from '@/lib/actions/chat'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; -// import { generateUUID } from '@/lib/utils'; // Assuming generateUUID is in lib/utils as per PR context - not needed for PKs +import { type Chat } from '@/lib/types'; +import { v4 as uuidv4 } from 'uuid'; -// This is a simplified POST handler. PR #533's version might be more complex, -// potentially handling streaming AI responses and then saving. -// For now, this focuses on the database interaction part. export async function POST(request: NextRequest) { try { const userId = await getCurrentUserIdOnServer(); @@ -14,47 +12,36 @@ export async function POST(request: NextRequest) { } const body = await request.json(); - - // Example: Distinguish between creating a new chat vs. adding a message to existing chat - // The actual structure of `body` would depend on client-side implementation. - // Let's assume a simple case: creating a new chat with an initial message. - const { title, initialMessageContent, role = 'user' } = body; + const { title, initialMessageContent, role = 'user' } + = body; if (!initialMessageContent) { return NextResponse.json({ error: 'Initial message content is required' }, { status: 400 }); } - const newChatData: NewChat = { - // id: generateUUID(), // Drizzle schema now has defaultRandom for UUIDs + const chatId = uuidv4(); + const newChat: Chat = { + id: chatId, userId: userId, - title: title || 'New Chat', // Default title if not provided - // createdAt: new Date(), // Handled by defaultNow() in schema - visibility: 'private', // Default visibility - }; - - // Use a transaction if creating chat and first message together - // For simplicity here, let's assume saveChat handles chat creation and returns ID, then we create a message. - // A more robust `saveChat` might create the chat and first message in one go. - // The `saveChat` in chat-db.ts is designed to handle this. - - const firstMessage: Omit = { - // id: generateUUID(), // Drizzle schema now has defaultRandom for UUIDs - // chatId is omitted as it will be set by saveChat - userId: userId, - role: role as NewMessage['role'], // Ensure role type matches schema expectation - content: initialMessageContent, - // createdAt: new Date(), // Handled by defaultNow() in schema, not strictly needed here + title: title || 'New Chat', + createdAt: new Date(), + path: `/search/${chatId}`, + messages: [ + { + id: uuidv4(), + role: role, + content: initialMessageContent, + createdAt: new Date(), + } + ] }; - // The saveChat in chat-db.ts is designed to take initial messages. - const savedChatId = await saveChat(newChatData, [firstMessage]); + const savedChatId = await saveChat(newChat, userId); if (!savedChatId) { return NextResponse.json({ error: 'Failed to save chat' }, { status: 500 }); } - // Fetch the newly created chat and message to return (optional, but good for client) - // For now, just return success and the new chat ID. return NextResponse.json({ message: 'Chat created successfully', chatId: savedChatId }, { status: 201 }); } catch (error) { diff --git a/app/api/chats/all/route.ts b/app/api/chats/all/route.ts index d0a3dbb7..8c43e6db 100644 --- a/app/api/chats/all/route.ts +++ b/app/api/chats/all/route.ts @@ -1,8 +1,6 @@ -// Content for app/api/chats/all/route.ts import { NextResponse } from 'next/server'; -import { clearHistory as dbClearHistory } from '@/lib/actions/chat-db'; +import { clearChats } from '@/lib/actions/chat'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; -import { revalidatePath } from 'next/cache'; // For revalidating after clearing export async function DELETE() { try { @@ -11,26 +9,18 @@ export async function DELETE() { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const success = await dbClearHistory(userId); - if (success) { - revalidatePath('/'); // Revalidate home or relevant pages - revalidatePath('/search'); // Revalidate search path - return NextResponse.json({ message: 'History cleared successfully' }, { status: 200 }); - } else { - // This case might be redundant if dbClearHistory throws an error on failure, - // but kept for explicitness if it returns false for "no error but nothing done". - return NextResponse.json({ error: 'Failed to clear history' }, { status: 500 }); + const result = await clearChats(userId); + if (result && 'error' in result) { + return NextResponse.json({ error: result.error }, { status: 500 }); } + + return NextResponse.json({ message: 'History cleared successfully' }, { status: 200 }); + } catch (error) { console.error('Error clearing history via API:', error); let errorMessage = 'Internal Server Error clearing history'; if (error instanceof Error && error.message) { - // Use the error message from dbClearHistory if available (e.g., "User ID is required") - // This depends on dbClearHistory actually throwing or returning specific error messages. - // The current dbClearHistory in chat.ts returns {error: ...} which won't be caught here as an Error instance directly. - // However, the dbClearHistory in chat-db.ts returns boolean. - // Let's assume if dbClearHistory from chat-db.ts (which returns boolean) fails, it's a generic 500. - // If it were to throw, that would be caught. + errorMessage = error.message } return NextResponse.json({ error: errorMessage }, { status: 500 }); } diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 91903e13..17bb728b 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -1,34 +1,24 @@ import { NextResponse, NextRequest } from 'next/server'; -import { getChatsPage } from '@/lib/actions/chat-db'; +import { getChats } from '@/lib/actions/chat'; import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'; export async function GET(request: NextRequest) { try { const userId = await getCurrentUserIdOnServer(); + + // If unauthenticated, return empty chats list instead of 401 + // This allows the UI to gracefully handle unauthenticated state if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ chats: [] }); } - const { searchParams } = new URL(request.url); - - const DEFAULT_LIMIT = 20; - const MAX_LIMIT = 100; - const DEFAULT_OFFSET = 0; - - let limit = parseInt(searchParams.get('limit') || '', 10); - if (isNaN(limit) || limit < 1 || limit > MAX_LIMIT) { - limit = DEFAULT_LIMIT; - } - - let offset = parseInt(searchParams.get('offset') || '', 10); - if (isNaN(offset) || offset < 0) { - offset = DEFAULT_OFFSET; - } - - const result = await getChatsPage(userId, limit, offset); - return NextResponse.json(result); + const chats = await getChats(userId); + return NextResponse.json({ chats }); } catch (error) { console.error('Error fetching chats:', error); - return NextResponse.json({ error: 'Internal Server Error fetching chats' }, { status: 500 }); + return NextResponse.json( + { error: 'Internal Server Error fetching chats' }, + { status: 500 } + ); } } diff --git a/app/api/user/credits/route.ts b/app/api/user/credits/route.ts new file mode 100644 index 00000000..330048ca --- /dev/null +++ b/app/api/user/credits/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { users } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { getSupabaseServerClient } from '@/lib/supabase/client'; +import { TIERS, parseTier, getTierConfig } from '@/lib/utils/subscription'; + +export async function GET(req: NextRequest) { + try { + const supabase = getSupabaseServerClient(); + const { + data: { user }, + error: userError + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Get user from database + const dbUser = await db.query.users.findFirst({ + where: eq(users.id, user.id) + }); + + if (!dbUser) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + + const tier = parseTier(dbUser.tier); + // If user is not on Standard tier, they might not need credits logic, + // but for now we return the credits regardless. + // If the tier doesn't support credits (e.g. Free or Pro), the UI can handle it. + + return NextResponse.json({ + credits: dbUser.credits, + tier: tier, + features: getTierConfig(tier) + }); + + } catch (error) { + console.error('Error fetching user credits:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/app/api/user/upgrade/route.ts b/app/api/user/upgrade/route.ts new file mode 100644 index 00000000..1edf4e08 --- /dev/null +++ b/app/api/user/upgrade/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { users } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { getSupabaseServerClient } from '@/lib/supabase/client'; +import { TIER_CONFIGS, TIERS, parseTier } from '@/lib/utils/subscription'; + +export async function POST(req: NextRequest) { + try { + const supabase = getSupabaseServerClient(); + const { + data: { user }, + error: userError + } = await supabase.auth.getUser(); + + if (userError || !user) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const { tier } = await req.json(); + + // Validate tier + if (!tier || !Object.values(TIERS).includes(tier)) { + return NextResponse.json( + { error: 'Invalid tier' }, + { status: 400 } + ); + } + + // Get tier config to determine credits to add + const tierConfig = TIER_CONFIGS[tier as keyof typeof TIER_CONFIGS]; + if (!tierConfig) { + return NextResponse.json( + { error: 'Tier not found' }, + { status: 400 } + ); + } + + // Get current user from database + const currentUser = await db.query.users.findFirst({ + where: eq(users.id, user.id) + }); + + if (!currentUser) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + + // Calculate credits to add + const creditsToAdd = tierConfig.credits; + const newCreditsTotal = currentUser.credits + creditsToAdd; + + // Update user in database with new tier and credits + const updatedUser = await db + .update(users) + .set({ + tier: tier, + credits: newCreditsTotal + }) + .where(eq(users.id, user.id)) + .returning(); + + return NextResponse.json({ + success: true, + tier: tier, + creditsAdded: creditsToAdd, + totalCredits: newCreditsTotal, + message: `Successfully upgraded to ${tier} tier with ${creditsToAdd} credits` + }); + + } catch (error) { + console.error('Error upgrading user:', error); + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 00000000..fffab410 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,57 @@ +import { createServerClient, type CookieOptions } from '@supabase/ssr' +import { cookies } from 'next/headers' +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + // if "next" is in search params, use it as the redirection URL + const next = searchParams.get('next') ?? '/' + + if (code) { + const cookieStore = cookies() + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + async get(name: string) { + const cookie = (await cookieStore).get(name) + return cookie?.value + }, + async set(name: string, value: string, options: CookieOptions) { + const store = await cookieStore + store.set({ name, value, ...options }) + }, + async remove(name: string, options: CookieOptions) { + const store = await cookieStore + store.set({ name, value: '', ...options }) + }, + }, + } + ) + const { error } = await supabase.auth.exchangeCodeForSession(code) + if (error) { + console.error('[Auth Callback] Exchange code error:', { + message: error.message, + status: error.status, + name: error.name, + code: code?.substring(0, 10) + '...' + }) + return NextResponse.redirect(`${origin}/auth/auth-code-error?error=${encodeURIComponent(error.message)}`) + } else { + try { + const { data: { user }, error: userErr } = await supabase.auth.getUser() + if (!userErr && user) { + console.log('[Auth Callback] User signed in:', user.email) + } + } catch (e) { + console.warn('[Auth Callback] Could not fetch user after exchange', e) + } + return NextResponse.redirect(`${origin}${next}`) + } + } + + // return the user to an error page with instructions + return NextResponse.redirect(`${origin}/auth/auth-code-error`) +} diff --git a/app/auth/page.tsx b/app/auth/page.tsx new file mode 100644 index 00000000..7641d841 --- /dev/null +++ b/app/auth/page.tsx @@ -0,0 +1,63 @@ +"use client" + +export const dynamic = 'force-dynamic' + +import Image from "next/image" +import { AuthPage } from "@/components/auth" +import { useAuth } from "@/lib/auth/v0" + +function Logo() { + return ( +
+ QCX Logo + QCX +
+ ) +} + +function ArtPanel() { + return ( +
+ Abstract art +
+ ) +} + +export default function LoginPage() { + const { + isLoading, + error, + magicLinkSent, + magicLinkEmail, + handleGoogleSignIn, + handleMagicLink, + resetError, + resetMagicLink, + } = useAuth({ + // Optional callbacks for additional handling + onMagicLinkSent: (email) => { + console.log("Magic link sent to:", email) + }, + onError: (error) => { + console.error("Auth error:", error) + }, + }) + + return ( + } + onGoogleSignIn={handleGoogleSignIn} + onMagicLinkSubmit={handleMagicLink} + showGitHub={false} + decorativePanel={} + isLoading={isLoading} + error={error} + magicLinkSent={magicLinkSent} + magicLinkEmail={magicLinkEmail} + onResetMagicLink={resetMagicLink} + onResetError={resetError} + /> + ) +} diff --git a/app/layout.tsx b/app/layout.tsx index a092d4fe..766cd265 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -15,7 +15,13 @@ import { ProfileToggleProvider } from '@/components/profile-toggle-context' import { CalendarToggleProvider } from '@/components/calendar-toggle-context' import { MapLoadingProvider } from '@/components/map-loading-context'; import ConditionalLottie from '@/components/conditional-lottie'; -import { MapProvider as MapContextProvider } from '@/components/map/map-context' +import { MapProvider } from '@/components/map/map-context' +import { getSupabaseUserAndSessionOnServer } from '@/lib/auth/get-current-user' +import { PurchaseCreditsPopup } from '@/components/credits/purchase-credits-popup'; +import { CreditsProvider } from '@/components/credits/credits-provider'; + +// Force dynamic rendering since we check auth with cookies +export const dynamic = 'force-dynamic' const fontSans = FontSans({ subsets: ['latin'], @@ -55,11 +61,14 @@ export const viewport: Viewport = { maximumScale: 1 } -export default function RootLayout({ +export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + // Check authentication and conditionally render the layout + const { user } = await getSupabaseUserAndSessionOnServer(); + return ( - - - - - - -
- - {children} - -