diff --git a/app/(root)/dashboard/api-integrations/page.tsx b/app/(root)/dashboard/api-integrations/page.tsx deleted file mode 100644 index 6e62c52..0000000 --- a/app/(root)/dashboard/api-integrations/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { WebhookManager } from '@/components/Layout/Dashboard/API-Integrations/WebhookManager'; - -export default function Page(){ - - return -} \ No newline at end of file diff --git a/app/(root)/dashboard/apikeys/page.tsx b/app/(root)/dashboard/apikeys/page.tsx new file mode 100644 index 0000000..8baedd7 --- /dev/null +++ b/app/(root)/dashboard/apikeys/page.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import ApiKeysDashboard from '@/components/apikey/ApikeysManagement'; + +const page = () => { + return +} + +export default page \ No newline at end of file diff --git a/app/(root)/dashboard/webhooks/page.tsx b/app/(root)/dashboard/webhooks/page.tsx new file mode 100644 index 0000000..a3b4ab9 --- /dev/null +++ b/app/(root)/dashboard/webhooks/page.tsx @@ -0,0 +1,6 @@ +import { WebhookManager } from '@/components/Layout/Dashboard/webhooks/WebhookManager'; + +export default function Page(){ + + return +} \ No newline at end of file diff --git a/app/api/generate/[contentId]/[sessionId]/route.ts b/app/api/contents/[contentId]/[sessionId]/route.ts similarity index 100% rename from app/api/generate/[contentId]/[sessionId]/route.ts rename to app/api/contents/[contentId]/[sessionId]/route.ts diff --git a/app/api/contents/route.ts b/app/api/contents/route.ts new file mode 100644 index 0000000..4d0e2fa --- /dev/null +++ b/app/api/contents/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fetchContentsFromDB, resolveUserIdFromApiKey } from '@/lib/db/content'; + +export const runtime = 'nodejs'; + + +export async function GET(request: NextRequest) { + try { + const apiKey = request.headers.get('x-api-key'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Unauthorized: Invalid or missing API Key' }, + { status: 401 } + ); + } + + const authenticatedUserId = await resolveUserIdFromApiKey(apiKey); + + if (!authenticatedUserId) { + return NextResponse.json( + { error: 'Unauthorized: Invalid or revoked API Key.' }, + { status: 401 } + ); + } + + + const searchParams = request.nextUrl.searchParams; + const limit = parseInt(searchParams.get('limit')); + const versions = parseInt(searchParams.get('versions')); + const authorID = searchParams.get('author_id'); + + // Ensure limit is a positive number + const safeLimit = Math.max(1, limit); + // Ensure versions is a non-negative number + const safeVersions = Math.max(0, versions); + + const contentData = await fetchContentsFromDB(safeLimit, safeVersions, authorID); + + return NextResponse.json({ + success: true, + count: contentData.length, + data: contentData, + }, { status: 200 }); + + } catch (error) { + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/format/route.ts b/app/api/format/route.ts deleted file mode 100644 index e69de29..0000000 diff --git a/app/api/keys/route.ts b/app/api/keys/route.ts new file mode 100644 index 0000000..9989b20 --- /dev/null +++ b/app/api/keys/route.ts @@ -0,0 +1,30 @@ +import { generateAndStoreNewApiKey } from "@/lib/db/content"; +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = 'nodejs'; + +export async function POST(request: NextRequest) { + + try { + const { keyName } = await request.json(); + + // 2. Input Validation + if (!keyName || typeof keyName !== 'string' || keyName.trim().length === 0) { + return NextResponse.json({ error: 'A valid key name is required.' }, { status: 400 }); + } + + // 3. Generate, Hash, and Store the new key + const plainTextKey = await generateAndStoreNewApiKey(keyName.trim()); + + // 4. Return the plaintext key (ONLY return it on creation!) + return NextResponse.json({ + success: true, + plainTextKey: plainTextKey, + message: "Key generated. Store this key securely, it will not be shown again." + }, { status: 201 }); + + } catch (error) { + console.error('API Key Generation Error:', error); + return NextResponse.json({ error: 'Failed to process key generation request.' }, { status: 500 }); + } +} diff --git a/app/api/scheduled/[content_id]/[session_id]/route.ts b/app/api/schedule/[content_id]/[session_id]/route.ts similarity index 100% rename from app/api/scheduled/[content_id]/[session_id]/route.ts rename to app/api/schedule/[content_id]/[session_id]/route.ts diff --git a/app/api/scheduled/content_generate/[time]/route.ts b/app/api/schedule/content/[time]/route.ts similarity index 97% rename from app/api/scheduled/content_generate/[time]/route.ts rename to app/api/schedule/content/[time]/route.ts index e50a1b6..b1be9ac 100644 --- a/app/api/scheduled/content_generate/[time]/route.ts +++ b/app/api/schedule/content/[time]/route.ts @@ -9,7 +9,7 @@ interface Context { }; } -const API_ENDPOINT = 'http://localhost:3000/api/scheduled'; +const API_ENDPOINT = 'http://localhost:3000/api/schedule'; export async function GET(request: NextRequest, context: Context) { const { time } = await context.params; diff --git a/app/api/execute-webhook/route.ts b/app/api/webhook/route.ts similarity index 100% rename from app/api/execute-webhook/route.ts rename to app/api/webhook/route.ts diff --git a/components/Layout/Dashboard/API-Integrations/WebhookManager.tsx b/components/Layout/Dashboard/webhooks/WebhookManager.tsx similarity index 100% rename from components/Layout/Dashboard/API-Integrations/WebhookManager.tsx rename to components/Layout/Dashboard/webhooks/WebhookManager.tsx diff --git a/components/Layout/Navigations/Sidebar.tsx b/components/Layout/Navigations/Sidebar.tsx index 392ac1b..592b6c5 100644 --- a/components/Layout/Navigations/Sidebar.tsx +++ b/components/Layout/Navigations/Sidebar.tsx @@ -39,7 +39,8 @@ const navigation = [ title: 'Other', items: [ { href: '/dashboard/explore', icon: Globe, label: 'Explore Tools' }, - { href: '/dashboard/api-integrations', icon: Globe, label: 'API Intgrations' }, + { href: '/dashboard/webhooks', icon: Globe, label: 'Webhook Intgrations' }, + { href: '/dashboard/apikeys', icon: Globe, label: 'Api Keys' }, ], }, ]; diff --git a/components/apikey/ApikeysManagement.tsx b/components/apikey/ApikeysManagement.tsx new file mode 100644 index 0000000..20a3508 --- /dev/null +++ b/components/apikey/ApikeysManagement.tsx @@ -0,0 +1,343 @@ +'use client' +import { useAuth } from '@/context/AuthContext'; +import { fetchUserApiKeys, generateAndStoreNewApiKey, revokeOrUnrevokeApiKey } from '@/lib/db/content'; +import React, { useState, useEffect, useCallback, useMemo, memo } from 'react'; + +/** + * Utility to copy text to clipboard + */ +const copyToClipboard = (text) => { + try { + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + } else { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + return true; + } catch (err) { + return false; + } +}; + +const SkeletonLoader = () => ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+); + +const NewKeyDialog = ({ isOpen, onClose, userId, onKeyGenerated }) => { + const [keyName, setKeyName] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [plainTextKey, setPlainTextKey] = useState(''); + const [error, setError] = useState(null); + + const handleGenerate = async (e) => { + e.preventDefault(); + if (!keyName.trim() || !userId) return; + + setIsLoading(true); + setError(null); + setPlainTextKey(''); + + try { + const plainTextKey = await generateAndStoreNewApiKey(keyName.trim()); + + if (plainTextKey) { + setPlainTextKey(plainTextKey); + onKeyGenerated(); + } else { + setError('Failed to generate key.'); + } + } catch (err) { + setError(err.message.toString() || 'Network error during key generation.'); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + setKeyName(''); + setPlainTextKey(''); + setError(null); + onClose(); + }; + + // Simulated Shadcn Dialog structure (Light theme) + return ( +
+
+
+

+ {plainTextKey ? 'Key Generated!' : 'Create New API Key'} +

+ + {plainTextKey ? ( +
+

+ ⚠️ SUCCESS: Copy the key below. It will not be shown again. +

+
+ {plainTextKey} + +
+ +
+ ) : ( +
+ setKeyName(e.target.value)} + required + className="w-full p-3 bg-white border border-gray-300 rounded-lg focus:ring-1 focus:ring-gray-900 focus:outline-none placeholder-gray-500 text-sm" + disabled={isLoading} + /> + {error &&

{error}

} +
+ + +
+
+ )} +
+
+ ); +}; + +const ApiKeysDashboard = () => { + const { user} = useAuth(); + const userId = useMemo(() => user?.id || null, [user]); + + const [isLoading, setIsLoading] = useState(true); + const [keys, setKeys] = useState([]); + const [showDialog, setShowDialog] = useState(false); + const [message, setMessage] = useState(null); + + // 2. Data Fetching (GET /api/keys) + const fetchKeys = useCallback(async () => { + if (!userId) return; + setIsLoading(true); + + try { + const keys = await fetchUserApiKeys(); + if (keys) { + setKeys(keys || []); + } else { + setMessage({ type: 'error', text: 'Failed to load keys.' }); + } + } catch (error) { + setMessage({ type: 'error', text: error.message || 'Network error while fetching keys.' }); + } finally { + setIsLoading(false); + } + }, [userId]); + + useEffect(() => { + fetchKeys(); + }, [fetchKeys]); + + + const handleRevoke = async (keyId, state) => { + if (!window.confirm("Are you sure you want to permanently revoke this API key? This cannot be undone.")) return; + + setIsLoading(true); + setMessage(null); + + try { + const response = await revokeOrUnrevokeApiKey(keyId, state); + if (response) { + setMessage({ type: 'success', text: state === false ? "Key revoke successfully" : "Key unrevoked successfully." }); + fetchKeys(); // Refresh the list + } else { + setMessage({ type: 'error', text: 'Failed to revoke key.' }); + setIsLoading(false); + } + } catch (error) { + setMessage({ type: 'error', text: error.message || 'Network error while revoking key.' }); + setIsLoading(false); + } + }; + + // Utility for date formatting + const formatDate = (dateValue) => { + if (!dateValue) return 'N/A'; + // Assuming dateValue is an ISO string or a Firestore Timestamp object from the API + const date = dateValue.toDate ? dateValue.toDate() : new Date(dateValue); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const isDataLoading = isLoading; + + // Main Dashboard Render (Light theme) + return ( +
+ + setShowDialog(false)} + userId={userId} + onKeyGenerated={() => { + setShowDialog(false); + fetchKeys(); + setMessage({ type: 'success', text: 'Key generated successfully! Check the dialog for the key value.' }); + }} + /> + +
+
+
+

