Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev

This commit is contained in:
tusharmagar 2025-08-15 13:47:57 +05:30
commit f9d2c31238
31 changed files with 781 additions and 529 deletions

View file

@ -1,24 +1,31 @@
'use server'; 'use server';
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ObjectId } from "mongodb"; 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 { z } from 'zod';
import crypto from 'crypto'; import crypto from 'crypto';
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { templates } from "../lib/project_templates"; import { templates } from "../lib/project_templates";
import { authCheck } from "./auth_actions"; import { authCheck } from "./auth_actions";
import { User, WithStringId } from "../lib/types/types"; 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 { Project } from "../lib/types/project_types";
import { USE_AUTH } from "../lib/feature_flags"; import { USE_AUTH } from "../lib/feature_flags";
import { authorizeUserAction } from "./billing_actions"; import { authorizeUserAction } from "./billing_actions";
import { Workflow } from "../lib/types/workflow_types"; import { Workflow } from "../lib/types/workflow_types";
import { container } from "@/di/container"; import { container } from "@/di/container";
import { IProjectActionAuthorizationPolicy } from "@/src/application/policies/project-action-authorization.policy"; 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 KLAVIS_API_KEY = process.env.KLAVIS_API_KEY || '';
const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy'); const projectActionAuthorizationPolicy = container.resolve<IProjectActionAuthorizationPolicy>('projectActionAuthorizationPolicy');
const createApiKeyController = container.resolve<ICreateApiKeyController>('createApiKeyController');
const listApiKeysController = container.resolve<IListApiKeysController>('listApiKeysController');
const deleteApiKeyController = container.resolve<IDeleteApiKeyController>('deleteApiKeyController');
const apiKeysRepository = container.resolve<IApiKeysRepository>('apiKeysRepository');
export async function listTemplates() { export async function listTemplates() {
const templatesArray = Object.entries(templates) const templatesArray = Object.entries(templates)
@ -180,36 +187,32 @@ export async function updateWebhookUrl(projectId: string, url: string) {
); );
} }
export async function createApiKey(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>> { export async function createApiKey(projectId: string): Promise<z.infer<typeof ApiKey>> {
await projectAuthCheck(projectId); const user = await authCheck();
return await createApiKeyController.execute({
// count existing keys caller: 'user',
const count = await apiKeysCollection.countDocuments({ projectId }); userId: user._id,
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<typeof ApiKey> = {
projectId, projectId,
key, });
createdAt: new Date().toISOString(),
};
await apiKeysCollection.insertOne(doc);
const { _id, ...rest } = doc as WithStringId<z.infer<typeof ApiKey>>;
return { ...rest, _id: _id.toString() };
} }
export async function deleteApiKey(projectId: string, id: string) { export async function deleteApiKey(projectId: string, id: string) {
await projectAuthCheck(projectId); const user = await authCheck();
await apiKeysCollection.deleteOne({ projectId, _id: new ObjectId(id) }); return await deleteApiKeyController.execute({
caller: 'user',
userId: user._id,
projectId,
id,
});
} }
export async function listApiKeys(projectId: string): Promise<WithStringId<z.infer<typeof ApiKey>>[]> { export async function listApiKeys(projectId: string): Promise<z.infer<typeof ApiKey>[]> {
await projectAuthCheck(projectId); const user = await authCheck();
const keys = await apiKeysCollection.find({ projectId }).toArray(); return await listApiKeysController.execute({
return keys.map(k => ({ ...k, _id: k._id.toString() })); caller: 'user',
userId: user._id,
projectId,
});
} }
export async function updateProjectName(projectId: string, name: string) { export async function updateProjectName(projectId: string, name: string) {
@ -227,9 +230,7 @@ export async function deleteProject(projectId: string) {
await projectAuthCheck(projectId); await projectAuthCheck(projectId);
// delete api keys // delete api keys
await apiKeysCollection.deleteMany({ await apiKeysRepository.deleteAll(projectId);
projectId,
});
// delete embeddings // delete embeddings
const sources = await dataSourcesCollection.find({ const sources = await dataSourcesCollection.find({
@ -243,9 +244,6 @@ export async function deleteProject(projectId: string) {
const ids = sources.map(s => s._id); const ids = sources.map(s => s._id);
// delete data sources // delete data sources
await embeddingsCollection.deleteMany({
sourceId: { $in: ids.map(i => i.toString()) },
});
await dataSourcesCollection.deleteMany({ await dataSourcesCollection.deleteMany({
_id: { _id: {
$in: ids, $in: ids,

View file

@ -248,7 +248,7 @@ function TextInputField({
{/* Input field */} {/* Input field */}
{mentions ? ( {mentions ? (
<div className="w-full"> <div className="w-full min-h-[300px]">
<MentionsEditor <MentionsEditor
atValues={mentionsAtValues} atValues={mentionsAtValues}
value={value} value={value}
@ -264,7 +264,7 @@ function TextInputField({
placeholder={placeholder} placeholder={placeholder}
variant="bordered" variant="bordered"
size={getInputSize()} size={getInputSize()}
minRows={3} minRows={12}
maxRows={20} maxRows={20}
isInvalid={!isValid} isInvalid={!isValid}
errorMessage={validationResult?.errorMessage} errorMessage={validationResult?.errorMessage}
@ -321,11 +321,13 @@ function TextInputField({
{/* Content display */} {/* Content display */}
<div <div
className={clsx( className={clsx(
"group relative rounded-lg border border-gray-200 dark:border-gray-700 p-3 min-h-[40px] transition-all duration-200", "group relative rounded-lg border border-gray-200 dark:border-gray-700 p-3 transition-all duration-200",
{ {
"cursor-pointer hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800": !locked && !disabled, "cursor-pointer hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800": !locked && !disabled,
"cursor-not-allowed opacity-60": locked || disabled, "cursor-not-allowed opacity-60": locked || disabled,
"border-0 bg-transparent p-0": inline, "border-0 bg-transparent p-0": inline,
"min-h-[300px]": multiline,
"min-h-[40px]": !multiline,
} }
)} )}
onClick={() => !locked && !disabled && setIsEditing(true)} onClick={() => !locked && !disabled && setIsEditing(true)}

View file

@ -1,7 +1,7 @@
import { MongoClient } from "mongodb"; import { MongoClient } from "mongodb";
import { User, Webpage } from "./types/types"; import { User, Webpage } from "./types/types";
import { Workflow } from "./types/workflow_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 { ProjectMember } from "./types/project_types";
import { Project } from "./types/project_types"; import { Project } from "./types/project_types";
import { EmbeddingDoc } from "./types/datasource_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 db = client.db("rowboat");
export const dataSourcesCollection = db.collection<z.infer<typeof DataSource>>("sources"); export const dataSourcesCollection = db.collection<z.infer<typeof DataSource>>("sources");
export const dataSourceDocsCollection = db.collection<z.infer<typeof DataSourceDoc>>("source_docs"); export const dataSourceDocsCollection = db.collection<z.infer<typeof DataSourceDoc>>("source_docs");
export const embeddingsCollection = db.collection<z.infer<typeof EmbeddingDoc>>("embeddings");
export const projectsCollection = db.collection<z.infer<typeof Project>>("projects"); export const projectsCollection = db.collection<z.infer<typeof Project>>("projects");
export const projectMembersCollection = db.collection<z.infer<typeof ProjectMember>>("project_members"); export const projectMembersCollection = db.collection<z.infer<typeof ProjectMember>>("project_members");
export const webpagesCollection = db.collection<z.infer<typeof Webpage>>('webpages');
export const agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows"); export const agentWorkflowsCollection = db.collection<z.infer<typeof Workflow>>("agent_workflows");
export const apiKeysCollection = db.collection<z.infer<typeof ApiKey>>("api_keys");
export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats"); export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats");
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages"); export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs"); export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");

View file

@ -41,11 +41,4 @@ export const ProjectMember = z.object({
projectId: z.string(), projectId: z.string(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
lastUpdatedAt: 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(),
}); });

View file

@ -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 { Spinner, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Textarea } from "@heroui/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ReactNode, useEffect, useState } from "react"; 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 { CopyButton } from "../../../../components/common/copy-button";
import { InputField } from "../../../lib/components/input-field"; import { InputField } from "../../../lib/components/input-field";
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react"; 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 { Label } from "../../../lib/components/label";
import { FormSection } from "../../../lib/components/form-section"; import { FormSection } from "../../../lib/components/form-section";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
@ -108,156 +104,6 @@ export function BasicSettingsSection({
</Section>; </Section>;
} }
export function ApiKeysSection({
projectId,
}: {
projectId: string;
}) {
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]);
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 <Section title="API keys">
<div className="space-y-4">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
API keys are used to authenticate requests to the Rowboat API.
</p>
<Button
onClick={handleCreateKey}
size="sm"
startContent={<Plus className="h-4 w-4" />}
variant="primary"
disabled={loading}
>
Create API key
</Button>
</div>
<Divider />
{loading && <Spinner size="sm" />}
{!loading && <div className="border border rounded-lg text-sm">
<div className="flex items-center border-b border p-4">
<div className="flex-3 font-normal">API Key</div>
<div className="flex-1 font-normal">Created</div>
<div className="flex-1 font-normal">Last Used</div>
<div className="w-10"></div>
</div>
{message?.type === 'success' && <div className="flex flex-col p-2">
<div className="text-sm bg-green-50 text-green-500 p-2 rounded-md">{message.text}</div>
</div>}
{message?.type === 'error' && <div className="flex flex-col p-2">
<div className="text-sm bg-red-50 text-red-500 p-2 rounded-md">{message.text}</div>
</div>}
{message?.type === 'info' && <div className="flex flex-col p-2">
<div className="text-sm bg-yellow-50 text-yellow-500 p-2 rounded-md">{message.text}</div>
</div>}
<div className="flex flex-col">
{keys.map((key) => (
<div key={key._id} className="flex items-start border-b border last:border-b-0 p-4">
<div className="flex-3 p-2">
<ApiKeyDisplay apiKey={key.key} />
</div>
<div className="flex-1 p-2">
<RelativeTime date={new Date(key.createdAt)} />
</div>
<div className="flex-1 p-2">
{key.lastUsedAt ? <RelativeTime date={new Date(key.lastUsedAt)} /> : 'Never'}
</div>
<div className="w-10 p-2">
<Dropdown>
<DropdownTrigger>
<button className="text-muted-foreground hover:text-foreground">
<MoreVertical className="h-4 w-4" />
</button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key='delete'
className="text-destructive"
onPress={() => handleDeleteKey(key._id)}
>
Delete
</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
))}
{keys.length === 0 && (
<div className="p-4 text-center text-muted-foreground">
No API keys created yet
</div>
)}
</div>
</div>}
</div>
</Section>;
}
export function SecretSection({ export function SecretSection({
projectId, projectId,
}: { }: {

View file

@ -7,7 +7,7 @@ import { getProjectConfig, createApiKey, deleteApiKey, listApiKeys, deleteProjec
import { CopyButton } from "../../../../../components/common/copy-button"; import { CopyButton } from "../../../../../components/common/copy-button";
import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { EyeIcon, EyeOffIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { WithStringId } from "../../../../lib/types/types"; 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 { z } from "zod";
import { RelativeTime } from "@primer/react"; import { RelativeTime } from "@primer/react";
import { Label } from "../../../../lib/components/label"; import { Label } from "../../../../lib/components/label";
@ -224,7 +224,7 @@ function ApiKeyDisplay({ apiKey, onDelete }: { apiKey: string; onDelete: () => v
} }
function ApiKeysSection({ projectId }: { projectId: string }) { function ApiKeysSection({ projectId }: { projectId: string }) {
const [keys, setKeys] = useState<WithStringId<z.infer<typeof ApiKey>>[]>([]); const [keys, setKeys] = useState<z.infer<typeof ApiKey>[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{ const [message, setMessage] = useState<{
type: 'success' | 'error' | 'info'; type: 'success' | 'error' | 'info';
@ -270,7 +270,7 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
try { try {
setLoading(true); setLoading(true);
await deleteApiKey(projectId, id); await deleteApiKey(projectId, id);
setKeys(keys.filter((k) => k._id !== id)); setKeys(keys.filter((k) => k.id !== id));
setMessage({ setMessage({
type: 'info', type: 'info',
text: 'API key deleted successfully', text: 'API key deleted successfully',
@ -325,11 +325,11 @@ function ApiKeysSection({ projectId }: { projectId: string }) {
)} )}
{keys.map((key) => ( {keys.map((key) => (
<div key={key._id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4"> <div key={key.id} className="grid grid-cols-12 items-center border-b border-gray-200 dark:border-gray-700 last:border-0 p-4">
<div className="col-span-7"> <div className="col-span-7">
<ApiKeyDisplay <ApiKeyDisplay
apiKey={key.key} apiKey={key.key}
onDelete={() => handleDeleteKey(key._id)} onDelete={() => handleDeleteKey(key.id)}
/> />
</div> </div>
<div className="col-span-3 text-sm text-gray-500"> <div className="col-span-3 text-sm text-gray-500">

View file

@ -7,11 +7,11 @@ import { CopilotMessage } from "../../../lib/types/copilot_types";
import { Workflow } from "@/app/lib/types/workflow_types"; import { Workflow } from "@/app/lib/types/workflow_types";
import { DataSource } from "@/app/lib/types/datasource_types"; import { DataSource } from "@/app/lib/types/datasource_types";
import { z } from "zod"; 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 { Panel } from "@/components/common/panel-common";
import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot"; import { ComposeBoxCopilot } from "@/components/common/compose-box-copilot";
import { Messages } from "./components/messages"; 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 { useCopilot } from "./use-copilot";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal"; import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
import { WithStringId } from "@/app/lib/types/types"; import { WithStringId } from "@/app/lib/types/types";
@ -225,7 +225,7 @@ const App = forwardRef<{ handleCopyChat: () => void; handleUserMessage: (message
</div> </div>
</div> </div>
)} )}
<div className="shrink-0 px-1 pb-6"> <div className="shrink-0 px-1">
{responseError && ( {responseError && (
<div className="mb-4 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2 justify-between items-center text-sm"> <div className="mb-4 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex gap-2 justify-between items-center text-sm">
<p className="text-red-600 dark:text-red-400">{responseError}</p> <p className="text-red-600 dark:text-red-400">{responseError}</p>
@ -322,16 +322,11 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
variant="copilot" variant="copilot"
tourTarget="copilot" tourTarget="copilot"
showWelcome={messages.length === 0} showWelcome={messages.length === 0}
title={ icon={<Sparkles className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />}
<div className="flex items-center gap-3"> title="Skipper"
<div className="flex items-center gap-2"> subtitle="Build your assistant"
<div className="font-semibold text-zinc-700 dark:text-zinc-300"> rightActions={
Skipper <div className="flex items-center gap-2">
</div>
<Tooltip content="A copilot to help you build and modify your workflow">
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
@ -342,10 +337,6 @@ export const Copilot = forwardRef<{ handleUserMessage: (message: string) => void
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon className="w-4 h-4" />
</Button> </Button>
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Spinner } from "@heroui/react"; import { Spinner } from "@heroui/react";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { z } from "zod"; import { z } from "zod";
import { Workflow} from "@/app/lib/types/workflow_types"; import { Workflow} from "@/app/lib/types/workflow_types";
import MarkdownContent from "@/app/lib/components/markdown-content"; import MarkdownContent from "@/app/lib/components/markdown-content";
@ -153,16 +153,7 @@ function InternalAssistantMessage({ content }: { content: string }) {
); );
} }
type ActionPanelBlock = {
part: {
type: 'action';
action: any;
} | {
type: 'streaming_action';
action: any;
};
actionIndex: number;
};
/** /**
* AssistantMessage component that renders copilot responses with action cards. * AssistantMessage component that renders copilot responses with action cards.
@ -194,26 +185,24 @@ function AssistantMessage({
// Remove autoApplyEnabled and useEffect for auto-apply // Remove autoApplyEnabled and useEffect for auto-apply
// parse actions from parts // parse actions from parts
let parsed: z.infer<typeof CopilotResponsePart>[] = []; const parsed = useMemo(() => {
for (const block of blocks) { const result: z.infer<typeof CopilotResponsePart>[] = [];
if (block.type === 'text') { for (const block of blocks) {
parsed.push({ if (block.type === 'text') {
type: 'text', result.push({
content: block.content, type: 'text',
}); content: block.content,
} else { });
parsed.push(enrich(block.content)); } else {
result.push(enrich(block.content));
}
} }
} return result;
}, [blocks]);
// Only render text outside the panel // Count action cards for tracking
const textBlocks = parsed.filter(part => part.type === 'text'); const actionParts = parsed.filter(part => part.type === 'action' || part.type === 'streaming_action');
// All cards (action and streaming_action) go inside the panel const totalActions = parsed.filter(part => part.type === 'action').length;
const cardBlocks: ActionPanelBlock[] = parsed
.map((part, actionIndex) => ({ part, actionIndex }))
.filter(({ part }) => part.type === 'action' || part.type === 'streaming_action') as ActionPanelBlock[];
const hasCards = cardBlocks.length > 0;
const totalActions = cardBlocks.filter(({ part }) => part.type === 'action').length;
const appliedCount = Array.from(appliedActions).length; const appliedCount = Array.from(appliedActions).length;
const pendingCount = Math.max(0, totalActions - appliedCount); const pendingCount = Math.max(0, totalActions - appliedCount);
const allApplied = pendingCount === 0 && totalActions > 0; const allApplied = pendingCount === 0 && totalActions > 0;
@ -307,9 +296,14 @@ function AssistantMessage({
// Memoized handleApplyAll for useEffect dependencies // Memoized handleApplyAll for useEffect dependencies
const handleApplyAll = useCallback(() => { const handleApplyAll = useCallback(() => {
// Find all unapplied action indices // Find all unapplied action indices
const unapplied = cardBlocks const unapplied = parsed
.map((part, idx) => ({ part, actionIndex: idx }))
.filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex)) .filter(({ part, actionIndex }) => part.type === 'action' && !appliedActions.has(actionIndex))
.map(({ part, actionIndex }) => ({ action: part.action, actionIndex })); .map(({ part, actionIndex }) => ({
action: part.type === 'action' ? part.action : null,
actionIndex
}))
.filter(({ action }) => action !== null);
// Synchronously apply all unapplied actions // Synchronously apply all unapplied actions
unapplied.forEach(({ action, actionIndex }) => { unapplied.forEach(({ action, actionIndex }) => {
@ -322,7 +316,7 @@ function AssistantMessage({
unapplied.forEach(({ actionIndex }) => next.add(actionIndex)); unapplied.forEach(({ actionIndex }) => next.add(actionIndex));
return next; return next;
}); });
}, [cardBlocks, appliedActions, setAppliedActions, applyAction]); }, [parsed, appliedActions, setAppliedActions, applyAction]);
// Manual single apply (from card) // Manual single apply (from card)
const handleSingleApply = (action: any, actionIndex: number) => { const handleSingleApply = (action: any, actionIndex: number) => {
@ -343,37 +337,20 @@ function AssistantMessage({
// Removed useEffect for auto-apply // Removed useEffect for auto-apply
// Find streaming/ongoing card and extract name // Find streaming/ongoing card and extract name
const streamingBlock = cardBlocks.find(({ part }) => part.type === 'streaming_action'); const streamingPart = parsed.find(part => part.type === 'streaming_action');
let streamingLine = ''; let streamingLine = '';
if (streamingBlock && streamingBlock.part.type === 'streaming_action' && streamingBlock.part.action && streamingBlock.part.action.name) { if (streamingPart && streamingPart.type === 'streaming_action' && streamingPart.action && streamingPart.action.name) {
streamingLine = `Generating ${streamingBlock.part.action.name}...`; streamingLine = `Generating ${streamingPart.action.name}...`;
} }
// Find the first card index
const firstCardIdx = parsed.findIndex(part => part.type === 'action' || part.type === 'streaming_action');
// Group blocks into: beforePanel, cardBlocks, afterPanel
const beforePanel = firstCardIdx === -1 ? parsed : parsed.slice(0, firstCardIdx);
const panelBlocks = firstCardIdx === -1 ? [] : parsed.slice(firstCardIdx).filter(part => part.type === 'action' || part.type === 'streaming_action');
// Find where the card blocks end (first non-card after first card)
let afterPanelStart = firstCardIdx;
if (firstCardIdx !== -1) {
for (let i = firstCardIdx; i < parsed.length; i++) {
if (parsed[i].type !== 'action' && parsed[i].type !== 'streaming_action') {
afterPanelStart = i;
break;
}
}
}
const afterPanel = (firstCardIdx !== -1 && afterPanelStart > firstCardIdx) ? parsed.slice(afterPanelStart) : [];
// Only show Apply All button if all cards are loaded (no streaming_action cards) and streaming is finished // Only show Apply All button if all cards are loaded (no streaming_action cards) and streaming is finished
const allCardsLoaded = !loading && panelBlocks.length > 0 && panelBlocks.every(part => part.type === 'action'); const allCardsLoaded = !loading && actionParts.length > 0 && actionParts.every(part => part.type === 'action');
// When all cards are loaded, show summary of agents created/updated // When all cards are loaded, show summary of agents created/updated
let completedSummary = ''; let completedSummary = '';
if (allCardsLoaded && totalActions > 0) { if (allCardsLoaded && totalActions > 0) {
// Count how many are create vs edit // Count how many are create vs edit
const createCount = cardBlocks.filter(({ part }) => part.type === 'action' && part.action.action === 'create_new').length; const createCount = parsed.filter(part => part.type === 'action' && part.action.action === 'create_new').length;
const editCount = cardBlocks.filter(({ part }) => part.type === 'action' && part.action.action === 'edit').length; const editCount = parsed.filter(part => part.type === 'action' && part.action.action === 'edit').length;
const parts = []; const parts = [];
if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`); if (createCount > 0) parts.push(`${createCount} agent${createCount > 1 ? 's' : ''} created`);
if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`); if (editCount > 0) parts.push(`${editCount} agent${editCount > 1 ? 's' : ''} updated`);
@ -381,51 +358,13 @@ function AssistantMessage({
} }
// Detect if any card has an error or is cancelled // Detect if any card has an error or is cancelled
const hasPanelWarning = cardBlocks.some( const hasPanelWarning = parsed.some(
({ part }) => part =>
part.type === 'action' && part.type === 'action' &&
part.action && part.action &&
(part.action.error || ('cancelled' in part.action && part.action.cancelled)) (part.action.error || ('cancelled' in part.action && part.action.cancelled))
); );
// Ticker summary for collapsed state (two lines)
const ticker = (
<div className="flex flex-col">
{allCardsLoaded && completedSummary ? (
<span className="font-medium text-xs sm:text-sm">{completedSummary}</span>
) : streamingLine && (
<span className="font-medium text-xs sm:text-sm">{streamingLine}</span>
)}
<span className="font-medium text-xs sm:text-sm">{appliedCount} applied, {pendingCount} pending</span>
</div>
);
const applyAllButton = (
<button
onClick={handleApplyAll}
disabled={allApplied} // Changed to allApplied
className={`flex items-center gap-2 px-4 py-2 rounded-full font-medium text-sm transition-colors duration-200
${
allApplied
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-400 cursor-not-allowed border border-zinc-200 dark:border-zinc-700 shadow-none'
: 'bg-blue-100 dark:bg-zinc-900 text-blue-700 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-zinc-800 border border-blue-200 dark:border-zinc-800 shadow-sm'
}
`}
>
{allApplied ? (
<>
<CheckCheckIcon size={16} />
All applied!
</>
) : (
<>
<CheckCheckIcon size={16} />
Apply all
</>
)}
</button>
);
// Utility to filter out divider/empty markdown blocks // Utility to filter out divider/empty markdown blocks
function isNonDividerMarkdown(content: string) { function isNonDividerMarkdown(content: string) {
const trimmed = content.trim(); 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 // At the end of the render, call onStatusBarChange with the current status bar props
// Track the latest status bar info // Track the latest status bar info
const latestStatusBar = useRef<any>(null); const latestStatusBar = useRef<any>(null);

View file

@ -363,11 +363,12 @@ export function AgentConfig({
}); });
showSavedMessage(); showSavedMessage();
}} }}
placeholder="Type agent instructions..."
markdown markdown
multiline multiline
mentions mentions
mentionsAtValues={atMentions} 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]"
/> />
</div> </div>
{/* Examples Section */} {/* Examples Section */}

View file

@ -7,7 +7,7 @@ import { Chat } from "./components/chat";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip } from "@heroui/react"; 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({ export function App({
hidden = false, hidden = false,
@ -56,16 +56,11 @@ export function App({
className={`${hidden ? 'hidden' : 'block'}`} className={`${hidden ? 'hidden' : 'block'}`}
variant="playground" variant="playground"
tourTarget="playground" tourTarget="playground"
title={ icon={<MessageCircle className="w-5 h-5 text-blue-600 dark:text-blue-400" />}
<div className="flex items-center gap-3"> title="Playground"
<div className="flex items-center gap-2"> subtitle="Chat with your assistant"
<div className="font-semibold text-zinc-700 dark:text-zinc-300"> rightActions={
Playground <div className="flex items-center gap-2">
</div>
<Tooltip content="Test your workflow and chat with your agents in real-time">
<InfoIcon className="w-4 h-4 text-gray-400 cursor-help" />
</Tooltip>
</div>
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
@ -90,10 +85,6 @@ export function App({
<BugOffIcon className="w-4 h-4" /> <BugOffIcon className="w-4 h-4" />
)} )}
</Button> </Button>
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"

View file

@ -22,13 +22,17 @@ interface SelectComposioToolkitProps {
tools: z.infer<typeof Workflow.shape.tools>; tools: z.infer<typeof Workflow.shape.tools>;
onSelectToolkit: (toolkit: ToolkitType) => void; onSelectToolkit: (toolkit: ToolkitType) => void;
initialToolkitSlug?: string | null; initialToolkitSlug?: string | null;
filterByTriggers?: boolean; // New prop to filter toolkits that have triggers
filterByTools?: boolean; // New prop to filter toolkits that have tools
} }
export function SelectComposioToolkit({ export function SelectComposioToolkit({
projectId, projectId,
tools, tools,
onSelectToolkit, onSelectToolkit,
initialToolkitSlug initialToolkitSlug,
filterByTriggers = false,
filterByTools = false
}: SelectComposioToolkitProps) { }: SelectComposioToolkitProps) {
const [toolkits, setToolkits] = useState<ToolkitType[]>([]); const [toolkits, setToolkits] = useState<ToolkitType[]>([]);
const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null); const [projectConfig, setProjectConfig] = useState<ProjectType | null>(null);
@ -59,17 +63,16 @@ export function SelectComposioToolkit({
cursor = response.next_cursor; cursor = response.next_cursor;
} while (cursor !== null); } while (cursor !== null);
// // Only show those toolkits that // Filter toolkits based on the filter props
// // - either do not require authentication, OR let finalToolkits = allToolkits;
// // - have oauth2 managed by Composio if (filterByTriggers) {
// const filteredToolkits = allToolkits.filter(toolkit => { finalToolkits = finalToolkits.filter(toolkit => toolkit.meta.triggers_count > 0);
// const noAuth = toolkit.no_auth; }
// const hasOAuth2 = toolkit.auth_schemes.includes('OAUTH2'); if (filterByTools) {
// const hasComposioManagedOAuth2 = toolkit.composio_managed_auth_schemes.includes('OAUTH2'); finalToolkits = finalToolkits.filter(toolkit => toolkit.meta.tools_count > 0);
// return noAuth || hasOAuth2; }
// });
setToolkits(allToolkits); setToolkits(finalToolkits);
setError(null); setError(null);
} catch (err: any) { } catch (err: any) {
setError('Unable to load all Composio toolkits. Please check your connection and try again.'); setError('Unable to load all Composio toolkits. Please check your connection and try again.');
@ -78,7 +81,7 @@ export function SelectComposioToolkit({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectId]); }, [projectId, filterByTriggers, filterByTools]);
const handleSelectToolkit = useCallback((toolkit: ToolkitType) => { const handleSelectToolkit = useCallback((toolkit: ToolkitType) => {
onSelectToolkit(toolkit); onSelectToolkit(toolkit);
@ -125,7 +128,14 @@ export function SelectComposioToolkit({
return ( return (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading Composio toolkits...</p> <p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
{filterByTriggers
? 'Loading Composio toolkits with triggers...'
: filterByTools
? 'Loading Composio toolkits with tools...'
: 'Loading Composio toolkits...'
}
</p>
</div> </div>
); );
} }
@ -161,7 +171,13 @@ export function SelectComposioToolkit({
</div> </div>
<input <input
type="text" type="text"
placeholder="Search toolkits..." placeholder={
filterByTriggers
? "Search toolkits with triggers..."
: filterByTools
? "Search toolkits with tools..."
: "Search toolkits..."
}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => 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 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({
</div> </div>
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap"> <div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
{filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'} {filteredToolkits.length} {filteredToolkits.length === 1 ? 'toolkit' : 'toolkits'}
{filterByTriggers && ' with triggers'}
{filterByTools && ' with tools'}
</div> </div>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" /> <div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
</div> </div>
@ -204,6 +222,7 @@ export function SelectComposioToolkit({
isConnected={isConnected} isConnected={isConnected}
workflowTools={tools} workflowTools={tools}
onSelectToolkit={() => handleSelectToolkit(toolkit)} onSelectToolkit={() => handleSelectToolkit(toolkit)}
showTriggerCounts={filterByTriggers}
/> />
); );
})} })}
@ -212,7 +231,14 @@ export function SelectComposioToolkit({
{filteredToolkits.length === 0 && !loading && ( {filteredToolkits.length === 0 && !loading && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500 dark:text-gray-400">
{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.'
}
</p> </p>
</div> </div>
)} )}

View file

@ -30,6 +30,7 @@ interface ToolkitCardProps {
isConnected: boolean; isConnected: boolean;
onSelectToolkit: () => void; onSelectToolkit: () => void;
workflowTools: z.infer<typeof Workflow.shape.tools>; workflowTools: z.infer<typeof Workflow.shape.tools>;
showTriggerCounts?: boolean; // New prop to show trigger counts instead of tool counts
} }
export function ToolkitCard({ export function ToolkitCard({
@ -37,6 +38,7 @@ export function ToolkitCard({
isConnected, isConnected,
onSelectToolkit, onSelectToolkit,
workflowTools, workflowTools,
showTriggerCounts = false,
}: ToolkitCardProps) { }: ToolkitCardProps) {
const handleCardClick = useCallback(() => { const handleCardClick = useCallback(() => {
onSelectToolkit(); onSelectToolkit();
@ -69,9 +71,11 @@ export function ToolkitCard({
variant="faded" variant="faded"
size="sm" size="sm"
> >
{selectedToolsCount > 0 {showTriggerCounts
? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected` ? `${toolkit.meta.triggers_count} triggers`
: `${toolkit.meta.tools_count} tools` : selectedToolsCount > 0
? `${toolkit.meta.tools_count} tools, ${selectedToolsCount} selected`
: `${toolkit.meta.tools_count} tools`
} }
</Chip> </Chip>
</div> </div>

View file

@ -73,6 +73,7 @@ export function ToolsConfig({
tools={tools} tools={tools}
onSelectToolkit={handleSelectToolkit} onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={initialToolkitSlug} initialToolkitSlug={initialToolkitSlug}
filterByTools={true}
/> />
</div> </div>
</Tab> </Tab>

View file

@ -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 (
<div className="rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm border border-zinc-200 dark:border-zinc-800 px-5 py-2">
<div className="flex justify-between items-center">
<div className="workflow-version-selector flex items-center gap-4 px-2 text-gray-800 dark:text-gray-100">
{/* Project Name Editor */}
<div className="flex flex-col min-w-0 max-w-xs">
<Input
type="text"
value={localProjectName}
onChange={(e) => 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"
}}
/>
</div>
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
<div className="flex items-center gap-2">
{publishing && <Spinner size="sm" />}
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
<RadioIcon size={16} />
Live workflow
</div>}
{!isLive && <div className="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1.5">
<PenLine size={16} />
Draft workflow
</div>}
{/* Download JSON icon button, with tooltip, to the left of the menu */}
<Tooltip content="Download Assistant JSON">
<button
onClick={onDownloadJSON}
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
aria-label="Download JSON"
type="button"
>
<DownloadIcon size={20} />
</button>
</Tooltip>
</div>
</div>
{showCopySuccess && <div className="flex items-center gap-2">
<div className="text-green-500">Copied to clipboard</div>
</div>}
<div className="flex items-center gap-2">
{isLive && <div className="flex items-center gap-2">
<div className="bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
<AlertTriangle size={16} />
This version is locked. Changes applied will not be reflected.
</div>
</div>}
{!isLive && <>
<button
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
title="Undo"
disabled={!canUndo}
onClick={onUndo}
>
<UndoIcon size={16} />
</button>
<button
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
title="Redo"
disabled={!canRedo}
onClick={onRedo}
>
<RedoIcon size={16} />
</button>
</>}
{/* Deploy CTA - always visible */}
<div className="flex">
<Button
variant="solid"
size="md"
onPress={onPublishWorkflow}
className="gap-2 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold text-sm rounded-r-none"
startContent={<RocketIcon size={16} />}
data-tour-target="deploy"
>
Deploy
</Button>
<Dropdown>
<DropdownTrigger>
<Button
variant="solid"
size="md"
className="min-w-0 px-2 bg-green-600 hover:bg-green-700 border-l-1 border-green-500 text-white font-semibold text-sm rounded-l-none"
>
<ChevronDownIcon size={14} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Deploy actions">
<DropdownItem
key="settings"
startContent={<SettingsIcon size={16} />}
onPress={onSettingsModalOpen}
>
API & SDK settings
</DropdownItem>
<DropdownItem
key="manage-triggers"
startContent={<ZapIcon size={16} />}
onPress={onTriggersModalOpen}
>
Manage triggers
</DropdownItem>
{!isLive ? (
<>
<DropdownItem
key="view-live"
startContent={<RadioIcon size={16} />}
onPress={() => onChangeMode('live')}
>
View live version
</DropdownItem>
<DropdownItem
key="reset-to-live"
startContent={<AlertTriangle size={16} />}
onPress={onRevertToLive}
className="text-red-600 dark:text-red-400"
>
Reset to live version
</DropdownItem>
</>
) : null}
</DropdownMenu>
</Dropdown>
</div>
{isLive && <div className="flex items-center gap-2">
<Button
variant="solid"
size="md"
onPress={() => onChangeMode('draft')}
className="gap-2 px-4 bg-gray-600 hover:bg-gray-700 text-white font-semibold text-sm"
>
Switch to draft
</Button>
</div>}
{!isLive && <Button
variant="solid"
size="md"
onPress={onToggleCopilot}
className="gap-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-sm"
startContent={showCopilot ? null : <span className="text-indigo-300"></span>}
>
{showCopilot ? "Hide Skipper" : "Skipper"}
</Button>}
</div>
</div>
</div>
);
}

View file

@ -299,6 +299,7 @@ export function TriggersModal({
tools={[]} // Empty array since we're not using this for tools tools={[]} // Empty array since we're not using this for tools
onSelectToolkit={handleSelectToolkit} onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={null} initialToolkitSlug={null}
filterByTriggers={true}
/> />
</div> </div>
); );

View file

@ -38,6 +38,7 @@ import { InputField } from "@/app/lib/components/input-field";
import { VoiceSection } from "../config/components/voice"; import { VoiceSection } from "../config/components/voice";
import { ChatWidgetSection } from "../config/components/project"; import { ChatWidgetSection } from "../config/components/project";
import { TriggersModal } from "./components/TriggersModal"; import { TriggersModal } from "./components/TriggersModal";
import { TopBar } from "./components/TopBar";
enablePatches(); enablePatches();
@ -1221,161 +1222,31 @@ export function WorkflowEditor({
onSelectTool: handleSelectTool, onSelectTool: handleSelectTool,
onSelectPrompt: handleSelectPrompt, onSelectPrompt: handleSelectPrompt,
}}> }}>
<div className="flex flex-col h-full relative"> <div className="h-full flex flex-col gap-5">
<div className="shrink-0 flex justify-between items-center pb-6"> {/* Top Bar - Isolated like sidebar */}
<div className="workflow-version-selector flex items-center gap-4 px-2 text-gray-800 dark:text-gray-100"> <TopBar
{/* Project Name Editor */} localProjectName={localProjectName}
<div className="flex flex-col min-w-0 max-w-xs"> projectNameError={projectNameError}
<InputField onProjectNameChange={handleProjectNameChange}
type="text" publishing={state.present.publishing}
value={localProjectName} isLive={isLive}
onChange={handleProjectNameChange} showCopySuccess={showCopySuccess}
error={projectNameError} canUndo={state.currentIndex > 0}
placeholder="Project name..." canRedo={state.currentIndex < state.patches.length}
className="text-lg font-semibold !min-h-[24px] !py-0.5 !border !border-gray-200 dark:!border-gray-700 !rounded-lg" showCopilot={showCopilot}
inline={true} onUndo={() => dispatch({ type: "undo" })}
/> onRedo={() => dispatch({ type: "redo" })}
</div> onDownloadJSON={handleDownloadJSON}
onPublishWorkflow={handlePublishWorkflow}
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600"></div> onChangeMode={onChangeMode}
onRevertToLive={handleRevertToLive}
<div className="flex items-center gap-2"> onToggleCopilot={() => setShowCopilot(!showCopilot)}
{state.present.publishing && <Spinner size="sm" />} onSettingsModalOpen={onSettingsModalOpen}
{isLive && <div className="bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2"> onTriggersModalOpen={onTriggersModalOpen}
<RadioIcon size={16} /> />
Live workflow
</div>} {/* Content Area */}
{!isLive && <div className="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2"> <ResizablePanelGroup direction="horizontal" className="flex-1 flex overflow-auto gap-1 rounded-xl bg-zinc-50 dark:bg-zinc-900">
<PenLine size={16} />
Draft workflow
</div>}
{/* Download JSON icon button, with tooltip, to the left of the menu */}
<Tooltip content="Download Assistant JSON">
<button
onClick={handleDownloadJSON}
className="p-1.5 text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 transition-colors"
aria-label="Download JSON"
type="button"
>
<DownloadIcon size={20} />
</button>
</Tooltip>
</div>
</div>
{showCopySuccess && <div className="flex items-center gap-2">
<div className="text-green-500">Copied to clipboard</div>
</div>}
<div className="flex items-center gap-2">
{isLive && <div className="flex items-center gap-2">
<div className="bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2">
<AlertTriangle size={16} />
This version is locked. Changes applied will not be reflected.
</div>
</div>}
{!isLive && <>
<button
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
title="Undo"
disabled={state.currentIndex <= 0}
onClick={() => dispatch({ type: "undo" })}
>
<UndoIcon size={16} />
</button>
<button
className="p-1 text-gray-400 hover:text-black hover:cursor-pointer"
title="Redo"
disabled={state.currentIndex >= state.patches.length}
onClick={() => dispatch({ type: "redo" })}
>
<RedoIcon size={16} />
</button>
</>}
{/* Deploy CTA - always visible */}
<div className="flex">
<Button
variant="solid"
size="md"
onPress={handlePublishWorkflow}
className="gap-2 px-4 bg-green-600 hover:bg-green-700 text-white font-semibold text-sm rounded-r-none"
startContent={<RocketIcon size={16} />}
data-tour-target="deploy"
>
Deploy
</Button>
<Dropdown>
<DropdownTrigger>
<Button
variant="solid"
size="md"
className="min-w-0 px-2 bg-green-600 hover:bg-green-700 border-l-1 border-green-500 text-white font-semibold text-sm rounded-l-none"
>
<ChevronDownIcon size={14} />
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Deploy actions">
<DropdownItem
key="settings"
startContent={<SettingsIcon size={16} />}
onPress={onSettingsModalOpen}
>
API & SDK settings
</DropdownItem>
<DropdownItem
key="manage-triggers"
startContent={<ZapIcon size={16} />}
onPress={onTriggersModalOpen}
>
Manage triggers
</DropdownItem>
{!isLive ? (
<>
<DropdownItem
key="view-live"
startContent={<RadioIcon size={16} />}
onPress={() => onChangeMode('live')}
>
View live version
</DropdownItem>
<DropdownItem
key="reset-to-live"
startContent={<AlertTriangle size={16} />}
onPress={handleRevertToLive}
className="text-red-600 dark:text-red-400"
>
Reset to live version
</DropdownItem>
</>
) : null}
</DropdownMenu>
</Dropdown>
</div>
{isLive && <div className="flex items-center gap-2">
<Button
variant="solid"
size="md"
onPress={() => onChangeMode('draft')}
className="gap-2 px-4 bg-gray-600 hover:bg-gray-700 text-white font-semibold text-sm"
>
Switch to draft
</Button>
</div>}
{!isLive && <Button
variant="solid"
size="md"
onPress={() => setShowCopilot(!showCopilot)}
className="gap-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold text-sm"
startContent={showCopilot ? null : <Sparkles size={16} />}
>
{showCopilot ? "Hide Skipper" : "Skipper"}
</Button>}
</div>
</div>
<ResizablePanelGroup direction="horizontal" className="grow flex overflow-auto gap-1">
<ResizablePanel minSize={10} defaultSize={PANEL_RATIOS.entityList}> <ResizablePanel minSize={10} defaultSize={PANEL_RATIOS.entityList}>
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<EntityList <EntityList

View file

@ -42,7 +42,7 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
return ( return (
<div className="h-screen flex gap-5 p-5 bg-zinc-50 dark:bg-zinc-900"> <div className="h-screen flex gap-5 p-5 bg-zinc-50 dark:bg-zinc-900">
{/* Sidebar with improved shadow and blur */} {/* Sidebar with improved shadow and blur */}
<div className="overflow-hidden rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm"> <div className="h-full overflow-hidden rounded-xl bg-white/70 dark:bg-zinc-800/70 shadow-sm backdrop-blur-sm">
<Sidebar <Sidebar
projectId={projectId ?? undefined} projectId={projectId ?? undefined}
useAuth={useAuth} useAuth={useAuth}
@ -53,8 +53,8 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
</div> </div>
{/* Main content area */} {/* Main content area */}
<main className="flex gap-2 flex-col flex-1 overflow-auto rounded-xl bg-white dark:bg-zinc-800 shadow-sm p-4"> <main className="flex-1 h-full overflow-auto">
{billingPastDue && <div className="shrink-0"> {billingPastDue && <div className="shrink-0 mb-2">
<div className="bg-red-50 text-red-500 px-2 py-1 text-sm rounded-md flex items-center gap-2"> <div className="bg-red-50 text-red-500 px-2 py-1 text-sm rounded-md flex items-center gap-2">
<span>Your subscription is past due. Please update your payment information to avoid losing access to your projects.</span> <span>Your subscription is past due. Please update your payment information to avoid losing access to your projects.</span>
<Button <Button
@ -68,9 +68,7 @@ export default function AppLayout({ children, useAuth = false, useBilling = fals
</Button> </Button>
</div> </div>
</div>} </div>}
<div className="flex-1 overflow-auto"> {children}
{children}
</div>
</main> </main>
</div> </div>
); );

View file

@ -70,6 +70,12 @@
.ql-editor { .ql-editor {
font-size: 1rem !important; /* Increase base font size */ font-size: 1rem !important; /* Increase base font size */
line-height: 1.6 !important; /* Adjust line height for better readability */ 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 */ /* Keep the rendered markdown view at its original size */

View file

@ -100,13 +100,12 @@ export function ComposeBoxCopilot({
disabled={loading} disabled={loading}
placeholder="Type a message..." placeholder="Type a message..."
autoResize={true} autoResize={true}
maxHeight={120} maxHeight={200}
className={` className={`
min-h-0! min-h-6
border-0! shadow-none! ring-0! border-0! shadow-none! ring-0!
bg-transparent bg-transparent
resize-none resize-none
overflow-y-auto
[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar]:w-1
[&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-track]:bg-transparent
[&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:bg-gray-300
@ -294,7 +293,7 @@ function CopilotStatusBar({
); );
}; };
return ( return (
<div className="w-auto max-w-[calc(100%-16px)] mx-auto flex items-center px-3 py-1 pt-2.5 pb-5 mt-2 -mb-3 rounded-xl bg-zinc-50 dark:bg-zinc-900/90 border border-zinc-300 dark:border-zinc-700 shadow-md dark:shadow-zinc-950/10 backdrop-blur-sm transition-all z-0 relative mx-2 overflow-visible"> <div className="w-auto max-w-[calc(100%-16px)] mx-auto flex items-center px-3 py-1 pt-2.5 pb-5 mt-2 -mb-3 rounded-xl bg-white/90 dark:bg-zinc-800/90 border border-zinc-300 dark:border-zinc-700 shadow-md dark:shadow-zinc-950/10 backdrop-blur-sm transition-all z-0 relative mx-2 overflow-visible">
{/* Left: context + status/ticker, flex-1, truncate as needed */} {/* Left: context + status/ticker, flex-1, truncate as needed */}
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-visible"> <div className="flex items-center gap-2 flex-1 min-w-0 overflow-visible">
{renderContext()} {renderContext()}

View file

@ -33,6 +33,8 @@ export function ActionButton({
interface PanelProps { interface PanelProps {
title: React.ReactNode; title: React.ReactNode;
subtitle?: string;
icon?: React.ReactNode;
rightActions?: React.ReactNode; rightActions?: React.ReactNode;
actions?: React.ReactNode; actions?: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
@ -47,6 +49,8 @@ interface PanelProps {
export function Panel({ export function Panel({
title, title,
subtitle,
icon,
rightActions, rightActions,
actions, actions,
children, children,
@ -65,8 +69,8 @@ export function Panel({
"flex flex-col rounded-xl border relative w-full", "flex flex-col rounded-xl border relative w-full",
// Only apply overflow-hidden if no custom overflow is set (for backward compatibility) // Only apply overflow-hidden if no custom overflow is set (for backward compatibility)
overflow ? undefined : "overflow-hidden", overflow ? undefined : "overflow-hidden",
variant === 'copilot' ? "border-blue-200 dark:border-blue-800" : "border-zinc-200 dark:border-zinc-800", variant === 'copilot' ? "border-transparent" : "border-zinc-200 dark:border-zinc-800",
"bg-white dark:bg-zinc-900", variant === 'copilot' ? "bg-zinc-50 dark:bg-zinc-900" : "bg-white dark:bg-zinc-900",
maxHeight ? "max-h-(--panel-height)" : "h-full", maxHeight ? "max-h-(--panel-height)" : "h-full",
className className
)} )}
@ -93,7 +97,8 @@ export function Panel({
)} )}
<div <div
className={clsx( className={clsx(
"shrink-0 border-b border-zinc-100 dark:border-zinc-800 relative", "shrink-0 border-b relative",
variant === 'copilot' ? "border-zinc-300 dark:border-zinc-700" : "border-zinc-100 dark:border-zinc-800",
{ {
"flex flex-col gap-3 px-4 py-3": variant === 'projects', "flex flex-col gap-3 px-4 py-3": variant === 'projects',
"flex items-center justify-between h-[53px] p-3": isEntityList, "flex items-center justify-between h-[53px] p-3": isEntityList,
@ -113,7 +118,34 @@ export function Panel({
) : variant === 'copilot' ? ( ) : variant === 'copilot' ? (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{title} {icon && icon}
<div className="flex flex-col">
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
{title}
</div>
{subtitle && (
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{subtitle}
</div>
)}
</div>
</div>
{rightActions}
</>
) : variant === 'playground' ? (
<>
<div className="flex items-center gap-2">
{icon && icon}
<div className="flex flex-col">
<div className="font-semibold text-zinc-700 dark:text-zinc-300">
{title}
</div>
{subtitle && (
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{subtitle}
</div>
)}
</div>
</div> </div>
{rightActions} {rightActions}
</> </>

View file

@ -47,7 +47,6 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
} }
}, [propValue, isEditing]); }, [propValue, isEditing]);
/*
useEffect(() => { useEffect(() => {
if (!autoResize) return; if (!autoResize) return;
@ -69,7 +68,6 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
window.addEventListener('resize', adjustHeight); window.addEventListener('resize', adjustHeight);
return () => window.removeEventListener('resize', adjustHeight); return () => window.removeEventListener('resize', adjustHeight);
}, [localValue, autoResize, maxHeight, textareaRef]); }, [localValue, autoResize, maxHeight, textareaRef]);
*/
const validateAndUpdate = (value: string) => { const validateAndUpdate = (value: string) => {
if (validate) { if (validate) {

View file

@ -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 { 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"; 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({ export const container = createContainer({
injectionMode: InjectionMode.PROXY, injectionMode: InjectionMode.PROXY,
strict: true, strict: true,
@ -96,6 +104,12 @@ container.register({
// api keys // api keys
// --- // ---
apiKeysRepository: asClass(MongoDBApiKeysRepository).singleton(), 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 // jobs
// --- // ---

View file

@ -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 { 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<typeof CreateSchema>): Promise<z.infer<typeof ApiKey>>;
/**
* 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<z.infer<typeof ApiKey>[]>;
/**
* 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<boolean>;
/**
* Deletes all API keys for a given project.
* @param projectId - The ID of the project.
*/
deleteAll(projectId: string): Promise<void>;
/**
* 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<boolean>; checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean>;
} }

View file

@ -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<typeof inputSchema>): Promise<z.infer<typeof ApiKey>>;
}
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<typeof inputSchema>): Promise<z.infer<typeof ApiKey>> {
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 });
}
}

View file

@ -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<typeof inputSchema>): Promise<boolean>;
}
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<typeof inputSchema>): Promise<boolean> {
const { caller, userId, apiKey, projectId, id } = data;
await this.projectActionAuthorizationPolicy.authorize({
caller,
userId,
apiKey,
projectId,
});
return await this.apiKeysRepository.delete(projectId, id);
}
}

View file

@ -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<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]>;
}
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<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]> {
const { caller, userId, apiKey, projectId } = data;
await this.projectActionAuthorizationPolicy.authorize({
caller,
userId,
apiKey,
projectId,
});
return await this.apiKeysRepository.listAll(projectId);
}
}

View file

@ -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(),
});

View file

@ -1,12 +1,63 @@
import { IApiKeysRepository } from "@/src/application/repositories/api-keys.repository.interface"; 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 { export class MongoDBApiKeysRepository implements IApiKeysRepository {
private readonly collection = db.collection<z.infer<typeof DocSchema>>("api_keys");
async checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean> { async checkAndConsumeKey(projectId: string, apiKey: string): Promise<boolean> {
const result = await apiKeysCollection.findOneAndUpdate( const result = await this.collection.findOneAndUpdate(
{ projectId, key: apiKey }, { projectId, key: apiKey },
{ $set: { lastUsedAt: new Date().toISOString() } } { $set: { lastUsedAt: new Date().toISOString() } }
); );
return !!result; return !!result;
} }
async create(data: z.infer<typeof CreateSchema>): Promise<z.infer<typeof ApiKey>> {
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<z.infer<typeof ApiKey>[]> {
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<boolean> {
const result = await this.collection.deleteOne({ projectId, _id: new ObjectId(id) });
return result.deletedCount > 0;
}
async deleteAll(projectId: string): Promise<void> {
await this.collection.deleteMany({ projectId });
}
} }

View file

@ -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<typeof inputSchema>): Promise<z.infer<typeof ApiKey>>;
}
export class CreateApiKeyController implements ICreateApiKeyController {
private readonly createApiKeyUseCase: ICreateApiKeyUseCase;
constructor({ createApiKeyUseCase }: { createApiKeyUseCase: ICreateApiKeyUseCase }) {
this.createApiKeyUseCase = createApiKeyUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>> {
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 };

View file

@ -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<typeof inputSchema>): Promise<boolean>;
}
export class DeleteApiKeyController implements IDeleteApiKeyController {
private readonly deleteApiKeyUseCase: IDeleteApiKeyUseCase;
constructor({ deleteApiKeyUseCase }: { deleteApiKeyUseCase: IDeleteApiKeyUseCase }) {
this.deleteApiKeyUseCase = deleteApiKeyUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<boolean> {
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 };

View file

@ -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<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]>;
}
export class ListApiKeysController implements IListApiKeysController {
private readonly listApiKeysUseCase: IListApiKeysUseCase;
constructor({ listApiKeysUseCase }: { listApiKeysUseCase: IListApiKeysUseCase }) {
this.listApiKeysUseCase = listApiKeysUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ApiKey>[]> {
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 };