diff --git a/apps/rowboat/app/actions/project_actions.ts b/apps/rowboat/app/actions/project_actions.ts index 22e41161..b1aa9b8b 100644 --- a/apps/rowboat/app/actions/project_actions.ts +++ b/apps/rowboat/app/actions/project_actions.ts @@ -1,24 +1,31 @@ 'use server'; import { redirect } from "next/navigation"; import { ObjectId } from "mongodb"; -import { db, dataSourcesCollection, embeddingsCollection, projectsCollection, projectMembersCollection, apiKeysCollection, dataSourceDocsCollection } from "../lib/mongodb"; +import { db, dataSourcesCollection, projectsCollection, projectMembersCollection, dataSourceDocsCollection } from "../lib/mongodb"; import { z } from 'zod'; import crypto from 'crypto'; import { revalidatePath } from "next/cache"; import { templates } from "../lib/project_templates"; import { authCheck } from "./auth_actions"; import { User, WithStringId } from "../lib/types/types"; -import { ApiKey } from "../lib/types/project_types"; +import { ApiKey } from "@/src/entities/models/api-key"; import { Project } from "../lib/types/project_types"; import { USE_AUTH } from "../lib/feature_flags"; import { authorizeUserAction } from "./billing_actions"; import { Workflow } from "../lib/types/workflow_types"; import { container } from "@/di/container"; import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy"; - +import { ICreateApiKeyController } from "@/src/interface-adapters/controllers/api-keys/create-api-key.controller"; +import { IListApiKeysController } from "@/src/interface-adapters/controllers/api-keys/list-api-keys.controller"; +import { IDeleteApiKeyController } from "@/src/interface-adapters/controllers/api-keys/delete-api-key.controller"; +import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface"; const KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || ''; const projectActionAuthorizationPolicy = container.resolve('projectActionAuthorizationPolicy'); +const createApiKeyController = container.resolve('createApiKeyController'); +const listApiKeysController = container.resolve('listApiKeysController'); +const deleteApiKeyController = container.resolve('deleteApiKeyController'); +const apiKeysRepository = container.resolve('apiKeysRepository'); export async function listTemplates() { const templatesArray = Object.entries(templates) @@ -180,36 +187,32 @@ export async function updateWebhookUrl(projectId: string, url: string) { ); } -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 = { +export async function createApiKey(projectId: string): Promise> { + const user = await authCheck(); + return await createApiKeyController.execute({ + caller: 'user', + userId: user._id, 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) }); + const user = await authCheck(); + return await deleteApiKeyController.execute({ + caller: 'user', + userId: user._id, + projectId, + 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 listApiKeys(projectId: string): Promise[]> { + const user = await authCheck(); + return await listApiKeysController.execute({ + caller: 'user', + userId: user._id, + projectId, + }); } export async function updateProjectName(projectId: string, name: string) { @@ -227,9 +230,7 @@ export async function deleteProject(projectId: string) { await projectAuthCheck(projectId); // delete api keys - await apiKeysCollection.deleteMany({ - projectId, - }); + await apiKeysRepository.deleteAll(projectId); // delete embeddings const sources = await dataSourcesCollection.find({ @@ -243,9 +244,6 @@ export async function deleteProject(projectId: string) { 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, diff --git a/apps/rowboat/app/lib/components/input-field.tsx b/apps/rowboat/app/lib/components/input-field.tsx index 594a2db4..42ef48a6 100644 --- a/apps/rowboat/app/lib/components/input-field.tsx +++ b/apps/rowboat/app/lib/components/input-field.tsx @@ -248,7 +248,7 @@ function TextInputField({ {/* Input field */} {mentions ? ( -
+
!locked && !disabled && setIsEditing(true)} diff --git a/apps/rowboat/app/lib/mongodb.ts b/apps/rowboat/app/lib/mongodb.ts index e47555f5..7461d3c4 100644 --- a/apps/rowboat/app/lib/mongodb.ts +++ b/apps/rowboat/app/lib/mongodb.ts @@ -1,7 +1,7 @@ import { MongoClient } from "mongodb"; import { User, Webpage } from "./types/types"; import { Workflow } from "./types/workflow_types"; -import { ApiKey } from "./types/project_types"; +import { ApiKey } from "@/src/entities/models/api-key"; import { ProjectMember } from "./types/project_types"; import { Project } from "./types/project_types"; import { EmbeddingDoc } from "./types/datasource_types"; @@ -16,12 +16,9 @@ const client = new MongoClient(process.env["MONGODB_CONNECTION_STRING"] || "mong export const db = client.db("rowboat"); export const dataSourcesCollection = db.collection>("sources"); export const dataSourceDocsCollection = db.collection>("source_docs"); -export const embeddingsCollection = db.collection>("embeddings"); export const projectsCollection = db.collection>("projects"); export const projectMembersCollection = db.collection>("project_members"); -export const webpagesCollection = db.collection>('webpages'); export const agentWorkflowsCollection = db.collection>("agent_workflows"); -export const apiKeysCollection = db.collection>("api_keys"); export const chatsCollection = db.collection>("chats"); export const chatMessagesCollection = db.collection>("chat_messages"); export const twilioConfigsCollection = db.collection>("twilio_configs"); diff --git a/apps/rowboat/app/lib/types/project_types.ts b/apps/rowboat/app/lib/types/project_types.ts index 9dd1957d..18e37af6 100644 --- a/apps/rowboat/app/lib/types/project_types.ts +++ b/apps/rowboat/app/lib/types/project_types.ts @@ -41,11 +41,4 @@ export const ProjectMember = z.object({ projectId: z.string(), createdAt: z.string().datetime(), lastUpdatedAt: z.string().datetime(), -}); - -export const ApiKey = z.object({ - projectId: z.string(), - key: z.string(), - createdAt: z.string().datetime(), - lastUsedAt: z.string().datetime().optional(), }); \ No newline at end of file diff --git a/apps/rowboat/app/projects/[projectId]/config/app.tsx b/apps/rowboat/app/projects/[projectId]/config/app.tsx index e7b0d26b..30698143 100644 --- a/apps/rowboat/app/projects/[projectId]/config/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/config/app.tsx @@ -4,14 +4,10 @@ import { Metadata } from "next"; import { Spinner, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Textarea } from "@heroui/react"; import { Button } from "@/components/ui/button"; import { ReactNode, useEffect, useState } from "react"; -import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions"; +import { getProjectConfig, updateProjectName, updateWebhookUrl, deleteProject, rotateSecret } from "../../../actions/project_actions"; import { CopyButton } from "../../../../components/common/copy-button"; import { InputField } from "../../../lib/components/input-field"; import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react"; -import { WithStringId } from "../../../lib/types/types"; -import { ApiKey } from "../../../lib/types/project_types"; -import { z } from "zod"; -import { RelativeTime } from "@primer/react"; import { Label } from "../../../lib/components/label"; import { FormSection } from "../../../lib/components/form-section"; import { Panel } from "@/components/common/panel-common"; @@ -108,156 +104,6 @@ export function BasicSettingsSection({ ; } -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) => ( -
-
- -
-
- -
-
- {key.lastUsedAt ? : 'Never'} -
-
- - - - - - handleDeleteKey(key._id)} - > - Delete - - - -
-
- ))} - {keys.length === 0 && ( -
- No API keys created yet -
- )} -
-
} -
-
; -} - export function SecretSection({ projectId, }: { diff --git a/apps/rowboat/app/projects/[projectId]/config/components/project.tsx b/apps/rowboat/app/projects/[projectId]/config/components/project.tsx index 4c2c9661..8b375b62 100644 --- a/apps/rowboat/app/projects/[projectId]/config/components/project.tsx +++ b/apps/rowboat/app/projects/[projectId]/config/components/project.tsx @@ -7,7 +7,7 @@ import { getProjectConfig, createApiKey, deleteApiKey, listApiKeys, deleteProjec import { CopyButton } from "../../../../../components/common/copy-button"; import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { WithStringId } from "../../../../lib/types/types"; -import { ApiKey } from "../../../../lib/types/project_types"; +import { ApiKey } from "@/src/entities/models/api-key"; import { z } from "zod"; import { RelativeTime } from "@primer/react"; import { Label } from "../../../../lib/components/label"; @@ -224,7 +224,7 @@ function ApiKeyDisplay({ apiKey, onDelete }: { apiKey: string; onDelete: () => v } function ApiKeysSection({ projectId }: { projectId: string }) { - const [keys, setKeys] = useState>[]>([]); + const [keys, setKeys] = useState[]>([]); const [loading, setLoading] = useState(true); const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; @@ -270,7 +270,7 @@ function ApiKeysSection({ projectId }: { projectId: string }) { try { setLoading(true); await deleteApiKey(projectId, id); - setKeys(keys.filter((k) => k._id !== id)); + setKeys(keys.filter((k) => k.id !== id)); setMessage({ type: 'info', text: 'API key deleted successfully', @@ -325,11 +325,11 @@ function ApiKeysSection({ projectId }: { projectId: string }) { )} {keys.map((key) => ( -
+
handleDeleteKey(key._id)} + onDelete={() => handleDeleteKey(key.id)} />
diff --git a/apps/rowboat/app/projects/[projectId]/copilot/app.tsx b/apps/rowboat/app/projects/[projectId]/copilot/app.tsx index cd50a10e..743f6a55 100644 --- a/apps/rowboat/app/projects/[projectId]/copilot/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/copilot/app.tsx @@ -7,11 +7,11 @@ import { CopilotMessage } from "../../../lib/types/copilot_types"; import { Workflow } from "@/app/lib/types/workflow_types"; import { DataSource } from "@/app/lib/types/datasource_types"; import { z } from "zod"; -import { Action as WorkflowDispatch } from "../workflow/workflow_editor"; +import { Action as WorkflowDispatch } from "@/app/projects/[projectId]/workflow/workflow_editor"; import { Panel } from "@/components/common/panel-common"; import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot"; import { Messages } from "./components/messages"; -import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon } from "lucide-react"; +import { CopyIcon, CheckIcon, PlusIcon, XIcon, InfoIcon, Sparkles } from "lucide-react"; import { useCopilot } from "./use-copilot"; import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal"; import { WithStringId } from "@/app/lib/types/types"; @@ -225,7 +225,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
)} -
+
{responseError && (

{responseError}

@@ -322,16 +322,11 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void variant="copilot" tourTarget="copilot" showWelcome={messages.length === 0} - title={ -
-
-
- Skipper -
- - - -
+ icon={} + title="Skipper" + subtitle="Build your assistant" + rightActions={ +
-
- } - rightActions={ -
- ); - // Utility to filter out divider/empty markdown blocks function isNonDividerMarkdown(content: string) { const trimmed = content.trim(); @@ -435,9 +374,6 @@ function AssistantMessage({ ); } - // Restore panelOpen state if missing - const [panelOpen, setPanelOpen] = useState(false); // collapsed by default - // At the end of the render, call onStatusBarChange with the current status bar props // Track the latest status bar info const latestStatusBar = useRef(null); diff --git a/apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx b/apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx index b3b8045a..f5d3a5f7 100644 --- a/apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx +++ b/apps/rowboat/app/projects/[projectId]/entities/agent_config.tsx @@ -363,11 +363,12 @@ export function AgentConfig({ }); showSavedMessage(); }} + placeholder="Type agent instructions..." markdown multiline mentions mentionsAtValues={atMentions} - className="h-full min-h-0 overflow-auto !mb-0 !mt-0" + className="h-full min-h-0 overflow-auto !mb-0 !mt-0 min-h-[300px]" />
{/* Examples Section */} diff --git a/apps/rowboat/app/projects/[projectId]/playground/app.tsx b/apps/rowboat/app/projects/[projectId]/playground/app.tsx index 5366555b..c9b5978f 100644 --- a/apps/rowboat/app/projects/[projectId]/playground/app.tsx +++ b/apps/rowboat/app/projects/[projectId]/playground/app.tsx @@ -7,7 +7,7 @@ import { Chat } from "./components/chat"; import { Panel } from "@/components/common/panel-common"; import { Button } from "@/components/ui/button"; import { Tooltip } from "@heroui/react"; -import { CheckIcon, CopyIcon, PlusIcon, InfoIcon, BugIcon, BugOffIcon } from "lucide-react"; +import { CheckIcon, CopyIcon, PlusIcon, InfoIcon, BugIcon, BugOffIcon, MessageCircle } from "lucide-react"; export function App({ hidden = false, @@ -56,16 +56,11 @@ export function App({ className={`${hidden ? 'hidden' : 'block'}`} variant="playground" tourTarget="playground" - title={ -
-
-
- Playground -
- - - -
+ icon={} + title="Playground" + subtitle="Chat with your assistant" + rightActions={ +
-
- } - rightActions={ -
setSearchQuery(e.target.value)} className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md @@ -173,6 +189,8 @@ export function SelectComposioToolkit({
{filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'} + {filterByTriggers && ' with triggers'} + {filterByTools && ' with tools'}
@@ -204,6 +222,7 @@ export function SelectComposioToolkit({ isConnected={isConnected} workflowTools={tools} onSelectToolkit={() => handleSelectToolkit(toolkit)} + showTriggerCounts={filterByTriggers} /> ); })} @@ -212,7 +231,14 @@ export function SelectComposioToolkit({ {filteredToolkits.length === 0 && !loading && (

- {searchQuery ? 'No toolkits found matching your search.' : 'No toolkits available.'} + {searchQuery + ? 'No toolkits found matching your search.' + : filterByTriggers + ? 'No toolkits with triggers available.' + : filterByTools + ? 'No toolkits with tools available.' + : 'No toolkits available.' + }

)} diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx index f6457c30..9b2b2a13 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolkitCard.tsx @@ -30,6 +30,7 @@ interface ToolkitCardProps { isConnected: boolean; onSelectToolkit: () => void; workflowTools: z.infer; + showTriggerCounts?: boolean; // New prop to show trigger counts instead of tool counts } export function ToolkitCard({ @@ -37,6 +38,7 @@ export function ToolkitCard({ isConnected, onSelectToolkit, workflowTools, + showTriggerCounts = false, }: ToolkitCardProps) { const handleCardClick = useCallback(() => { onSelectToolkit(); @@ -69,9 +71,11 @@ export function ToolkitCard({ variant="faded" size="sm" > - {selectedToolsCount > 0 - ? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected` - : `${toolkit.meta.tools_count} tools` + {showTriggerCounts + ? `${toolkit.meta.triggers_count} triggers` + : selectedToolsCount > 0 + ? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected` + : `${toolkit.meta.tools_count} tools` }
diff --git a/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx b/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx index 908dcb4f..ed8b141b 100644 --- a/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx +++ b/apps/rowboat/app/projects/[projectId]/tools/components/ToolsConfig.tsx @@ -73,6 +73,7 @@ export function ToolsConfig({ tools={tools} onSelectToolkit={handleSelectToolkit} initialToolkitSlug={initialToolkitSlug} + filterByTools={true} />
diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx new file mode 100644 index 00000000..9b5122f9 --- /dev/null +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TopBar.tsx @@ -0,0 +1,210 @@ +"use client"; +import React from "react"; +import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Spinner, Tooltip, Input } from "@heroui/react"; +import { RadioIcon, RedoIcon, UndoIcon, RocketIcon, PenLine, AlertTriangle, DownloadIcon, SettingsIcon, ChevronDownIcon, ZapIcon } from "lucide-react"; + +interface TopBarProps { + localProjectName: string; + projectNameError: string | null; + onProjectNameChange: (value: string) => void; + publishing: boolean; + isLive: boolean; + showCopySuccess: boolean; + canUndo: boolean; + canRedo: boolean; + showCopilot: boolean; + onUndo: () => void; + onRedo: () => void; + onDownloadJSON: () => void; + onPublishWorkflow: () => void; + onChangeMode: (mode: 'draft' | 'live') => void; + onRevertToLive: () => void; + onToggleCopilot: () => void; + onSettingsModalOpen: () => void; + onTriggersModalOpen: () => void; +} + +export function TopBar({ + localProjectName, + projectNameError, + onProjectNameChange, + publishing, + isLive, + showCopySuccess, + canUndo, + canRedo, + showCopilot, + onUndo, + onRedo, + onDownloadJSON, + onPublishWorkflow, + onChangeMode, + onRevertToLive, + onToggleCopilot, + onSettingsModalOpen, + onTriggersModalOpen, +}: TopBarProps) { + return ( +
+
+
+ {/* Project Name Editor */} +
+ onProjectNameChange(e.target.value)} + isInvalid={!!projectNameError} + errorMessage={projectNameError} + placeholder="Project name..." + variant="bordered" + size="sm" + classNames={{ + base: "max-w-xs", + input: "text-base font-semibold px-2", + inputWrapper: "min-h-[28px] h-[28px] border-gray-200 dark:border-gray-700 px-0" + }} + /> +
+ +
+ +
+ {publishing && } + {isLive &&
+ + Live workflow +
} + {!isLive &&
+ + Draft workflow +
} + + {/* Download JSON icon button, with tooltip, to the left of the menu */} + + + +
+
+ {showCopySuccess &&
+
Copied to clipboard
+
} +
+ {isLive &&
+
+ + This version is locked. Changes applied will not be reflected. +
+
} + + {!isLive && <> + + + } + + {/* Deploy CTA - always visible */} +
+ + + + + + + } + onPress={onSettingsModalOpen} + > + API & SDK settings + + } + onPress={onTriggersModalOpen} + > + Manage triggers + + {!isLive ? ( + <> + } + onPress={() => onChangeMode('live')} + > + View live version + + } + onPress={onRevertToLive} + className="text-red-600 dark:text-red-400" + > + Reset to live version + + + ) : null} + + +
+ + {isLive &&
+ +
} + + {!isLive && } +
+
+
+ ); +} diff --git a/apps/rowboat/app/projects/[projectId]/workflow/components/TriggersModal.tsx b/apps/rowboat/app/projects/[projectId]/workflow/components/TriggersModal.tsx index 8a0b05fa..ad7f4459 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/components/TriggersModal.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/components/TriggersModal.tsx @@ -299,6 +299,7 @@ export function TriggersModal({ tools={[]} // Empty array since we're not using this for tools onSelectToolkit={handleSelectToolkit} initialToolkitSlug={null} + filterByTriggers={true} />
); diff --git a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx index b51a6a68..9e8bdf6a 100644 --- a/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx +++ b/apps/rowboat/app/projects/[projectId]/workflow/workflow_editor.tsx @@ -38,6 +38,7 @@ import { InputField } from "@/app/lib/components/input-field"; import { VoiceSection } from "../config/components/voice"; import { ChatWidgetSection } from "../config/components/project"; import { TriggersModal } from "./components/TriggersModal"; +import { TopBar } from "./components/TopBar"; enablePatches(); @@ -1221,161 +1222,31 @@ export function WorkflowEditor({ onSelectTool: handleSelectTool, onSelectPrompt: handleSelectPrompt, }}> -
-
-
- {/* Project Name Editor */} -
- -
- -
- -
- {state.present.publishing && } - {isLive &&
- - Live workflow -
} - {!isLive &&
- - Draft workflow -
} - - {/* Download JSON icon button, with tooltip, to the left of the menu */} - - - -
-
- {showCopySuccess &&
-
Copied to clipboard
-
} -
- {isLive &&
-
- - This version is locked. Changes applied will not be reflected. -
-
} - - {!isLive && <> - - - } - - {/* Deploy CTA - always visible */} -
- - - - - - - } - onPress={onSettingsModalOpen} - > - API & SDK settings - - } - onPress={onTriggersModalOpen} - > - Manage triggers - - {!isLive ? ( - <> - } - onPress={() => onChangeMode('live')} - > - View live version - - } - onPress={handleRevertToLive} - className="text-red-600 dark:text-red-400" - > - Reset to live version - - - ) : null} - - -
- - {isLive &&
- -
} - - {!isLive && } -
-
- +
+ {/* Top Bar - Isolated like sidebar */} + 0} + canRedo={state.currentIndex < state.patches.length} + showCopilot={showCopilot} + onUndo={() => dispatch({ type: "undo" })} + onRedo={() => dispatch({ type: "redo" })} + onDownloadJSON={handleDownloadJSON} + onPublishWorkflow={handlePublishWorkflow} + onChangeMode={onChangeMode} + onRevertToLive={handleRevertToLive} + onToggleCopilot={() => setShowCopilot(!showCopilot)} + onSettingsModalOpen={onSettingsModalOpen} + onTriggersModalOpen={onTriggersModalOpen} + /> + + {/* Content Area */} +
{/* Sidebar with improved shadow and blur */} -
+
{/* Main content area */} -
- {billingPastDue &&
+
+ {billingPastDue &&
Your subscription is past due. Please update your payment information to avoid losing access to your projects.
} -
- {children} -
+ {children}
); diff --git a/apps/rowboat/app/styles/quill-mentions.css b/apps/rowboat/app/styles/quill-mentions.css index 17a9a4e2..0e7f0951 100644 --- a/apps/rowboat/app/styles/quill-mentions.css +++ b/apps/rowboat/app/styles/quill-mentions.css @@ -70,6 +70,12 @@ .ql-editor { font-size: 1rem !important; /* Increase base font size */ line-height: 1.6 !important; /* Adjust line height for better readability */ + min-height: 300px !important; /* Set minimum height for better editing experience */ +} + +/* Quill editor container height */ +.ql-container { + min-height: 300px !important; /* Ensure the container also has minimum height */ } /* Keep the rendered markdown view at its original size */ diff --git a/apps/rowboat/components/common/compose-box-copilot.tsx b/apps/rowboat/components/common/compose-box-copilot.tsx index eb6c6f5e..31e49563 100644 --- a/apps/rowboat/components/common/compose-box-copilot.tsx +++ b/apps/rowboat/components/common/compose-box-copilot.tsx @@ -100,13 +100,12 @@ export function ComposeBoxCopilot({ disabled={loading} placeholder="Type a message..." autoResize={true} - maxHeight={120} + maxHeight={200} className={` - min-h-0! + min-h-6 border-0! shadow-none! ring-0! bg-transparent resize-none - overflow-y-auto [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-gray-300 @@ -294,7 +293,7 @@ function CopilotStatusBar({ ); }; return ( -
+
{/* Left: context + status/ticker, flex-1, truncate as needed */}
{renderContext()} diff --git a/apps/rowboat/components/common/panel-common.tsx b/apps/rowboat/components/common/panel-common.tsx index 435bb5e3..87228150 100644 --- a/apps/rowboat/components/common/panel-common.tsx +++ b/apps/rowboat/components/common/panel-common.tsx @@ -33,6 +33,8 @@ export function ActionButton({ interface PanelProps { title: React.ReactNode; + subtitle?: string; + icon?: React.ReactNode; rightActions?: React.ReactNode; actions?: React.ReactNode; children: React.ReactNode; @@ -47,6 +49,8 @@ interface PanelProps { export function Panel({ title, + subtitle, + icon, rightActions, actions, children, @@ -65,8 +69,8 @@ export function Panel({ "flex flex-col rounded-xl border relative w-full", // Only apply overflow-hidden if no custom overflow is set (for backward compatibility) overflow ? undefined : "overflow-hidden", - variant === 'copilot' ? "border-blue-200 dark:border-blue-800" : "border-zinc-200 dark:border-zinc-800", - "bg-white dark:bg-zinc-900", + variant === 'copilot' ? "border-transparent" : "border-zinc-200 dark:border-zinc-800", + variant === 'copilot' ? "bg-zinc-50 dark:bg-zinc-900" : "bg-white dark:bg-zinc-900", maxHeight ? "max-h-(--panel-height)" : "h-full", className )} @@ -93,7 +97,8 @@ export function Panel({ )}
- {title} + {icon && icon} +
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+
+ {rightActions} + + ) : variant === 'playground' ? ( + <> +
+ {icon && icon} +
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
{rightActions} diff --git a/apps/rowboat/components/ui/textarea.tsx b/apps/rowboat/components/ui/textarea.tsx index 83df5fea..e3ac1390 100644 --- a/apps/rowboat/components/ui/textarea.tsx +++ b/apps/rowboat/components/ui/textarea.tsx @@ -47,7 +47,6 @@ export const Textarea = forwardRef(({ } }, [propValue, isEditing]); - /* useEffect(() => { if (!autoResize) return; @@ -69,7 +68,6 @@ export const Textarea = forwardRef(({ window.addEventListener('resize', adjustHeight); return () => window.removeEventListener('resize', adjustHeight); }, [localValue, autoResize, maxHeight, textareaRef]); - */ const validateAndUpdate = (value: string) => { if (validate) { diff --git a/apps/rowboat/di/container.ts b/apps/rowboat/di/container.ts index d3a44ac2..ab40d5db 100644 --- a/apps/rowboat/di/container.ts +++ b/apps/rowboat/di/container.ts @@ -64,6 +64,14 @@ import { ListRecurringJobRulesController } from "@/src/interface-adapters/contro import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller"; import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller"; +// API Keys +import { CreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case"; +import { ListApiKeysUseCase } from "@/src/application/use-cases/api-keys/list-api-keys.use-case"; +import { DeleteApiKeyUseCase } from "@/src/application/use-cases/api-keys/delete-api-key.use-case"; +import { CreateApiKeyController } from "@/src/interface-adapters/controllers/api-keys/create-api-key.controller"; +import { ListApiKeysController } from "@/src/interface-adapters/controllers/api-keys/list-api-keys.controller"; +import { DeleteApiKeyController } from "@/src/interface-adapters/controllers/api-keys/delete-api-key.controller"; + export const container = createContainer({ injectionMode: InjectionMode.PROXY, strict: true, @@ -96,6 +104,12 @@ container.register({ // api keys // --- apiKeysRepository: asClass(MongoDBApiKeysRepository).singleton(), + createApiKeyUseCase: asClass(CreateApiKeyUseCase).singleton(), + listApiKeysUseCase: asClass(ListApiKeysUseCase).singleton(), + deleteApiKeyUseCase: asClass(DeleteApiKeyUseCase).singleton(), + createApiKeyController: asClass(CreateApiKeyController).singleton(), + listApiKeysController: asClass(ListApiKeysController).singleton(), + deleteApiKeyController: asClass(DeleteApiKeyController).singleton(), // jobs // --- diff --git a/apps/rowboat/src/application/repositories/api-keys.repository.interface.ts b/apps/rowboat/src/application/repositories/api-keys.repository.interface.ts index 383443d0..b33b637f 100644 --- a/apps/rowboat/src/application/repositories/api-keys.repository.interface.ts +++ b/apps/rowboat/src/application/repositories/api-keys.repository.interface.ts @@ -1,3 +1,47 @@ +import { PaginatedList } from "@/src/entities/common/paginated-list"; +import { ApiKey } from "@/src/entities/models/api-key"; +import { z } from "zod"; + +export const CreateSchema = ApiKey.pick({ + projectId: true, + key: true, +}); + +// Interface for repository operations related to API keys. export interface IApiKeysRepository { + /** + * Creates a new API key for a given project. + * @param data - The data required to create an API key (projectId and key). + * @returns The created ApiKey object. + */ + create(data: z.infer): Promise>; + + /** + * Lists all API keys for a given project. + * @param projectId - The ID of the project whose API keys are to be listed. + * @returns A list of ApiKey objects. + */ + listAll(projectId: string): Promise[]>; + + /** + * Deletes an API key by its ID for a given project. + * @param projectId - The ID of the project. + * @param id - The ID of the API key to delete. + * @returns True if the key was deleted, false if not found. + */ + delete(projectId: string, id: string): Promise; + + /** + * Deletes all API keys for a given project. + * @param projectId - The ID of the project. + */ + deleteAll(projectId: string): Promise; + + /** + * Checks if an API key is valid for a project and consumes it (e.g., for rate limiting or one-time use). + * @param projectId - The ID of the project. + * @param apiKey - The API key to check and consume. + * @returns True if the key is valid and was consumed, false otherwise. + */ checkAndConsumeKey(projectId: string, apiKey: string): Promise; } \ No newline at end of file diff --git a/apps/rowboat/src/application/use-cases/api-keys/create-api-key.use-case.ts b/apps/rowboat/src/application/use-cases/api-keys/create-api-key.use-case.ts new file mode 100644 index 00000000..ec2e92d4 --- /dev/null +++ b/apps/rowboat/src/application/use-cases/api-keys/create-api-key.use-case.ts @@ -0,0 +1,59 @@ +import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface"; +import { z } from "zod"; +import { ApiKey } from "@/src/entities/models/api-key"; +import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy"; +import crypto from "crypto"; +import { BadRequestError } from "@/src/entities/errors/common"; + +const inputSchema = z.object({ + caller: z.enum(["user", "api"]), + userId: z.string().optional(), + apiKey: z.string().optional(), + projectId: z.string(), +}); + +export class MaxKeysReachedError extends BadRequestError { + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + } +} + +export interface ICreateApiKeyUseCase { + execute(data: z.infer): Promise>; +} + +export class CreateApiKeyUseCase implements ICreateApiKeyUseCase { + private readonly apiKeysRepository: IApiKeysRepository; + private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy; + + constructor({ + apiKeysRepository, + projectActionAuthorizationPolicy, + }: { + apiKeysRepository: IApiKeysRepository, + projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy, + }) { + this.apiKeysRepository = apiKeysRepository; + this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy; + } + + async execute(data: z.infer): Promise> { + const { caller, userId, apiKey, projectId } = data; + await this.projectActionAuthorizationPolicy.authorize({ + caller, + userId, + apiKey, + projectId, + }); + + // count existing keys + const keys = await this.apiKeysRepository.listAll(projectId); + if (keys.length >= 3) { + throw new MaxKeysReachedError("You can only have up to 3 API keys per project."); + } + + // Generate a random key using crypto + const key = crypto.randomBytes(32).toString('hex'); + return await this.apiKeysRepository.create({ projectId, key }); + } +} diff --git a/apps/rowboat/src/application/use-cases/api-keys/delete-api-key.use-case.ts b/apps/rowboat/src/application/use-cases/api-keys/delete-api-key.use-case.ts new file mode 100644 index 00000000..3a9db601 --- /dev/null +++ b/apps/rowboat/src/application/use-cases/api-keys/delete-api-key.use-case.ts @@ -0,0 +1,42 @@ +import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface"; +import { z } from "zod"; +import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy"; + +const inputSchema = z.object({ + caller: z.enum(["user", "api"]), + userId: z.string().optional(), + apiKey: z.string().optional(), + projectId: z.string(), + id: z.string(), +}); + +export interface IDeleteApiKeyUseCase { + execute(data: z.infer): Promise; +} + +export class DeleteApiKeyUseCase implements IDeleteApiKeyUseCase { + private readonly apiKeysRepository: IApiKeysRepository; + private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy; + + constructor({ + apiKeysRepository, + projectActionAuthorizationPolicy, + }: { + apiKeysRepository: IApiKeysRepository, + projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy, + }) { + this.apiKeysRepository = apiKeysRepository; + this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy; + } + + async execute(data: z.infer): Promise { + const { caller, userId, apiKey, projectId, id } = data; + await this.projectActionAuthorizationPolicy.authorize({ + caller, + userId, + apiKey, + projectId, + }); + return await this.apiKeysRepository.delete(projectId, id); + } +} diff --git a/apps/rowboat/src/application/use-cases/api-keys/list-api-keys.use-case.ts b/apps/rowboat/src/application/use-cases/api-keys/list-api-keys.use-case.ts new file mode 100644 index 00000000..3e01ca42 --- /dev/null +++ b/apps/rowboat/src/application/use-cases/api-keys/list-api-keys.use-case.ts @@ -0,0 +1,42 @@ +import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface"; +import { z } from "zod"; +import { ApiKey } from "@/src/entities/models/api-key"; +import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy"; + +const inputSchema = z.object({ + caller: z.enum(["user", "api"]), + userId: z.string().optional(), + apiKey: z.string().optional(), + projectId: z.string(), +}); + +export interface IListApiKeysUseCase { + execute(data: z.infer): Promise[]>; +} + +export class ListApiKeysUseCase implements IListApiKeysUseCase { + private readonly apiKeysRepository: IApiKeysRepository; + private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy; + + constructor({ + apiKeysRepository, + projectActionAuthorizationPolicy, + }: { + apiKeysRepository: IApiKeysRepository, + projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy, + }) { + this.apiKeysRepository = apiKeysRepository; + this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy; + } + + async execute(data: z.infer): Promise[]> { + const { caller, userId, apiKey, projectId } = data; + await this.projectActionAuthorizationPolicy.authorize({ + caller, + userId, + apiKey, + projectId, + }); + return await this.apiKeysRepository.listAll(projectId); + } +} diff --git a/apps/rowboat/src/entities/models/api-key.ts b/apps/rowboat/src/entities/models/api-key.ts new file mode 100644 index 00000000..af07b86a --- /dev/null +++ b/apps/rowboat/src/entities/models/api-key.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const ApiKey = z.object({ + id: z.string(), + projectId: z.string(), + key: z.string(), + createdAt: z.string().datetime(), + lastUsedAt: z.string().datetime().optional(), +}); \ No newline at end of file diff --git a/apps/rowboat/src/infrastructure/repositories/mongodb.api-keys.repository.ts b/apps/rowboat/src/infrastructure/repositories/mongodb.api-keys.repository.ts index 657a2461..bd953437 100644 --- a/apps/rowboat/src/infrastructure/repositories/mongodb.api-keys.repository.ts +++ b/apps/rowboat/src/infrastructure/repositories/mongodb.api-keys.repository.ts @@ -1,12 +1,63 @@ import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface"; -import { apiKeysCollection } from "@/app/lib/mongodb"; +import { db } from "@/app/lib/mongodb"; +import { ApiKey } from "@/src/entities/models/api-key"; +import { z } from "zod"; +import { ObjectId } from "mongodb"; +import { CreateSchema } from "@/src/application/repositories/api-keys.repository.interface"; + +const DocSchema = ApiKey + .omit({ + id: true, + }); export class MongoDBApiKeysRepository implements IApiKeysRepository { + private readonly collection = db.collection>("api_keys"); + async checkAndConsumeKey(projectId: string, apiKey: string): Promise { - const result = await apiKeysCollection.findOneAndUpdate( + const result = await this.collection.findOneAndUpdate( { projectId, key: apiKey }, { $set: { lastUsedAt: new Date().toISOString() } } ); return !!result; } + + async create(data: z.infer): Promise> { + const now = new Date().toISOString(); + const _id = new ObjectId(); + + const doc = { + ...data, + createdAt: now, + }; + + const result = await this.collection.insertOne({ + _id, + ...doc, + }); + + return { + ...doc, + id: _id.toString(), + }; + } + + async listAll(projectId: string): Promise[]> { + const results = await this.collection.find({ projectId }).sort({ createdAt: -1 }).toArray(); + return results.map(doc => { + const { _id, ...rest } = doc; + return { + ...rest, + id: _id.toString(), + }; + }); + } + + async delete(projectId: string, id: string): Promise { + const result = await this.collection.deleteOne({ projectId, _id: new ObjectId(id) }); + return result.deletedCount > 0; + } + + async deleteAll(projectId: string): Promise { + await this.collection.deleteMany({ projectId }); + } } \ No newline at end of file diff --git a/apps/rowboat/src/interface-adapters/controllers/api-keys/create-api-key.controller.ts b/apps/rowboat/src/interface-adapters/controllers/api-keys/create-api-key.controller.ts new file mode 100644 index 00000000..b03453cd --- /dev/null +++ b/apps/rowboat/src/interface-adapters/controllers/api-keys/create-api-key.controller.ts @@ -0,0 +1,30 @@ +import { BadRequestError } from "@/src/entities/errors/common"; +import z from "zod"; +import { ApiKey } from "@/src/entities/models/api-key"; +import { ICreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case"; + +const inputSchema = z.object({ + caller: z.enum(["user", "api"]), + userId: z.string().optional(), + apiKey: z.string().optional(), + projectId: z.string(), +}); + +export interface ICreateApiKeyController { + execute(request: z.infer): Promise>; +} + +export class CreateApiKeyController implements ICreateApiKeyController { + private readonly createApiKeyUseCase: ICreateApiKeyUseCase; + constructor({ createApiKeyUseCase }: { createApiKeyUseCase: ICreateApiKeyUseCase }) { + this.createApiKeyUseCase = createApiKeyUseCase; + } + async execute(request: z.infer): Promise> { + const result = inputSchema.safeParse(request); + if (!result.success) { + throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`); + } + return await this.createApiKeyUseCase.execute(result.data); + } +} +export { inputSchema as createApiKeyInputSchema }; diff --git a/apps/rowboat/src/interface-adapters/controllers/api-keys/delete-api-key.controller.ts b/apps/rowboat/src/interface-adapters/controllers/api-keys/delete-api-key.controller.ts new file mode 100644 index 00000000..1381e5c3 --- /dev/null +++ b/apps/rowboat/src/interface-adapters/controllers/api-keys/delete-api-key.controller.ts @@ -0,0 +1,30 @@ +import { BadRequestError } from "@/src/entities/errors/common"; +import z from "zod"; +import { IDeleteApiKeyUseCase } from "@/src/application/use-cases/api-keys/delete-api-key.use-case"; + +const inputSchema = z.object({ + caller: z.enum(["user", "api"]), + userId: z.string().optional(), + apiKey: z.string().optional(), + projectId: z.string(), + id: z.string(), +}); + +export interface IDeleteApiKeyController { + execute(request: z.infer): Promise; +} + +export class DeleteApiKeyController implements IDeleteApiKeyController { + private readonly deleteApiKeyUseCase: IDeleteApiKeyUseCase; + constructor({ deleteApiKeyUseCase }: { deleteApiKeyUseCase: IDeleteApiKeyUseCase }) { + this.deleteApiKeyUseCase = deleteApiKeyUseCase; + } + async execute(request: z.infer): Promise { + const result = inputSchema.safeParse(request); + if (!result.success) { + throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`); + } + return await this.deleteApiKeyUseCase.execute(result.data); + } +} +export { inputSchema as deleteApiKeyInputSchema }; diff --git a/apps/rowboat/src/interface-adapters/controllers/api-keys/list-api-keys.controller.ts b/apps/rowboat/src/interface-adapters/controllers/api-keys/list-api-keys.controller.ts new file mode 100644 index 00000000..6b02aab5 --- /dev/null +++ b/apps/rowboat/src/interface-adapters/controllers/api-keys/list-api-keys.controller.ts @@ -0,0 +1,30 @@ +import { BadRequestError } from "@/src/entities/errors/common"; +import z from "zod"; +import { ApiKey } from "@/src/entities/models/api-key"; +import { IListApiKeysUseCase } from "@/src/application/use-cases/api-keys/list-api-keys.use-case"; + +const inputSchema = z.object({ + caller: z.enum(["user", "api"]), + userId: z.string().optional(), + apiKey: z.string().optional(), + projectId: z.string(), +}); + +export interface IListApiKeysController { + execute(request: z.infer): Promise[]>; +} + +export class ListApiKeysController implements IListApiKeysController { + private readonly listApiKeysUseCase: IListApiKeysUseCase; + constructor({ listApiKeysUseCase }: { listApiKeysUseCase: IListApiKeysUseCase }) { + this.listApiKeysUseCase = listApiKeysUseCase; + } + async execute(request: z.infer): Promise[]> { + const result = inputSchema.safeParse(request); + if (!result.success) { + throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`); + } + return await this.listApiKeysUseCase.execute(result.data); + } +} +export { inputSchema as listApiKeysInputSchema };