API Keys Dashboard

+ +
+

+ Use these keys to access your data via the external platform API. +

+
+ + {message && ( +
+ {message.text} +
+ )} + + {/* Key List / Skeleton Loader */} +
+ {isDataLoading ? ( + + ) : ( +
+ {keys.length === 0 ? ( +

+ You have no API keys. Click "New Key" to create one. +

+ ) : ( +
+ {keys.map((key) => ( +
+ {/* TOP SECTION: Name, Status, and REVOKE BUTTON */} +
+ + {/* Left Section: Icon, Key Name, Status */} +
+
+ +
+
+

+ {key.name} +

+
+ + {key.isActive ? 'Active' : 'Revoked'} +
+
+ + {/* REVOKE BUTTON (Subtle, Icon-only) */} + + + +
+ + {/* Right Section: Creation Date */} +
+
+ + Created: + {formatDate(key.createdAt)} +
+
+
+ + {/* BOTTOM SECTION: Metadata Details (ID, Last Used) */} +
+ + {/* Key ID +
+ + Key ID: + + {key.id} + +
*/} + + {/* Last Used */} +
+ + Last Used: + {formatDate(key.lastUsed)} +
+
+
+ ))} +
+ )} +
+ )} +
+
+
+ ); +}; + +export default memo(ApiKeysDashboard); \ No newline at end of file diff --git a/context/GenerationContext.tsx b/context/GenerationContext.tsx index 58ec209..f800967 100644 --- a/context/GenerationContext.tsx +++ b/context/GenerationContext.tsx @@ -48,6 +48,8 @@ export const contentRendererTabsState = { templates: 'templates' }; +const API_Endpoint = '/api/contents'; + // --- 1. Define the Context Interface (What components can access) --- interface GenerationContextType { // The final structured output from the AI @@ -265,7 +267,7 @@ export function ContextProvider({ children }: { children: ReactNode }) { formData.append('document', blob, file.name); // 'document' is the expected field name on your server } - const response = await fetch(`/api/generate/${content_id}/${sessionId}`, { + const response = await fetch(`${API_Endpoint}/${content_id}/${sessionId}`, { method: 'POST', body: formData, }); diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 8971fa2..03bea3c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -8,7 +8,7 @@ import { pgSchema, boolean, } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; +import { relations, sql } from 'drizzle-orm'; const authSchema = pgSchema('auth'); export const users = authSchema.table('users', { @@ -98,6 +98,25 @@ export const userContents = pgTable( }, ); +export const apiKeys = pgTable("api_keys", { + id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), + + // Storing the HASH of the API key (MANDATORY for security) + keyHash: text("key_hash").notNull().unique(), + + // Link back to the user who owns this key + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), // Link to the user + + name: text("name").notNull(), // E.g., "Integration Key for Mobile App" + isActive: boolean("is_active").default(true).notNull(), + + createdAt: timestamp("created_at").defaultNow().notNull(), + lastUsed: timestamp("last_used"), +}); + + // Relations for 'userContents' export const userContentsRelations = relations(userContents, ({ one }) => ({ // Defines the 'one' master content record this version belongs to. @@ -106,3 +125,10 @@ export const userContentsRelations = relations(userContents, ({ one }) => ({ references: [contents.contentId], }), })); + +export const apiKeysRelations = relations(apiKeys, ({ one }) => ({ + owner: one(users, { + fields: [apiKeys.userId], + references: [users.id], + }), +})); diff --git a/lib/db/content.ts b/lib/db/content.ts index bbc090c..b8437ce 100644 --- a/lib/db/content.ts +++ b/lib/db/content.ts @@ -1,8 +1,10 @@ 'use server'; import { createClient } from '@/utils/supabase/server'; import { db } from '@/db'; -import { contents, userContents } from '@/drizzle/schema'; -import { and, desc, eq } from 'drizzle-orm'; +import { v4 as uuidv4 } from 'uuid'; +import * as bcrypt from 'bcryptjs'; +import { apiKeys, contents, userContents } from '@/drizzle/schema'; +import { and, desc, eq, sql } from 'drizzle-orm'; import { revalidatePath } from 'next/cache'; export interface WebhookCredentials { id: string; @@ -21,7 +23,7 @@ interface ScheduledJob { job_id: string; cronSchedule: string; frequency: string; - time: string; // e.g., '09:30' + time: string; isActive: boolean; runSlot: string; referenceUrls: string[]; @@ -557,3 +559,156 @@ export async function getWebhookCredentials() { throw error; } } + + +export async function fetchContentsFromDB(limit = 10, versions = 3, userId?: string, contentId?: string) { + + try{ + const relationConfig = { + limit: versions, + orderBy: [desc(userContents.createdAt)], + } + + const userIdClause = userId ? eq(contents.authorId, userId) : undefined; + const contentIdClause = userId ? eq(contents.contentId, contentId) : undefined; + + + const results = await db.query.contents.findMany({ + limit: limit, + where: and(userIdClause, contentIdClause), + + with: { + versions: relationConfig, + }, + orderBy: (contents, { desc }) => [desc(contents.createdAt)], + }); + + return results; + + }catch (e){ + console.error(e.message); + throw e; + } +} + +export async function generateAndStoreNewApiKey(keyName: string): Promise { + const SALT_ROUNDS = 10; + + // Generate a secure, unique plaintext key (e.g., sk_prefix_longrandomstring) + const prefix = "sk_ai_"; // Secret Key prefix + // Generate a random part using UUID and basic string manipulation + const randomPart = Buffer.from(uuidv4()).toString('base64').replace(/=/g, '').slice(0, 32); + const plainTextKey = `${prefix}${randomPart}`; + + // Hash the key before storing it + const keyHash = await bcrypt.hash(plainTextKey, SALT_ROUNDS); + + try { + const supabase = await createClient(); + + const { data: userData, error: authError } = await supabase.auth.getUser(); + + if (authError || !userData?.user) { + console.error('Authentication Error:', authError?.message || 'User not logged in.'); + throw new Error('User authentication required to create API keys.'); + } + + // Save the HASH and related metadata to the database + await db.insert(apiKeys).values({ + keyHash: keyHash, + userId: userData.user.id, + name: keyName, + // Default values (isActive, createdAt) are handled by Drizzle/PostgreSQL defaults + }); + + // Return the plaintext key (the one the user needs to copy) + return plainTextKey; + + } catch (e: any) { + console.error('Error saving API Key hash:', e); + throw new Error('Failed to generate and store API key.'); + } +} + + +export async function resolveUserIdFromApiKey(plainTextKey: string): Promise { + + + const activeKeys = await db.select({ + id: apiKeys.id, + keyHash: apiKeys.keyHash, + userId: apiKeys.userId, + }) + .from(apiKeys) + .where(sql`${apiKeys.isActive} = TRUE`); + + for (const keyRecord of activeKeys) { + // Use bcrypt.compare to check the plaintext key against the stored hash + if (await bcrypt.compare(plainTextKey, keyRecord.keyHash)) { + // Update lastUsed timestamp on successful verification (Optional but recommended) + await db.update(apiKeys) + .set({ lastUsed: sql`now()` }) // Use SQL function for current time + .where(sql`${apiKeys.id} = ${keyRecord.id}`); + + return keyRecord.userId; + } + } + + return null; +} + + +export async function fetchUserApiKeys() { + try { + const supabase = await createClient(); + + const { data: userData, error: authError } = await supabase.auth.getUser(); + + if (authError || !userData?.user) { + console.error('Authentication Error:', authError?.message || 'User not logged in.'); + throw new Error('User authentication required to fetch API keys.'); + } + + const keys = await db.select({ + id: apiKeys.id, + name: apiKeys.name, + isActive: apiKeys.isActive, + createdAt: apiKeys.createdAt, + lastUsed: apiKeys.lastUsed, + }) + .from(apiKeys) + .where(eq(apiKeys.userId, userData.user.id)) + .orderBy(desc(apiKeys.createdAt)); + + return keys; + } catch (e) { + console.error('Error fetching API keys:', e); + throw new Error('Failed to retrieve keys from database.'); + } +} + + +export async function revokeOrUnrevokeApiKey(keyId: string, state: boolean): Promise { + try { + const supabase = await createClient(); + + const { data: userData, error: authError } = await supabase.auth.getUser(); + + if (authError || !userData?.user) { + console.error('Authentication Error:', authError?.message || 'User not logged in.'); + throw new Error('User authentication required to revoke API keys.'); + } + + const userId = userData.user.id; + // Only allow the key owner to revoke the key, and only if it's currently active. + const result = await db.update(apiKeys) + .set({ isActive: state }) + .where(sql`${apiKeys.id} = ${keyId} AND ${apiKeys.userId} = ${userId}`) + .returning({ id: apiKeys.id }); + + return result.length > 0; // If one row was returned, the update was successful. + } catch (e) { + console.error('Error revoking API key:', e); + throw new Error('Failed to revoke key.'); + } +} \ No newline at end of file diff --git a/package.json b/package.json index 55bbd2a..3b11915 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@xdevplatform/xdk": "^0.2.1-beta", "ai": "^5.0.87", "axios": "^1.13.2", + "bcryptjs": "^3.0.3", "cheerio": "^1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -69,6 +70,7 @@ "tailwind-merge": "^3.3.1", "unsplash-js": "^7.0.20", "use-debounce": "^10.0.6", + "uuid": "^13.0.0", "vaul": "^1.1.2", "zod": "^4.1.12" }, diff --git a/vercel.json b/vercel.json index 1113000..4013eb5 100644 --- a/vercel.json +++ b/vercel.json @@ -3,11 +3,11 @@ "framework": "nextjs", "crons": [ { - "path": "/api/scheduled/content_generate/early", + "path": "/api/schedule/content/early", "schedule": "0 9 * * *" }, { - "path": "/api/scheduled/content_generate/later", + "path": "/api/schedule/content/later", "schedule": "0 17 * * *" } ]