diff --git a/apps/rowboat/app/actions.ts b/apps/rowboat/app/actions.ts index c42c15a3..8cf1d126 100644 --- a/apps/rowboat/app/actions.ts +++ b/apps/rowboat/app/actions.ts @@ -1,10 +1,10 @@ 'use server'; import { redirect } from "next/navigation"; -import { SimulationData, EmbeddingDoc, GetInformationToolResult, DataSource, PlaygroundChat, AgenticAPIChatRequest, AgenticAPIChatResponse, convertFromAgenticAPIChatMessages, WebpageCrawlResponse, Workflow, WorkflowAgent, CopilotAPIRequest, CopilotAPIResponse, CopilotMessage, CopilotWorkflow, convertToCopilotWorkflow, convertToCopilotApiMessage, convertToCopilotMessage, CopilotAssistantMessage, CopilotChatContext, convertToCopilotApiChatContext, Scenario, ClientToolCallRequestBody, ClientToolCallJwt, ClientToolCallRequest, WithStringId, Project, WorkflowTool, WorkflowPrompt } from "./lib/types"; +import { SimulationData, EmbeddingDoc, GetInformationToolResult, DataSource, PlaygroundChat, AgenticAPIChatRequest, AgenticAPIChatResponse, convertFromAgenticAPIChatMessages, WebpageCrawlResponse, Workflow, WorkflowAgent, CopilotAPIRequest, CopilotAPIResponse, CopilotMessage, CopilotWorkflow, convertToCopilotWorkflow, convertToCopilotApiMessage, convertToCopilotMessage, CopilotAssistantMessage, CopilotChatContext, convertToCopilotApiChatContext, Scenario, ClientToolCallRequestBody, ClientToolCallJwt, ClientToolCallRequest, WithStringId, Project, WorkflowTool, WorkflowPrompt, ApiKey } from "./lib/types"; import { ObjectId, WithId } from "mongodb"; import { generateObject, generateText, tool, embed } from "ai"; -import { dataSourcesCollection, embeddingsCollection, projectsCollection, webpagesCollection, agentWorkflowsCollection, scenariosCollection, projectMembersCollection } from "@/app/lib/mongodb"; +import { dataSourcesCollection, embeddingsCollection, projectsCollection, webpagesCollection, agentWorkflowsCollection, scenariosCollection, projectMembersCollection, apiKeysCollection } from "@/app/lib/mongodb"; import { z } from 'zod'; import { openai } from "@ai-sdk/openai"; import FirecrawlApp, { ScrapeResponse } from '@mendable/firecrawl-js'; @@ -978,4 +978,94 @@ export async function executeClientTool( const result = await callClientToolWebhook(toolCall, projectId); return result; -} \ No newline at end of file +} + +export async function createApiKey(projectId: string): Promise>> { + await projectAuthCheck(projectId); + + // count existing keys + const count = await apiKeysCollection.countDocuments({ projectId }); + if (count >= 3) { + throw new Error('Maximum number of API keys reached'); + } + + // create key + const key = crypto.randomBytes(32).toString('hex'); + const doc: z.infer = { + projectId, + key, + createdAt: new Date().toISOString(), + }; + await apiKeysCollection.insertOne(doc); + const { _id, ...rest } = doc as WithStringId>; + return { ...rest, _id: _id.toString() }; +} + +export async function deleteApiKey(projectId: string, id: string) { + await projectAuthCheck(projectId); + await apiKeysCollection.deleteOne({ projectId, _id: new ObjectId(id) }); +} + +export async function listApiKeys(projectId: string): Promise>[]> { + await projectAuthCheck(projectId); + const keys = await apiKeysCollection.find({ projectId }).toArray(); + return keys.map(k => ({ ...k, _id: k._id.toString() })); +} + +export async function updateProjectName(projectId: string, name: string) { + await projectAuthCheck(projectId); + await projectsCollection.updateOne({ _id: projectId }, { $set: { name } }); + revalidatePath(`/projects/${projectId}`, 'layout'); +} + +export async function deleteProject(projectId: string) { + await projectAuthCheck(projectId); + + // delete api keys + await apiKeysCollection.deleteMany({ + projectId, + }); + + // delete embeddings + const sources = await dataSourcesCollection.find({ + projectId, + }, { + projection: { + _id: true, + } + }).toArray(); + + const ids = sources.map(s => s._id); + + // delete data sources + await embeddingsCollection.deleteMany({ + sourceId: { $in: ids.map(i => i.toString()) }, + }); + await dataSourcesCollection.deleteMany({ + _id: { + $in: ids, + } + }); + + // delete project members + await projectMembersCollection.deleteMany({ + projectId, + }); + + // delete workflows + await agentWorkflowsCollection.deleteMany({ + projectId, + }); + + // delete scenarios + await scenariosCollection.deleteMany({ + projectId, + }); + + // delete project + await projectsCollection.deleteOne({ + _id: projectId, + }); + + redirect('/projects'); +} diff --git a/apps/rowboat/app/lib/components/copy-button.tsx b/apps/rowboat/app/lib/components/copy-button.tsx new file mode 100644 index 00000000..28108d18 --- /dev/null +++ b/apps/rowboat/app/lib/components/copy-button.tsx @@ -0,0 +1,32 @@ +'use client'; +import { CopyIcon, CheckIcon } from "lucide-react"; +import { useState } from "react"; + +export function CopyButton({ + onCopy, + label, + successLabel, +}: { + onCopy: () => void; + label: string; + successLabel: string; +}) { + const [showCopySuccess, setShowCopySuccess] = useState(false); + const handleCopy = () => { + onCopy(); + setShowCopySuccess(true); + setTimeout(() => { + setShowCopySuccess(false); + }, 500); + } + return +} \ No newline at end of file diff --git a/apps/rowboat/app/lib/mongodb.ts b/apps/rowboat/app/lib/mongodb.ts index d138b64e..cea0a94f 100644 --- a/apps/rowboat/app/lib/mongodb.ts +++ b/apps/rowboat/app/lib/mongodb.ts @@ -1,5 +1,5 @@ import { MongoClient } from "mongodb"; -import { PlaygroundChat, DataSource, EmbeddingDoc, Project, Webpage, ChatClientId, Workflow, Scenario, ProjectMember } from "./types"; +import { PlaygroundChat, DataSource, EmbeddingDoc, Project, Webpage, ChatClientId, Workflow, Scenario, ProjectMember, ApiKey } from "./types"; import { z } from 'zod'; const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mongodb://localhost:27017"); @@ -12,3 +12,4 @@ export const projectMembersCollection = db.collection>('webpages'); export const agentWorkflowsCollection = db.collection>("agent_workflows"); export const scenariosCollection = db.collection>("scenarios"); +export const apiKeysCollection = db.collection>("api_keys"); \ No newline at end of file diff --git a/apps/rowboat/app/lib/types.ts b/apps/rowboat/app/lib/types.ts index 6d00e283..41f6e5e2 100644 --- a/apps/rowboat/app/lib/types.ts +++ b/apps/rowboat/app/lib/types.ts @@ -109,6 +109,13 @@ export const ProjectMember = z.object({ lastUpdatedAt: z.string().datetime(), }); +export const ApiKey = z.object({ + projectId: z.string(), + key: z.string(), + createdAt: z.string().datetime(), + lastUsedAt: z.string().datetime().optional(), +}); + export const GetInformationToolResultItem = z.object({ title: z.string(), content: z.string(), diff --git a/apps/rowboat/app/projects/[projectId]/config/app.tsx b/apps/rowboat/app/projects/[projectId]/config/app.tsx index dd337f50..e84a489c 100644 --- a/apps/rowboat/app/projects/[projectId]/config/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/config/app.tsx @@ -1,49 +1,447 @@ 'use client'; import { Metadata } from "next"; -import { Secret } from "./secret"; -import { Divider, Spinner } from "@nextui-org/react"; -import { useEffect, useState } from "react"; -import { Project } from "@/app/lib/types"; -import { getProjectConfig } from "@/app/actions"; -import { EmbedCode } from "./embed"; -import { WebhookUrl } from "./webhook-url"; -import { z } from 'zod'; +import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure } from "@nextui-org/react"; +import { ReactNode, useEffect, useState, useCallback } from "react"; +import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "@/app/actions"; +import { CopyButton } from "@/app/lib/components/copy-button"; +import { EditableField } from "@/app/lib/components/editable-field"; +import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon } from "lucide-react"; export const metadata: Metadata = { title: "Project config", }; -export default function App({ +export function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return
+

{title}

+ {children} +
; +} + +export function SectionRow({ + children, +}: { + children: ReactNode; +}) { + return
{children}
; +} + +export function LeftLabel({ + label, +}: { + label: string; +}) { + return
+
{label}:
+
; +} + +export function RightContent({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +export function BasicSettingsSection({ projectId, }: { projectId: string; }) { - const [isLoading, setIsLoading] = useState(true); - const [project, setProject] = useState | null>(null); + const [loading, setLoading] = useState(false); + const [projectName, setProjectName] = useState(null); useEffect(() => { - let ignore = false; - - async function fetchProjectConfig() { - setIsLoading(true); - const project = await getProjectConfig(projectId); - if (!ignore) { - setProject(project); - setIsLoading(false); - } - } - fetchProjectConfig(); - - return () => { - ignore = true; - }; + setLoading(true); + getProjectConfig(projectId).then((project) => { + setProjectName(project?.name); + setLoading(false); + }); }, [projectId]); - const standardEmbedCode = ` + async function updateName(name: string) { + setLoading(true); + await updateProjectName(projectId, name); + setProjectName(name); + setLoading(false); + } + + return
+ + + +
+ {loading && } + {!loading && } +
+
+
+ + + + +
+
{projectId}
+ { + navigator.clipboard.writeText(projectId); + }} + label="Copy" + successLabel="Copied" + /> +
+
+
+
; +} + +function ApiKeyDisplay({ apiKey }: { apiKey: string }) { + const [isVisible, setIsVisible] = useState(false); + + const formattedKey = isVisible ? apiKey : `${apiKey.slice(0, 2)}${'•'.repeat(5)}${apiKey.slice(-2)}`; + + return ( +
+
{formattedKey}
+
+ + { + navigator.clipboard.writeText(apiKey); + }} + label="Copy" + successLabel="Copied" + /> +
+
+ ); +} + +export function ApiKeysSection({ + projectId, +}: { + projectId: string; +}) { + const [keys, setKeys] = useState>([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState<{ + type: 'success' | 'error' | 'info'; + text: string; + } | null>(null); + + useEffect(() => { + const loadKeys = async () => { + const keys = await listApiKeys(projectId); + setKeys(keys); + setLoading(false); + }; + loadKeys(); + }, [projectId]); + + const handleCreateKey = async () => { + setLoading(true); + setMessage(null); + try { + const key = await createApiKey(projectId); + setLoading(false); + setMessage({ + type: 'success', + text: 'API key created successfully', + }); + setKeys([...keys, key]); + + setTimeout(() => { + setMessage(null); + }, 2000); + } catch (error) { + setLoading(false); + setMessage({ + type: 'error', + text: error instanceof Error ? error.message : "Failed to create API key", + }); + } + }; + + const handleDeleteKey = async (id: string) => { + if (!window.confirm("Are you sure you want to delete this API key? This action cannot be undone.")) { + return; + } + + try { + setLoading(true); + setMessage(null); + await deleteApiKey(projectId, id); + setKeys(keys.filter((k) => k._id !== id)); + setLoading(false); + setMessage({ + type: 'info', + text: 'API key deleted successfully', + }); + setTimeout(() => { + setMessage(null); + }, 2000); + } catch (error) { + setLoading(false); + setMessage({ + type: 'error', + text: error instanceof Error ? error.message : "Failed to delete API key", + }); + } + }; + + return
+
+
+

+ API keys are used to authenticate requests to the Rowboat API. +

+ +
+ + {loading && } + {!loading &&
+
+
API Key
+
Created
+
Last Used
+
+
+ {message?.type === 'success' &&
+
{message.text}
+
} + {message?.type === 'error' &&
+
{message.text}
+
} + {message?.type === 'info' &&
+
{message.text}
+
} +
+ {keys.map((key) => ( +
+
+ +
+
+ {new Date(key.createdAt).toLocaleDateString()} +
+
Never
+
+ + + + + + handleDeleteKey(key._id)} + > + Delete + + + +
+
+ ))} + {keys.length === 0 && ( +
+ No API keys created yet +
+ )} +
+
} +
+
; +} + +export function SecretSection({ + projectId, +}: { + projectId: string; +}) { + const [loading, setLoading] = useState(false); + const [hidden, setHidden] = useState(true); + const [secret, setSecret] = useState(null); + + const formattedSecret = hidden ? `${secret?.slice(0, 2)}${'•'.repeat(5)}${secret?.slice(-2)}` : secret; + + useEffect(() => { + setLoading(true); + getProjectConfig(projectId).then((project) => { + setSecret(project.secret); + setLoading(false); + }); + }, [projectId]); + + const handleRotateSecret = async () => { + if (!confirm("Are you sure you want to rotate the secret? All existing signatures will become invalid.")) { + return; + } + setLoading(true); + try { + const newSecret = await rotateSecret(projectId); + setSecret(newSecret); + } catch (error) { + console.error('Failed to rotate secret:', error); + } finally { + setLoading(false); + } + }; + + return
+

+ The project secret is used for: +

+
    +
  • Signing tool-call requests sent to your webhook
  • +
  • Signing user-data sent through the chat widget
  • +
+ + + +
+ {loading && } + {!loading && secret &&
+
+ {formattedSecret} +
+ + { + navigator.clipboard.writeText(secret); + }} + label="Copy" + successLabel="Copied" + /> + +
} +
+
+
+
; +} + +export function WebhookUrlSection({ + projectId, +}: { + projectId: string; +}) { + const [loading, setLoading] = useState(false); + const [webhookUrl, setWebhookUrl] = useState(null); + + useEffect(() => { + setLoading(true); + getProjectConfig(projectId).then((project) => { + setWebhookUrl(project.webhookUrl || null); + setLoading(false); + }); + }, [projectId]); + + async function update(url: string) { + setLoading(true); + await updateWebhookUrl(projectId, url); + setWebhookUrl(url); + setLoading(false); + } + + function validate(url: string) { + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol !== 'https:') { + return { valid: false, errorMessage: 'URL must use HTTPS' }; + } + return { valid: true }; + } catch { + return { valid: false, errorMessage: 'Please enter a valid URL' }; + } + } + + return
+

+ Tool calls issued through the chat widget will be posted to this URL. +

+ + + +
+ {loading && } + {!loading && } +
+
+
+
; +} + +export function ChatWidgetSection({ + projectId, +}: { + projectId: string; +}) { + const [loading, setLoading] = useState(false); + const [chatClientId, setChatClientId] = useState(null); + + useEffect(() => { + setLoading(true); + getProjectConfig(projectId).then((project) => { + setChatClientId(project.chatClientId); + setLoading(false); + }); + }, [projectId]); + + const code = ` `; - const nextJsEmbedCode = `// Add this to your Next.js page or layout -import Script from 'next/script' + return
+

+ To use the chat widget, copy and paste this code snippet just before the closing </body> tag of your website: +

+ {loading && } + {!loading &&