mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-19 02:34:37 -05:00
Compare commits
2 Commits
audit-log
...
feat/landi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a657d2f8ac | ||
|
|
03a218f8a0 |
@@ -4,7 +4,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">Build and deploy AI agent workflows in minutes.</p>
|
<p align="center">The open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to orchestrate agentic workflows.</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
<a href="https://sim.ai" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/sim.ai-6F3DFA" alt="Sim.ai"></a>
|
||||||
|
|||||||
@@ -264,15 +264,17 @@ export async function generateMetadata(props: {
|
|||||||
return {
|
return {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description:
|
description:
|
||||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
data.description ||
|
||||||
|
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||||
keywords: [
|
keywords: [
|
||||||
'AI workflow builder',
|
|
||||||
'visual workflow editor',
|
|
||||||
'AI automation',
|
|
||||||
'workflow automation',
|
|
||||||
'AI agents',
|
'AI agents',
|
||||||
'no-code AI',
|
'agentic workforce',
|
||||||
'drag and drop workflows',
|
'AI agent platform',
|
||||||
|
'agentic workflows',
|
||||||
|
'LLM orchestration',
|
||||||
|
'AI automation',
|
||||||
|
'knowledge base',
|
||||||
|
'AI integrations',
|
||||||
data.title?.toLowerCase().split(' '),
|
data.title?.toLowerCase().split(' '),
|
||||||
]
|
]
|
||||||
.flat()
|
.flat()
|
||||||
@@ -282,7 +284,8 @@ export async function generateMetadata(props: {
|
|||||||
openGraph: {
|
openGraph: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description:
|
description:
|
||||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
data.description ||
|
||||||
|
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||||
url: fullUrl,
|
url: fullUrl,
|
||||||
siteName: 'Sim Documentation',
|
siteName: 'Sim Documentation',
|
||||||
type: 'article',
|
type: 'article',
|
||||||
@@ -303,7 +306,8 @@ export async function generateMetadata(props: {
|
|||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description:
|
description:
|
||||||
data.description || 'Sim visual workflow builder for AI applications documentation',
|
data.description ||
|
||||||
|
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce.',
|
||||||
images: [ogImageUrl],
|
images: [ogImageUrl],
|
||||||
creator: '@simdotai',
|
creator: '@simdotai',
|
||||||
site: '@simdotai',
|
site: '@simdotai',
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default async function Layout({ children, params }: LayoutProps) {
|
|||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
name: 'Sim Documentation',
|
name: 'Sim Documentation',
|
||||||
description:
|
description:
|
||||||
'Comprehensive documentation for Sim - the visual workflow builder for AI Agent Workflows.',
|
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||||
url: 'https://docs.sim.ai',
|
url: 'https://docs.sim.ai',
|
||||||
publisher: {
|
publisher: {
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
|
|||||||
@@ -7,26 +7,27 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
export const metadata = {
|
export const metadata = {
|
||||||
metadataBase: new URL('https://docs.sim.ai'),
|
metadataBase: new URL('https://docs.sim.ai'),
|
||||||
title: {
|
title: {
|
||||||
default: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
default: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||||
template: '%s',
|
template: '%s',
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.',
|
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||||
keywords: [
|
keywords: [
|
||||||
'AI workflow builder',
|
|
||||||
'visual workflow editor',
|
|
||||||
'AI automation',
|
|
||||||
'workflow automation',
|
|
||||||
'AI agents',
|
'AI agents',
|
||||||
'no-code AI',
|
'agentic workforce',
|
||||||
'drag and drop workflows',
|
'AI agent platform',
|
||||||
|
'open-source AI agents',
|
||||||
|
'agentic workflows',
|
||||||
|
'LLM orchestration',
|
||||||
'AI integrations',
|
'AI integrations',
|
||||||
'workflow canvas',
|
'knowledge base',
|
||||||
'AI Agent Workflow Builder',
|
'AI automation',
|
||||||
'workflow orchestration',
|
'workflow builder',
|
||||||
'agent builder',
|
'AI workflow orchestration',
|
||||||
'AI workflow automation',
|
'enterprise AI',
|
||||||
'visual programming',
|
'AI agent deployment',
|
||||||
|
'intelligent automation',
|
||||||
|
'AI tools',
|
||||||
],
|
],
|
||||||
authors: [{ name: 'Sim Team', url: 'https://sim.ai' }],
|
authors: [{ name: 'Sim Team', url: 'https://sim.ai' }],
|
||||||
creator: 'Sim',
|
creator: 'Sim',
|
||||||
@@ -53,9 +54,9 @@ export const metadata = {
|
|||||||
alternateLocale: ['es_ES', 'fr_FR', 'de_DE', 'ja_JP', 'zh_CN'],
|
alternateLocale: ['es_ES', 'fr_FR', 'de_DE', 'ja_JP', 'zh_CN'],
|
||||||
url: 'https://docs.sim.ai',
|
url: 'https://docs.sim.ai',
|
||||||
siteName: 'Sim Documentation',
|
siteName: 'Sim Documentation',
|
||||||
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||||
description:
|
description:
|
||||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
|
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation',
|
url: 'https://docs.sim.ai/api/og?title=Sim%20Documentation',
|
||||||
@@ -67,9 +68,9 @@ export const metadata = {
|
|||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: 'summary_large_image',
|
card: 'summary_large_image',
|
||||||
title: 'Sim Documentation - Visual Workflow Builder for AI Applications',
|
title: 'Sim Documentation — Build AI Agents & Run Your Agentic Workforce',
|
||||||
description:
|
description:
|
||||||
'Comprehensive documentation for Sim - the visual workflow builder for AI applications.',
|
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||||
creator: '@simdotai',
|
creator: '@simdotai',
|
||||||
site: '@simdotai',
|
site: '@simdotai',
|
||||||
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation'],
|
images: ['https://docs.sim.ai/api/og?title=Sim%20Documentation'],
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export async function GET() {
|
|||||||
|
|
||||||
const manifest = `# Sim Documentation
|
const manifest = `# Sim Documentation
|
||||||
|
|
||||||
> Visual Workflow Builder for AI Applications
|
> The open-source platform to build AI agents and run your agentic workforce.
|
||||||
|
|
||||||
Sim is a visual workflow builder for AI applications that lets you build AI agent workflows visually. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.
|
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders.
|
||||||
|
|
||||||
## Documentation Overview
|
## Documentation Overview
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export function TOCFooter() {
|
|||||||
<div className='text-balance font-semibold text-base leading-tight'>
|
<div className='text-balance font-semibold text-base leading-tight'>
|
||||||
Start building today
|
Start building today
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground'>Trusted by over 60,000 builders.</div>
|
<div className='text-muted-foreground'>Trusted by over 100,000 builders.</div>
|
||||||
<div className='text-muted-foreground'>
|
<div className='text-muted-foreground'>
|
||||||
Build Agentic workflows visually on a drag-and-drop canvas or with natural language.
|
The open-source platform to build AI agents and run your agentic workforce.
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href='https://sim.ai/signup'
|
href='https://sim.ai/signup'
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function StructuredData({
|
|||||||
name: 'Sim Documentation',
|
name: 'Sim Documentation',
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
description:
|
description:
|
||||||
'Comprehensive documentation for Sim visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines.',
|
'Documentation for Sim — the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||||
publisher: {
|
publisher: {
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
name: 'Sim',
|
name: 'Sim',
|
||||||
@@ -104,7 +104,7 @@ export function StructuredData({
|
|||||||
applicationCategory: 'DeveloperApplication',
|
applicationCategory: 'DeveloperApplication',
|
||||||
operatingSystem: 'Any',
|
operatingSystem: 'Any',
|
||||||
description:
|
description:
|
||||||
'Visual workflow builder for AI applications. Create powerful AI agents, automation workflows, and data processing pipelines by connecting blocks on a canvas—no coding required.',
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
author: {
|
author: {
|
||||||
'@type': 'Organization',
|
'@type': 'Organization',
|
||||||
@@ -115,12 +115,13 @@ export function StructuredData({
|
|||||||
category: 'Developer Tools',
|
category: 'Developer Tools',
|
||||||
},
|
},
|
||||||
featureList: [
|
featureList: [
|
||||||
'Visual workflow builder with drag-and-drop interface',
|
'AI agent creation',
|
||||||
'AI agent creation and automation',
|
'Agentic workflow orchestration',
|
||||||
'80+ built-in integrations',
|
'1,000+ integrations',
|
||||||
'Real-time team collaboration',
|
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||||
'Multiple deployment options',
|
'Knowledge base creation',
|
||||||
'Custom integrations via MCP protocol',
|
'Table creation',
|
||||||
|
'Document creation',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Collaboration section — team workflows and real-time collaboration.
|
||||||
|
*
|
||||||
|
* SEO:
|
||||||
|
* - `<section id="collaboration" aria-labelledby="collaboration-heading">`.
|
||||||
|
* - `<h2 id="collaboration-heading">` for the section title.
|
||||||
|
* - Product visuals use `<figure>` with `<figcaption>` and descriptive `alt` text.
|
||||||
|
*
|
||||||
|
* GEO:
|
||||||
|
* - Name specific capabilities (version control, shared workspaces, RBAC, audit logs).
|
||||||
|
* - Lead with a summary so AI can answer "Does Sim support team collaboration?".
|
||||||
|
* - Reference "Sim" by name per capability ("Sim's real-time collaboration").
|
||||||
|
*/
|
||||||
|
export default function Collaboration() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
17
apps/sim/app/(home)/components/enterprise/enterprise.tsx
Normal file
17
apps/sim/app/(home)/components/enterprise/enterprise.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Enterprise section — compliance, scale, and security messaging.
|
||||||
|
*
|
||||||
|
* SEO:
|
||||||
|
* - `<section id="enterprise" aria-labelledby="enterprise-heading">`.
|
||||||
|
* - `<h2 id="enterprise-heading">` for the section title.
|
||||||
|
* - Compliance certs (SOC2, HIPAA) as visible `<strong>` text.
|
||||||
|
* - Enterprise CTA links to contact form via `<a>` with `rel="noopener noreferrer"`.
|
||||||
|
*
|
||||||
|
* GEO:
|
||||||
|
* - Entity-rich: "Sim is SOC2 and HIPAA compliant" — not "We are compliant."
|
||||||
|
* - `<ul>` checklist of features (SSO, RBAC, audit logs, SLA, on-premise deployment)
|
||||||
|
* as an atomic answer block for "What enterprise features does Sim offer?".
|
||||||
|
*/
|
||||||
|
export default function Enterprise() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
17
apps/sim/app/(home)/components/features/features.tsx
Normal file
17
apps/sim/app/(home)/components/features/features.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Features section — Sim's core product capabilities.
|
||||||
|
*
|
||||||
|
* SEO:
|
||||||
|
* - `<section id="features" aria-labelledby="features-heading">`.
|
||||||
|
* - `<h2 id="features-heading">` for the section title.
|
||||||
|
* - Each feature: `<h3>` + `<p>` + `<ul>` capability list. Strict H2 -> H3 -> H4 hierarchy.
|
||||||
|
* - Feature lists map to `WebApplication.featureList` in structured-data.tsx — keep in sync.
|
||||||
|
*
|
||||||
|
* GEO:
|
||||||
|
* - Each feature block is independently extractable (atomic answer block pattern).
|
||||||
|
* - Include specific numbers ("1,000+ integrations", "15+ AI providers", "SOC2 compliant").
|
||||||
|
* - Always use "Sim" by name — never "the platform" or "our tool" (entity consistency).
|
||||||
|
*/
|
||||||
|
export default function Features() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
18
apps/sim/app/(home)/components/footer/footer.tsx
Normal file
18
apps/sim/app/(home)/components/footer/footer.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Landing page footer — navigation, legal links, and entity reinforcement.
|
||||||
|
*
|
||||||
|
* SEO:
|
||||||
|
* - `<footer role="contentinfo">` with `<nav aria-label="Footer navigation">`.
|
||||||
|
* - Link groups under semantic headings (`<h3>`). All links are `<Link>` or `<a>` with `href`.
|
||||||
|
* - External links include `rel="noopener noreferrer"`.
|
||||||
|
* - Legal links (Privacy, Terms) must be crawlable (trust signals).
|
||||||
|
*
|
||||||
|
* GEO:
|
||||||
|
* - Include "Sim — Build AI agents and run your agentic workforce" as visible text (entity reinforcement).
|
||||||
|
* - Social links (X, GitHub, LinkedIn, Discord) must match `sameAs` in structured-data.tsx.
|
||||||
|
* - Link to all major pages: Docs, Pricing, Enterprise, Careers, Changelog (internal link graph).
|
||||||
|
* - Display compliance badges (SOC2, HIPAA) and status page link as visible trust signals.
|
||||||
|
*/
|
||||||
|
export default function Footer() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion, type Variants } from 'framer-motion'
|
||||||
|
|
||||||
|
/** Stagger between each block appearing (seconds). */
|
||||||
|
const ENTER_STAGGER = 0.06
|
||||||
|
|
||||||
|
/** Duration of each block's fade-in (seconds). */
|
||||||
|
const ENTER_DURATION = 0.3
|
||||||
|
|
||||||
|
/** Stagger between each block disappearing (seconds). */
|
||||||
|
const EXIT_STAGGER = 0.12
|
||||||
|
|
||||||
|
/** Duration of each block's fade-out (seconds). */
|
||||||
|
const EXIT_DURATION = 0.5
|
||||||
|
|
||||||
|
/** Shared corner radius for all decorative rects. */
|
||||||
|
const RX = '2.59574'
|
||||||
|
|
||||||
|
/** Hold time after the initial enter animation before cycling starts (ms). */
|
||||||
|
const INITIAL_HOLD_MS = 2500
|
||||||
|
|
||||||
|
/** Pause between an exit completing and the next enter starting (ms). */
|
||||||
|
const TRANSITION_PAUSE_MS = 400
|
||||||
|
|
||||||
|
/** Hold time between successive transitions (ms). */
|
||||||
|
const HOLD_BETWEEN_MS = 2500
|
||||||
|
|
||||||
|
/** Animation state for a block group. */
|
||||||
|
export type BlockAnimState = 'entering' | 'visible' | 'exiting' | 'hidden'
|
||||||
|
|
||||||
|
/** Positions around the hero where block groups can appear. */
|
||||||
|
export type BlockPosition = 'topRight' | 'left' | 'rightEdge' | 'rightSide' | 'topLeft'
|
||||||
|
|
||||||
|
/** Attributes for a single animated SVG rect. */
|
||||||
|
interface BlockRect {
|
||||||
|
opacity: number
|
||||||
|
width: string
|
||||||
|
height: string
|
||||||
|
fill: string
|
||||||
|
x?: string
|
||||||
|
y?: string
|
||||||
|
transform?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: {},
|
||||||
|
visible: { transition: { staggerChildren: ENTER_STAGGER } },
|
||||||
|
exit: { transition: { staggerChildren: EXIT_STAGGER } },
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, transition: { duration: 0 } },
|
||||||
|
visible: (targetOpacity: number) => ({
|
||||||
|
opacity: targetOpacity,
|
||||||
|
transition: { duration: ENTER_DURATION },
|
||||||
|
}),
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
transition: { duration: EXIT_DURATION },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps a BlockAnimState to the framer-motion animate value. */
|
||||||
|
function toAnimateValue(state: BlockAnimState): string {
|
||||||
|
if (state === 'entering' || state === 'visible') return 'visible'
|
||||||
|
if (state === 'exiting') return 'exit'
|
||||||
|
return 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared SVG wrapper that staggers child rects in and out. */
|
||||||
|
function AnimatedBlocksSvg({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
viewBox,
|
||||||
|
rects,
|
||||||
|
animState = 'entering',
|
||||||
|
}: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
viewBox: string
|
||||||
|
rects: readonly BlockRect[]
|
||||||
|
animState?: BlockAnimState
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={viewBox}
|
||||||
|
fill='none'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
className='h-auto w-full'
|
||||||
|
initial='hidden'
|
||||||
|
animate={toAnimateValue(animState)}
|
||||||
|
variants={containerVariants}
|
||||||
|
>
|
||||||
|
{rects.map((r, i) => (
|
||||||
|
<motion.rect
|
||||||
|
key={i}
|
||||||
|
variants={blockVariants}
|
||||||
|
custom={r.opacity}
|
||||||
|
x={r.x}
|
||||||
|
y={r.y}
|
||||||
|
width={r.width}
|
||||||
|
height={r.height}
|
||||||
|
rx={RX}
|
||||||
|
fill={r.fill}
|
||||||
|
transform={r.transform}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rect data for the top-right position.
|
||||||
|
* Two-row horizontal strip, ordered left-to-right.
|
||||||
|
*/
|
||||||
|
const TOP_RIGHT_RECTS: readonly BlockRect[] = [
|
||||||
|
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#00F701' },
|
||||||
|
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||||
|
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#00F701' },
|
||||||
|
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||||
|
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FFCC02' },
|
||||||
|
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||||
|
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#FA4EDF' },
|
||||||
|
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#FA4EDF' },
|
||||||
|
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#FA4EDF' },
|
||||||
|
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||||
|
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#FA4EDF' },
|
||||||
|
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rect data for the top-left position.
|
||||||
|
* Same two-row structure as top-right with rotated colour palette:
|
||||||
|
* blue→green, green→yellow, yellow→pink, pink→blue.
|
||||||
|
*/
|
||||||
|
const TOP_LEFT_RECTS: readonly BlockRect[] = [
|
||||||
|
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '33.7252', fill: '#00F701' },
|
||||||
|
{ opacity: 0.6, x: '0', y: '0', width: '85.3433', height: '16.8626', fill: '#00F701' },
|
||||||
|
{ opacity: 1, x: '0', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||||
|
{ opacity: 0.6, x: '34.2403', y: '0', width: '34.2403', height: '33.7252', fill: '#00F701' },
|
||||||
|
{ opacity: 1, x: '34.2403', y: '0', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||||
|
{ opacity: 1, x: '51.6188', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#00F701' },
|
||||||
|
{ opacity: 1, x: '68.4812', y: '0', width: '54.6502', height: '16.8626', fill: '#FFCC02' },
|
||||||
|
{ opacity: 0.6, x: '106.268', y: '0', width: '34.2403', height: '33.7252', fill: '#FFCC02' },
|
||||||
|
{ opacity: 0.6, x: '106.268', y: '0', width: '51.103', height: '16.8626', fill: '#FFCC02' },
|
||||||
|
{ opacity: 1, x: '123.6484', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#FFCC02' },
|
||||||
|
{ opacity: 0.6, x: '157.371', y: '0', width: '34.2403', height: '16.8626', fill: '#FA4EDF' },
|
||||||
|
{ opacity: 1, x: '157.371', y: '0', width: '16.8626', height: '16.8626', fill: '#FA4EDF' },
|
||||||
|
{ opacity: 0.6, x: '208.993', y: '0', width: '68.4805', height: '16.8626', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 0.6, x: '209.137', y: '0', width: '16.8626', height: '33.7252', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 0.6, x: '243.233', y: '0', width: '34.2403', height: '33.7252', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 1, x: '243.233', y: '0', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 0.6, x: '260.096', y: '0', width: '34.04', height: '16.8626', fill: '#2ABBF8' },
|
||||||
|
{ opacity: 1, x: '260.611', y: '16.8626', width: '16.8626', height: '16.8626', fill: '#2ABBF8' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rect data for the left position.
|
||||||
|
* Two-column vertical strip, ordered top-to-bottom.
|
||||||
|
*/
|
||||||
|
const LEFT_RECTS: readonly BlockRect[] = [
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '34.240',
|
||||||
|
height: '33.725',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(0 1 1 0 0 0)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '68.480',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '33.986',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '140.507',
|
||||||
|
fill: '#00F701',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.4,
|
||||||
|
x: '17.119',
|
||||||
|
y: '136.962',
|
||||||
|
width: '34.240',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#FFCC02',
|
||||||
|
transform: 'rotate(-90 17.119 136.962)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
x: '17.119',
|
||||||
|
y: '136.962',
|
||||||
|
width: '16.8626',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#FFCC02',
|
||||||
|
transform: 'rotate(-90 17.119 136.962)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.5,
|
||||||
|
width: '34.240',
|
||||||
|
height: '33.725',
|
||||||
|
fill: '#00F701',
|
||||||
|
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#00F701',
|
||||||
|
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rect data for the right-side position (right edge of screenshot).
|
||||||
|
* Same two-column structure as left with rotated colours:
|
||||||
|
* pink→blue, green→pink, yellow→green.
|
||||||
|
*/
|
||||||
|
const RIGHT_SIDE_RECTS: readonly BlockRect[] = [
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '34.240',
|
||||||
|
height: '33.725',
|
||||||
|
fill: '#2ABBF8',
|
||||||
|
transform: 'matrix(0 1 1 0 0 0)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '68.480',
|
||||||
|
fill: '#2ABBF8',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.727 0)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#2ABBF8',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.727 17.378)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '33.986',
|
||||||
|
fill: '#2ABBF8',
|
||||||
|
transform: 'matrix(0 1 1 0 0 51.616)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '140.507',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.986 85.335)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.4,
|
||||||
|
x: '17.119',
|
||||||
|
y: '136.962',
|
||||||
|
width: '34.240',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#00F701',
|
||||||
|
transform: 'rotate(-90 17.119 136.962)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
x: '17.119',
|
||||||
|
y: '136.962',
|
||||||
|
width: '16.8626',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#00F701',
|
||||||
|
transform: 'rotate(-90 17.119 136.962)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.5,
|
||||||
|
width: '34.240',
|
||||||
|
height: '33.725',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(0 1 1 0 0.257 153.825)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rect data for the right-edge position (far right of screen).
|
||||||
|
* Two-column vertical strip, ordered top-to-bottom.
|
||||||
|
*/
|
||||||
|
const RIGHT_RECTS: readonly BlockRect[] = [
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '33.726',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(0 1 1 0 0 0)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '34.241',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(0 1 1 0 16.891 0)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '68.482',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.739 16.888)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '33.726',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(0 1 1 0 0 33.776)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.739 34.272)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '33.726',
|
||||||
|
fill: '#FA4EDF',
|
||||||
|
transform: 'matrix(0 1 1 0 0.012 68.510)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.6,
|
||||||
|
width: '16.8626',
|
||||||
|
height: '102.384',
|
||||||
|
fill: '#2ABBF8',
|
||||||
|
transform: 'matrix(-1 0 0 1 33.787 102.384)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 0.4,
|
||||||
|
x: '17.131',
|
||||||
|
y: '153.859',
|
||||||
|
width: '34.241',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#00F701',
|
||||||
|
transform: 'rotate(-90 17.131 153.859)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
x: '17.131',
|
||||||
|
y: '153.859',
|
||||||
|
width: '16.8626',
|
||||||
|
height: '16.8626',
|
||||||
|
fill: '#00F701',
|
||||||
|
transform: 'rotate(-90 17.131 153.859)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Number of rects per position, used to compute animation durations. */
|
||||||
|
const RECT_COUNTS: Record<BlockPosition, number> = {
|
||||||
|
topRight: TOP_RIGHT_RECTS.length,
|
||||||
|
topLeft: TOP_LEFT_RECTS.length,
|
||||||
|
left: LEFT_RECTS.length,
|
||||||
|
rightSide: RIGHT_SIDE_RECTS.length,
|
||||||
|
rightEdge: RIGHT_RECTS.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total enter animation time for a position (seconds). */
|
||||||
|
function enterTime(pos: BlockPosition): number {
|
||||||
|
return (RECT_COUNTS[pos] - 1) * ENTER_STAGGER + ENTER_DURATION
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total exit animation time for a position (seconds). */
|
||||||
|
function exitTime(pos: BlockPosition): number {
|
||||||
|
return (RECT_COUNTS[pos] - 1) * EXIT_STAGGER + EXIT_DURATION
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single step in the repeating animation cycle. */
|
||||||
|
type CycleStep =
|
||||||
|
| { action: 'exit'; position: BlockPosition }
|
||||||
|
| { action: 'enter'; position: BlockPosition }
|
||||||
|
| { action: 'hold'; ms: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The repeating cycle sequence. After all steps, the layout returns to its
|
||||||
|
* initial state (topRight + left + rightEdge) so the loop is seamless.
|
||||||
|
*
|
||||||
|
* Order: exit top → exit right-edge → enter right-side-of-preview →
|
||||||
|
* exit left → enter top-left → exit right-side → enter left →
|
||||||
|
* exit top-left → enter top-right → enter right-edge → back to initial.
|
||||||
|
*/
|
||||||
|
const CYCLE_STEPS: readonly CycleStep[] = [
|
||||||
|
{ action: 'exit', position: 'topRight' },
|
||||||
|
{ action: 'exit', position: 'rightEdge' },
|
||||||
|
{ action: 'enter', position: 'rightSide' },
|
||||||
|
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||||
|
{ action: 'exit', position: 'left' },
|
||||||
|
{ action: 'enter', position: 'topLeft' },
|
||||||
|
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||||
|
{ action: 'exit', position: 'rightSide' },
|
||||||
|
{ action: 'enter', position: 'left' },
|
||||||
|
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||||
|
{ action: 'exit', position: 'topLeft' },
|
||||||
|
{ action: 'enter', position: 'topRight' },
|
||||||
|
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||||
|
{ action: 'enter', position: 'rightEdge' },
|
||||||
|
{ action: 'hold', ms: HOLD_BETWEEN_MS },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the block-cycling animation loop. Returns the current animation
|
||||||
|
* state for every position so each component can be driven declaratively.
|
||||||
|
*
|
||||||
|
* Lifecycle:
|
||||||
|
* 1. All three initial groups (topRight, left, rightEdge) enter together.
|
||||||
|
* 2. After a hold period the cycle begins, processing each step in order.
|
||||||
|
* 3. Repeats indefinitely, returning to the initial layout every cycle.
|
||||||
|
*/
|
||||||
|
export function useBlockCycle(): Record<BlockPosition, BlockAnimState> {
|
||||||
|
const [states, setStates] = useState<Record<BlockPosition, BlockAnimState>>({
|
||||||
|
topRight: 'entering',
|
||||||
|
left: 'entering',
|
||||||
|
rightEdge: 'entering',
|
||||||
|
rightSide: 'hidden',
|
||||||
|
topLeft: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cancelled = { current: false }
|
||||||
|
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const longestEnter = Math.max(
|
||||||
|
enterTime('topRight'),
|
||||||
|
enterTime('left'),
|
||||||
|
enterTime('rightEdge')
|
||||||
|
)
|
||||||
|
await delay(longestEnter * 1000)
|
||||||
|
if (cancelled.current) return
|
||||||
|
|
||||||
|
setStates({
|
||||||
|
topRight: 'visible',
|
||||||
|
left: 'visible',
|
||||||
|
rightEdge: 'visible',
|
||||||
|
rightSide: 'hidden',
|
||||||
|
topLeft: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
await delay(INITIAL_HOLD_MS)
|
||||||
|
if (cancelled.current) return
|
||||||
|
|
||||||
|
while (!cancelled.current) {
|
||||||
|
for (const step of CYCLE_STEPS) {
|
||||||
|
if (cancelled.current) return
|
||||||
|
|
||||||
|
if (step.action === 'exit') {
|
||||||
|
setStates((prev) => ({ ...prev, [step.position]: 'exiting' }))
|
||||||
|
await delay(exitTime(step.position) * 1000)
|
||||||
|
if (cancelled.current) return
|
||||||
|
setStates((prev) => ({ ...prev, [step.position]: 'hidden' }))
|
||||||
|
await delay(TRANSITION_PAUSE_MS)
|
||||||
|
} else if (step.action === 'enter') {
|
||||||
|
setStates((prev) => ({ ...prev, [step.position]: 'entering' }))
|
||||||
|
await delay(enterTime(step.position) * 1000)
|
||||||
|
if (cancelled.current) return
|
||||||
|
setStates((prev) => ({ ...prev, [step.position]: 'visible' }))
|
||||||
|
await delay(TRANSITION_PAUSE_MS)
|
||||||
|
} else {
|
||||||
|
await delay(step.ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled.current) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
|
return () => {
|
||||||
|
cancelled.current = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return states
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnimatedBlockProps {
|
||||||
|
animState?: BlockAnimState
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-row horizontal strip at the top-right of the hero. */
|
||||||
|
export function BlocksTopRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||||
|
return (
|
||||||
|
<AnimatedBlocksSvg
|
||||||
|
width={295}
|
||||||
|
height={34}
|
||||||
|
viewBox='0 0 295 34'
|
||||||
|
rects={TOP_RIGHT_RECTS}
|
||||||
|
animState={animState}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-row horizontal strip at the top-left of the hero. */
|
||||||
|
export function BlocksTopLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||||
|
return (
|
||||||
|
<AnimatedBlocksSvg
|
||||||
|
width={295}
|
||||||
|
height={34}
|
||||||
|
viewBox='0 0 295 34'
|
||||||
|
rects={TOP_LEFT_RECTS}
|
||||||
|
animState={animState}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-column vertical strip on the left edge of the screenshot. */
|
||||||
|
export function BlocksLeftAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||||
|
return (
|
||||||
|
<AnimatedBlocksSvg
|
||||||
|
width={34}
|
||||||
|
height={226}
|
||||||
|
viewBox='0 0 34 226.021'
|
||||||
|
rects={LEFT_RECTS}
|
||||||
|
animState={animState}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-column vertical strip on the right edge of the screenshot. */
|
||||||
|
export function BlocksRightSideAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||||
|
return (
|
||||||
|
<AnimatedBlocksSvg
|
||||||
|
width={34}
|
||||||
|
height={226}
|
||||||
|
viewBox='0 0 34 226.021'
|
||||||
|
rects={RIGHT_SIDE_RECTS}
|
||||||
|
animState={animState}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Two-column vertical strip at the far-right edge of the screen. */
|
||||||
|
export function BlocksRightAnimated({ animState = 'entering' }: AnimatedBlockProps) {
|
||||||
|
return (
|
||||||
|
<AnimatedBlocksSvg
|
||||||
|
width={34}
|
||||||
|
height={205}
|
||||||
|
viewBox='0 0 34 204.769'
|
||||||
|
rects={RIGHT_RECTS}
|
||||||
|
animState={animState}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { memo, useCallback, useRef, useState } from 'react'
|
||||||
|
import { ArrowUp } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { BubbleChatPreview, ChevronDown, MoreHorizontal, Play } from '@/components/emcn'
|
||||||
|
import { LandingPromptStorage } from '@/lib/core/utils/browser-storage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight static panel replicating the real workspace panel styling.
|
||||||
|
* The copilot tab is active with a functional user input.
|
||||||
|
* When submitted, stores the prompt and redirects to /signup (same as landing hero).
|
||||||
|
*
|
||||||
|
* Structure mirrors the real Panel component:
|
||||||
|
* aside > div.border-l.pt-[14px] > Header(px-8) > Tabs(px-8,pt-14) > Content(pt-12)
|
||||||
|
* inside Content > Copilot > header-bar(mx-[-1px]) > UserInput(p-8)
|
||||||
|
*/
|
||||||
|
export const HeroPreviewPanel = memo(function HeroPreviewPanel() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const [cursorPos, setCursorPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
const isEmpty = inputValue.trim().length === 0
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (isEmpty) return
|
||||||
|
LandingPromptStorage.store(inputValue)
|
||||||
|
router.push('/signup')
|
||||||
|
}, [isEmpty, inputValue, router])
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSubmit]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full w-[280px] flex-shrink-0 flex-col bg-[#1e1e1e]'>
|
||||||
|
<div className='flex h-full flex-col border-[#2c2c2c] border-l pt-[14px]'>
|
||||||
|
{/* Header — More + Chat | Deploy + Run */}
|
||||||
|
<div className='flex flex-shrink-0 items-center justify-between px-[8px]'>
|
||||||
|
<div className='pointer-events-none flex gap-[6px]'>
|
||||||
|
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||||
|
<MoreHorizontal className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||||
|
</div>
|
||||||
|
<div className='flex h-[30px] w-[30px] items-center justify-center rounded-[5px] border border-[#3d3d3d] bg-[#363636]'>
|
||||||
|
<BubbleChatPreview className='h-[14px] w-[14px] text-[#e6e6e6]' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href='/signup'
|
||||||
|
className='flex gap-[6px]'
|
||||||
|
onMouseMove={(e) => setCursorPos({ x: e.clientX, y: e.clientY })}
|
||||||
|
onMouseLeave={() => setCursorPos(null)}
|
||||||
|
>
|
||||||
|
<div className='flex h-[30px] items-center rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||||
|
<span className='font-medium text-[#1b1b1b] text-[12px]'>Deploy</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex h-[30px] items-center gap-[8px] rounded-[5px] bg-[#32bd7e] px-[10px] transition-[filter] hover:brightness-110'>
|
||||||
|
<Play className='h-[11.5px] w-[11.5px] text-[#1b1b1b]' />
|
||||||
|
<span className='font-medium text-[#1b1b1b] text-[12px]'>Run</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
{cursorPos &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className='pointer-events-none fixed z-[9999]'
|
||||||
|
style={{ left: cursorPos.x + 14, top: cursorPos.y + 14 }}
|
||||||
|
>
|
||||||
|
{/* Decorative color bars — mirrors hero top-right block sequence */}
|
||||||
|
<div className='flex h-[4px]'>
|
||||||
|
<div className='h-full w-[8px] bg-[#2ABBF8]' />
|
||||||
|
<div className='h-full w-[14px] bg-[#2ABBF8] opacity-60' />
|
||||||
|
<div className='h-full w-[8px] bg-[#00F701]' />
|
||||||
|
<div className='h-full w-[16px] bg-[#00F701] opacity-60' />
|
||||||
|
<div className='h-full w-[8px] bg-[#FFCC02]' />
|
||||||
|
<div className='h-full w-[10px] bg-[#FFCC02] opacity-60' />
|
||||||
|
<div className='h-full w-[8px] bg-[#FA4EDF]' />
|
||||||
|
<div className='h-full w-[14px] bg-[#FA4EDF] opacity-60' />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='flex items-center gap-[5px] bg-white px-[6px] py-[4px] font-medium text-[#1C1C1C] text-[11px]'
|
||||||
|
style={{ boxShadow: '2px 2px 0px 0px #3d3d3d' }}
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
<ChevronDown className='-rotate-90 h-[7px] w-[7px] text-[#1C1C1C]' />
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className='flex flex-shrink-0 items-center px-[8px] pt-[14px]'>
|
||||||
|
<div className='pointer-events-none flex gap-[4px]'>
|
||||||
|
<div className='flex h-[28px] items-center rounded-[6px] border border-[#3d3d3d] bg-[#363636] px-[8px] py-[5px]'>
|
||||||
|
<span className='font-medium text-[#e6e6e6] text-[12.5px]'>Copilot</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
|
||||||
|
<span className='font-medium text-[#787878] text-[12.5px]'>Toolbar</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex h-[28px] items-center rounded-[6px] border border-transparent px-[8px] py-[5px]'>
|
||||||
|
<span className='font-medium text-[#787878] text-[12.5px]'>Editor</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content — copilot */}
|
||||||
|
<div className='flex flex-1 flex-col overflow-hidden pt-[12px]'>
|
||||||
|
<div className='flex h-full flex-col'>
|
||||||
|
{/* Copilot header bar — matches mx-[-1px] in real copilot */}
|
||||||
|
<div className='pointer-events-none mx-[-1px] flex flex-shrink-0 items-center rounded-[4px] border border-[#2c2c2c] bg-[#292929] px-[12px] py-[6px]'>
|
||||||
|
<span className='truncate font-medium text-[#e6e6e6] text-[14px]'>New Chat</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User input — matches real UserInput at p-[8px] inside copilot welcome state */}
|
||||||
|
<div className='px-[8px] pt-[12px] pb-[8px]'>
|
||||||
|
<div className='rounded-[4px] border border-[#3d3d3d] bg-[#292929] px-[6px] py-[6px]'>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder='Build an AI agent...'
|
||||||
|
rows={2}
|
||||||
|
className='mb-[6px] min-h-[48px] w-full cursor-text resize-none border-0 bg-transparent px-[2px] py-1 font-base text-[#e6e6e6] text-sm leading-[1.25rem] placeholder-[#787878] caret-[#e6e6e6] outline-none'
|
||||||
|
/>
|
||||||
|
<div className='flex items-center justify-end'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isEmpty}
|
||||||
|
className='flex h-[22px] w-[22px] items-center justify-center rounded-full border-0 p-0 transition-colors'
|
||||||
|
style={{
|
||||||
|
background: isEmpty ? '#808080' : '#e0e0e0',
|
||||||
|
cursor: isEmpty ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowUp size={14} strokeWidth={2.25} color='#1b1b1b' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { Database, Layout, Search, Settings } from 'lucide-react'
|
||||||
|
import { ChevronDown, Library } from '@/components/emcn'
|
||||||
|
import type { PreviewWorkflow } from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-workflow/workflow-data'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the HeroPreviewSidebar component
|
||||||
|
*/
|
||||||
|
interface HeroPreviewSidebarProps {
|
||||||
|
workflows: PreviewWorkflow[]
|
||||||
|
activeWorkflowId: string
|
||||||
|
onSelectWorkflow: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static footer navigation items matching the real sidebar
|
||||||
|
*/
|
||||||
|
const FOOTER_NAV_ITEMS = [
|
||||||
|
{ id: 'logs', label: 'Logs', icon: Library },
|
||||||
|
{ id: 'templates', label: 'Templates', icon: Layout },
|
||||||
|
{ id: 'knowledge-base', label: 'Knowledge Base', icon: Database },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight static sidebar replicating the real workspace sidebar styling.
|
||||||
|
* Only workflow items are interactive — everything else is pointer-events-none.
|
||||||
|
*
|
||||||
|
* Colors sourced from the dark theme CSS variables:
|
||||||
|
* --surface-1: #1e1e1e, --surface-5: #363636, --border: #2c2c2c, --border-1: #3d3d3d
|
||||||
|
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3, --text-muted: #787878
|
||||||
|
*/
|
||||||
|
export function HeroPreviewSidebar({
|
||||||
|
workflows,
|
||||||
|
activeWorkflowId,
|
||||||
|
onSelectWorkflow,
|
||||||
|
}: HeroPreviewSidebarProps) {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
setIsDropdownOpen((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDropdownOpen) return
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setIsDropdownOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [isDropdownOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full w-[220px] flex-shrink-0 flex-col border-[#2c2c2c] border-r bg-[#1e1e1e]'>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='relative flex-shrink-0 px-[14px] pt-[12px]' ref={dropdownRef}>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={handleToggle}
|
||||||
|
className='group -mx-[6px] flex cursor-pointer items-center gap-[8px] rounded-[6px] bg-transparent px-[6px] py-[4px] transition-colors hover:bg-[#363636]'
|
||||||
|
>
|
||||||
|
<span className='truncate font-base text-[#e6e6e6] text-[14px]'>My Workspace</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-[8px] w-[10px] flex-shrink-0 text-[#787878] transition-all duration-100 group-hover:text-[#cccccc] ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div className='pointer-events-none flex flex-shrink-0 items-center'>
|
||||||
|
<Search className='h-[14px] w-[14px] text-[#787878]' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workspace switcher dropdown */}
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className='absolute top-[42px] left-[8px] z-50 min-w-[160px] max-w-[160px] rounded-[6px] bg-[#242424] px-[6px] py-[6px] shadow-lg'>
|
||||||
|
<div
|
||||||
|
className='flex h-[26px] cursor-pointer items-center gap-[8px] rounded-[6px] bg-[#3d3d3d] px-[6px] font-base text-[#e6e6e6] text-[13px]'
|
||||||
|
role='menuitem'
|
||||||
|
onClick={() => setIsDropdownOpen(false)}
|
||||||
|
>
|
||||||
|
<span className='min-w-0 flex-1 truncate'>My Workspace</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow items */}
|
||||||
|
<div className='mt-[8px] space-y-[2px] overflow-x-hidden px-[8px]'>
|
||||||
|
{workflows.map((workflow) => {
|
||||||
|
const isActive = workflow.id === activeWorkflowId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={workflow.id}
|
||||||
|
type='button'
|
||||||
|
onClick={() => onSelectWorkflow(workflow.id)}
|
||||||
|
className={`group flex h-[26px] w-full items-center gap-[8px] rounded-[8px] px-[6px] text-[14px] transition-colors ${
|
||||||
|
isActive ? 'bg-[#363636]' : 'bg-transparent hover:bg-[#363636]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='h-[14px] w-[14px] flex-shrink-0 rounded-[4px]'
|
||||||
|
style={{ backgroundColor: workflow.color }}
|
||||||
|
/>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div
|
||||||
|
className={`min-w-0 truncate text-left font-medium ${
|
||||||
|
isActive ? 'text-[#e6e6e6]' : 'text-[#b3b3b3] group-hover:text-[#e6e6e6]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{workflow.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer navigation — static */}
|
||||||
|
<div className='pointer-events-none mt-auto flex flex-shrink-0 flex-col gap-[2px] border-[#2c2c2c] border-t px-[7.75px] pt-[8px] pb-[8px]'>
|
||||||
|
{FOOTER_NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className='flex h-[26px] items-center gap-[8px] rounded-[8px] px-[6px] text-[14px]'
|
||||||
|
>
|
||||||
|
<Icon className='h-[14px] w-[14px] flex-shrink-0 text-[#b3b3b3]' />
|
||||||
|
<span className='truncate font-medium text-[#b3b3b3] text-[13px]'>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import ReactFlow, {
|
||||||
|
applyEdgeChanges,
|
||||||
|
applyNodeChanges,
|
||||||
|
type Edge,
|
||||||
|
type EdgeProps,
|
||||||
|
type EdgeTypes,
|
||||||
|
getSmoothStepPath,
|
||||||
|
type Node,
|
||||||
|
type NodeTypes,
|
||||||
|
type OnEdgesChange,
|
||||||
|
type OnNodesChange,
|
||||||
|
ReactFlowProvider,
|
||||||
|
} from 'reactflow'
|
||||||
|
import 'reactflow/dist/style.css'
|
||||||
|
import { PreviewBlockNode } from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-workflow/preview-block-node'
|
||||||
|
import {
|
||||||
|
EASE_OUT,
|
||||||
|
type PreviewWorkflow,
|
||||||
|
toReactFlowElements,
|
||||||
|
} from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-workflow/workflow-data'
|
||||||
|
|
||||||
|
interface HeroPreviewWorkflowProps {
|
||||||
|
workflow: PreviewWorkflow
|
||||||
|
animate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom edge that draws left-to-right on initial load via stroke animation.
|
||||||
|
* Falls back to a static path when `data.animate` is false.
|
||||||
|
*/
|
||||||
|
function PreviewEdge({
|
||||||
|
id,
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
style,
|
||||||
|
data,
|
||||||
|
}: EdgeProps) {
|
||||||
|
const [edgePath] = getSmoothStepPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
sourcePosition,
|
||||||
|
targetPosition,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data?.animate) {
|
||||||
|
return (
|
||||||
|
<motion.path
|
||||||
|
id={id}
|
||||||
|
className='react-flow__edge-path'
|
||||||
|
d={edgePath}
|
||||||
|
style={{ ...style, fill: 'none' }}
|
||||||
|
initial={{ pathLength: 0, opacity: 0 }}
|
||||||
|
animate={{ pathLength: 1, opacity: 1 }}
|
||||||
|
transition={{
|
||||||
|
pathLength: { duration: 0.4, delay: data.delay ?? 0, ease: EASE_OUT },
|
||||||
|
opacity: { duration: 0.15, delay: data.delay ?? 0 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
id={id}
|
||||||
|
className='react-flow__edge-path'
|
||||||
|
d={edgePath}
|
||||||
|
style={{ ...style, fill: 'none' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_TYPES: NodeTypes = { previewBlock: PreviewBlockNode }
|
||||||
|
const EDGE_TYPES: EdgeTypes = { previewEdge: PreviewEdge }
|
||||||
|
const PRO_OPTIONS = { hideAttribution: true }
|
||||||
|
const FIT_VIEW_OPTIONS = { padding: 0.3, maxZoom: 1 } as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inner flow component. Keyed on workflow ID by the parent so it remounts
|
||||||
|
* cleanly on workflow switch — fitView fires on mount with zero delay.
|
||||||
|
*/
|
||||||
|
function PreviewFlow({ workflow, animate = false }: HeroPreviewWorkflowProps) {
|
||||||
|
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||||
|
() => toReactFlowElements(workflow, animate),
|
||||||
|
[workflow, animate]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [nodes, setNodes] = useState<Node[]>(initialNodes)
|
||||||
|
const [edges, setEdges] = useState<Edge[]>(initialEdges)
|
||||||
|
|
||||||
|
const onNodesChange: OnNodesChange = useCallback(
|
||||||
|
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onEdgesChange: OnEdgesChange = useCallback(
|
||||||
|
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={NODE_TYPES}
|
||||||
|
edgeTypes={EDGE_TYPES}
|
||||||
|
defaultEdgeOptions={{ type: 'previewEdge' }}
|
||||||
|
elementsSelectable={false}
|
||||||
|
nodesDraggable
|
||||||
|
nodesConnectable={false}
|
||||||
|
zoomOnScroll={false}
|
||||||
|
zoomOnDoubleClick={false}
|
||||||
|
panOnScroll={false}
|
||||||
|
zoomOnPinch={false}
|
||||||
|
panOnDrag
|
||||||
|
preventScrolling={false}
|
||||||
|
autoPanOnNodeDrag={false}
|
||||||
|
proOptions={PRO_OPTIONS}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={FIT_VIEW_OPTIONS}
|
||||||
|
className='h-full w-full bg-[#1b1b1b]'
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight ReactFlow canvas displaying an interactive workflow preview.
|
||||||
|
* The key on workflow.id forces a clean remount on switch — instant fitView,
|
||||||
|
* no timers, no flicker.
|
||||||
|
*/
|
||||||
|
export function HeroPreviewWorkflow({ workflow, animate = false }: HeroPreviewWorkflowProps) {
|
||||||
|
return (
|
||||||
|
<div className='h-full w-full'>
|
||||||
|
<ReactFlowProvider key={workflow.id}>
|
||||||
|
<PreviewFlow workflow={workflow} animate={animate} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import { Database } from 'lucide-react'
|
||||||
|
import { Handle, type NodeProps, Position } from 'reactflow'
|
||||||
|
import {
|
||||||
|
AgentIcon,
|
||||||
|
JiraIcon,
|
||||||
|
ScheduleIcon,
|
||||||
|
SlackIcon,
|
||||||
|
StartIcon,
|
||||||
|
TelegramIcon,
|
||||||
|
xIcon,
|
||||||
|
YouTubeIcon,
|
||||||
|
} from '@/components/icons'
|
||||||
|
import {
|
||||||
|
BLOCK_STAGGER,
|
||||||
|
EASE_OUT,
|
||||||
|
type PreviewTool,
|
||||||
|
} from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-workflow/workflow-data'
|
||||||
|
|
||||||
|
/** Map block type strings to their icon components. */
|
||||||
|
const BLOCK_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
starter: StartIcon,
|
||||||
|
start_trigger: StartIcon,
|
||||||
|
agent: AgentIcon,
|
||||||
|
slack: SlackIcon,
|
||||||
|
jira: JiraIcon,
|
||||||
|
x: xIcon,
|
||||||
|
youtube: YouTubeIcon,
|
||||||
|
schedule: ScheduleIcon,
|
||||||
|
telegram: TelegramIcon,
|
||||||
|
knowledge_base: Database,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data shape for preview block nodes
|
||||||
|
*/
|
||||||
|
interface PreviewBlockData {
|
||||||
|
name: string
|
||||||
|
blockType: string
|
||||||
|
bgColor: string
|
||||||
|
rows: Array<{ title: string; value: string }>
|
||||||
|
tools?: PreviewTool[]
|
||||||
|
markdown?: string
|
||||||
|
hideTargetHandle?: boolean
|
||||||
|
hideSourceHandle?: boolean
|
||||||
|
index?: number
|
||||||
|
animate?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle styling matching the real WorkflowBlock handles.
|
||||||
|
* --workflow-edge in dark mode: #454545
|
||||||
|
*/
|
||||||
|
const HANDLE_BASE = '!z-[10] !border-none !bg-[#454545]'
|
||||||
|
const HANDLE_LEFT = `${HANDLE_BASE} !left-[-8px] !h-5 !w-[7px] !rounded-r-none !rounded-l-[2px]`
|
||||||
|
const HANDLE_RIGHT = `${HANDLE_BASE} !right-[-8px] !h-5 !w-[7px] !rounded-l-none !rounded-r-[2px]`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static preview block node matching the real WorkflowBlock styling.
|
||||||
|
* Renders a block header with icon + name, sub-block rows, and tool chips.
|
||||||
|
*
|
||||||
|
* Colors sourced from dark theme CSS variables:
|
||||||
|
* --surface-2: #232323, --border-1: #3d3d3d
|
||||||
|
* --text-primary: #e6e6e6, --text-tertiary: #b3b3b3
|
||||||
|
*/
|
||||||
|
export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||||
|
data,
|
||||||
|
}: NodeProps<PreviewBlockData>) {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
blockType,
|
||||||
|
bgColor,
|
||||||
|
rows,
|
||||||
|
tools,
|
||||||
|
markdown,
|
||||||
|
hideTargetHandle,
|
||||||
|
hideSourceHandle,
|
||||||
|
index = 0,
|
||||||
|
animate = false,
|
||||||
|
} = data
|
||||||
|
const Icon = BLOCK_ICONS[blockType]
|
||||||
|
const delay = animate ? index * BLOCK_STAGGER : 0
|
||||||
|
|
||||||
|
if (blockType === 'note' && markdown) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className='relative'
|
||||||
|
initial={animate ? { opacity: 0 } : false}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||||
|
>
|
||||||
|
<div className='w-[280px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||||
|
<div className='border-[#3d3d3d] border-b p-[8px]'>
|
||||||
|
<span className='font-medium text-[#e6e6e6] text-[16px]'>Note</span>
|
||||||
|
</div>
|
||||||
|
<div className='p-[10px]'>
|
||||||
|
<NoteMarkdown content={markdown} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasContent = rows.length > 0 || (tools && tools.length > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className='relative'
|
||||||
|
initial={animate ? { opacity: 0 } : false}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.45, delay, ease: EASE_OUT }}
|
||||||
|
>
|
||||||
|
<div className='relative z-[20] w-[250px] select-none rounded-[8px] border border-[#3d3d3d] bg-[#232323]'>
|
||||||
|
{/* Target handle (left side) */}
|
||||||
|
{!hideTargetHandle && (
|
||||||
|
<Handle
|
||||||
|
type='target'
|
||||||
|
position={Position.Left}
|
||||||
|
id='target'
|
||||||
|
className={HANDLE_LEFT}
|
||||||
|
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||||
|
isConnectableStart={false}
|
||||||
|
isConnectableEnd={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between p-[8px] ${hasContent ? 'border-[#3d3d3d] border-b' : ''}`}
|
||||||
|
>
|
||||||
|
<div className='relative z-10 flex min-w-0 flex-1 items-center gap-[10px]'>
|
||||||
|
<div
|
||||||
|
className='flex h-[24px] w-[24px] flex-shrink-0 items-center justify-center rounded-[6px]'
|
||||||
|
style={{ background: bgColor }}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className='h-[16px] w-[16px] text-white' />}
|
||||||
|
</div>
|
||||||
|
<span className='truncate font-medium text-[#e6e6e6] text-[16px]'>{name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-block rows + tools */}
|
||||||
|
{hasContent && (
|
||||||
|
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.title} className='flex items-center gap-[8px]'>
|
||||||
|
<span className='min-w-0 truncate text-[#b3b3b3] text-[14px] capitalize'>
|
||||||
|
{row.title}
|
||||||
|
</span>
|
||||||
|
{row.value && (
|
||||||
|
<span className='flex-1 truncate text-right text-[#e6e6e6] text-[14px]'>
|
||||||
|
{row.value}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tool chips — inline with label */}
|
||||||
|
{tools && tools.length > 0 && (
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
<span className='flex-shrink-0 text-[#b3b3b3] text-[14px]'>Tools</span>
|
||||||
|
<div className='flex flex-1 flex-wrap items-center justify-end gap-[5px]'>
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const ToolIcon = BLOCK_ICONS[tool.type]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tool.type}
|
||||||
|
className='flex items-center gap-[5px] rounded-[5px] border border-[#3d3d3d] bg-[#2a2a2a] px-[6px] py-[3px]'
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-[4px]'
|
||||||
|
style={{ background: tool.bgColor }}
|
||||||
|
>
|
||||||
|
{ToolIcon && <ToolIcon className='h-[10px] w-[10px] text-white' />}
|
||||||
|
</div>
|
||||||
|
<span className='text-[#e6e6e6] text-[12px]'>{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source handle (right side) */}
|
||||||
|
{!hideSourceHandle && (
|
||||||
|
<Handle
|
||||||
|
type='source'
|
||||||
|
position={Position.Right}
|
||||||
|
id='source'
|
||||||
|
className={HANDLE_RIGHT}
|
||||||
|
style={{ top: '20px', transform: 'translateY(-50%)' }}
|
||||||
|
isConnectableStart={false}
|
||||||
|
isConnectableEnd={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders lightweight markdown-like content for note blocks.
|
||||||
|
* Supports ### headings, **bold**, _italic_, --- rules, and blank-line spacing.
|
||||||
|
*/
|
||||||
|
function NoteMarkdown({ content }: { content: string }) {
|
||||||
|
const lines = content.split('\n')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-[4px]'>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) return <div key={i} className='h-[4px]' />
|
||||||
|
|
||||||
|
if (trimmed === '---') {
|
||||||
|
return <hr key={i} className='my-[4px] border-[#3d3d3d] border-t' />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith('### ')) {
|
||||||
|
return (
|
||||||
|
<p key={i} className='font-semibold text-[#e6e6e6] text-[16px] leading-[1.3]'>
|
||||||
|
{trimmed.slice(4)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
key={i}
|
||||||
|
className='font-medium text-[#e6e6e6] text-[13px] leading-[1.5]'
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: trimmed
|
||||||
|
.replace(/\*\*_(.+?)_\*\*/g, '<strong><em>$1</em></strong>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/_"(.+?)"_/g, '<em>“$1”</em>')
|
||||||
|
.replace(/_(.+?)_/g, '<em>$1</em>'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type { Edge, Node } from 'reactflow'
|
||||||
|
import { Position } from 'reactflow'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool entry displayed as a chip on agent blocks
|
||||||
|
*/
|
||||||
|
export interface PreviewTool {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
bgColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static block definition for preview workflow nodes
|
||||||
|
*/
|
||||||
|
export interface PreviewBlock {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
bgColor: string
|
||||||
|
rows: Array<{ title: string; value: string }>
|
||||||
|
tools?: PreviewTool[]
|
||||||
|
markdown?: string
|
||||||
|
position: { x: number; y: number }
|
||||||
|
hideTargetHandle?: boolean
|
||||||
|
hideSourceHandle?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow definition containing nodes, edges, and metadata
|
||||||
|
*/
|
||||||
|
export interface PreviewWorkflow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string
|
||||||
|
blocks: PreviewBlock[]
|
||||||
|
edges: Array<{ id: string; source: string; target: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IT Service Management workflow — Slack Trigger -> Agent (KB + Jira tools)
|
||||||
|
*/
|
||||||
|
const IT_SERVICE_WORKFLOW: PreviewWorkflow = {
|
||||||
|
id: 'wf-it-service',
|
||||||
|
name: 'IT Service Management',
|
||||||
|
color: '#33C482',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'slack-1',
|
||||||
|
name: 'Slack',
|
||||||
|
type: 'slack',
|
||||||
|
bgColor: '#611f69',
|
||||||
|
rows: [
|
||||||
|
{ title: 'Channel', value: '#it-support' },
|
||||||
|
{ title: 'Event', value: 'New Message' },
|
||||||
|
],
|
||||||
|
position: { x: 80, y: 140 },
|
||||||
|
hideTargetHandle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-1',
|
||||||
|
name: 'Agent',
|
||||||
|
type: 'agent',
|
||||||
|
bgColor: '#701ffc',
|
||||||
|
rows: [
|
||||||
|
{ title: 'Model', value: 'claude-sonnet-4.6' },
|
||||||
|
{ title: 'System Prompt', value: 'Triage incoming IT...' },
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{ name: 'Knowledge Base', type: 'knowledge_base', bgColor: '#10B981' },
|
||||||
|
{ name: 'Jira', type: 'jira', bgColor: '#E0E0E0' },
|
||||||
|
],
|
||||||
|
position: { x: 420, y: 80 },
|
||||||
|
hideSourceHandle: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [{ id: 'e-1', source: 'slack-1', target: 'agent-1' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content pipeline workflow — Schedule -> Agent (X + YouTube tools)
|
||||||
|
* \-> Telegram
|
||||||
|
*/
|
||||||
|
const CONTENT_PIPELINE_WORKFLOW: PreviewWorkflow = {
|
||||||
|
id: 'wf-content-pipeline',
|
||||||
|
name: 'Content Pipeline',
|
||||||
|
color: '#FF6B2C',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'schedule-1',
|
||||||
|
name: 'Schedule',
|
||||||
|
type: 'schedule',
|
||||||
|
bgColor: '#6366F1',
|
||||||
|
rows: [
|
||||||
|
{ title: 'Run Frequency', value: 'Daily' },
|
||||||
|
{ title: 'Time', value: '09:00 AM' },
|
||||||
|
],
|
||||||
|
position: { x: 80, y: 140 },
|
||||||
|
hideTargetHandle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'agent-2',
|
||||||
|
name: 'Agent',
|
||||||
|
type: 'agent',
|
||||||
|
bgColor: '#701ffc',
|
||||||
|
rows: [
|
||||||
|
{ title: 'Model', value: 'grok-4' },
|
||||||
|
{ title: 'System Prompt', value: 'Repurpose trending...' },
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
{ name: 'X', type: 'x', bgColor: '#000000' },
|
||||||
|
{ name: 'YouTube', type: 'youtube', bgColor: '#FF0000' },
|
||||||
|
],
|
||||||
|
position: { x: 420, y: 40 },
|
||||||
|
hideSourceHandle: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'telegram-1',
|
||||||
|
name: 'Telegram',
|
||||||
|
type: 'telegram',
|
||||||
|
bgColor: '#E0E0E0',
|
||||||
|
rows: [
|
||||||
|
{ title: 'Operation', value: 'Send Message' },
|
||||||
|
{ title: 'Chat', value: '#content-updates' },
|
||||||
|
],
|
||||||
|
position: { x: 420, y: 260 },
|
||||||
|
hideSourceHandle: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'e-3', source: 'schedule-1', target: 'agent-2' },
|
||||||
|
{ id: 'e-4', source: 'schedule-1', target: 'telegram-1' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty "New Agent" workflow — a single note prompting the user to start building
|
||||||
|
*/
|
||||||
|
const NEW_AGENT_WORKFLOW: PreviewWorkflow = {
|
||||||
|
id: 'wf-new-agent',
|
||||||
|
name: 'New Agent',
|
||||||
|
color: '#787878',
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
id: 'note-1',
|
||||||
|
name: '',
|
||||||
|
type: 'note',
|
||||||
|
bgColor: 'transparent',
|
||||||
|
rows: [],
|
||||||
|
markdown: '### What will you build?\n\n_"Find Slack todos and add them to Linear"_',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
hideTargetHandle: true,
|
||||||
|
hideSourceHandle: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PREVIEW_WORKFLOWS: PreviewWorkflow[] = [
|
||||||
|
IT_SERVICE_WORKFLOW,
|
||||||
|
CONTENT_PIPELINE_WORKFLOW,
|
||||||
|
NEW_AGENT_WORKFLOW,
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Stagger delay between each block appearing (seconds). */
|
||||||
|
export const BLOCK_STAGGER = 0.12
|
||||||
|
|
||||||
|
/** Shared cubic-bezier easing — fast deceleration, gentle settle. */
|
||||||
|
export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]
|
||||||
|
|
||||||
|
/** Shared edge style applied to all preview workflow connections */
|
||||||
|
const EDGE_STYLE = { stroke: '#454545', strokeWidth: 1.5 } as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a PreviewWorkflow to React Flow nodes and edges.
|
||||||
|
*
|
||||||
|
* @param workflow - The workflow definition
|
||||||
|
* @param animate - When true, node/edge data includes animation metadata
|
||||||
|
*/
|
||||||
|
export function toReactFlowElements(
|
||||||
|
workflow: PreviewWorkflow,
|
||||||
|
animate = false
|
||||||
|
): {
|
||||||
|
nodes: Node[]
|
||||||
|
edges: Edge[]
|
||||||
|
} {
|
||||||
|
const blockIndexMap = new Map(workflow.blocks.map((b, i) => [b.id, i]))
|
||||||
|
|
||||||
|
const nodes: Node[] = workflow.blocks.map((block, index) => ({
|
||||||
|
id: block.id,
|
||||||
|
type: 'previewBlock',
|
||||||
|
position: block.position,
|
||||||
|
data: {
|
||||||
|
name: block.name,
|
||||||
|
blockType: block.type,
|
||||||
|
bgColor: block.bgColor,
|
||||||
|
rows: block.rows,
|
||||||
|
tools: block.tools,
|
||||||
|
markdown: block.markdown,
|
||||||
|
hideTargetHandle: block.hideTargetHandle,
|
||||||
|
hideSourceHandle: block.hideSourceHandle,
|
||||||
|
index,
|
||||||
|
animate,
|
||||||
|
},
|
||||||
|
draggable: true,
|
||||||
|
selectable: false,
|
||||||
|
connectable: false,
|
||||||
|
sourcePosition: Position.Right,
|
||||||
|
targetPosition: Position.Left,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const edges: Edge[] = workflow.edges.map((e) => {
|
||||||
|
const sourceIndex = blockIndexMap.get(e.source) ?? 0
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
type: 'previewEdge',
|
||||||
|
animated: false,
|
||||||
|
style: EDGE_STYLE,
|
||||||
|
sourceHandle: 'source',
|
||||||
|
targetHandle: 'target',
|
||||||
|
data: {
|
||||||
|
animate,
|
||||||
|
delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { nodes, edges }
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { motion, type Variants } from 'framer-motion'
|
||||||
|
import { HeroPreviewPanel } from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-panel/hero-preview-panel'
|
||||||
|
import { HeroPreviewSidebar } from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-sidebar/hero-preview-sidebar'
|
||||||
|
import { HeroPreviewWorkflow } from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-workflow/hero-preview-workflow'
|
||||||
|
import {
|
||||||
|
EASE_OUT,
|
||||||
|
PREVIEW_WORKFLOWS,
|
||||||
|
} from '@/app/(home)/components/hero/components/hero-preview/components/hero-preview-workflow/workflow-data'
|
||||||
|
|
||||||
|
const containerVariants: Variants = {
|
||||||
|
hidden: {},
|
||||||
|
visible: {
|
||||||
|
transition: { staggerChildren: 0.15 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, x: -12 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: {
|
||||||
|
x: { duration: 0.25, ease: EASE_OUT },
|
||||||
|
opacity: { duration: 0.25, ease: EASE_OUT },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelVariants: Variants = {
|
||||||
|
hidden: { opacity: 0, x: 12 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
transition: {
|
||||||
|
x: { duration: 0.25, ease: EASE_OUT },
|
||||||
|
opacity: { duration: 0.25, ease: EASE_OUT },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive workspace preview for the hero section.
|
||||||
|
*
|
||||||
|
* Renders a lightweight replica of the Sim workspace with:
|
||||||
|
* - A sidebar with two selectable workflows
|
||||||
|
* - A ReactFlow canvas showing the active workflow's blocks and edges
|
||||||
|
* - A panel with a functional copilot input (stores prompt + redirects to /signup)
|
||||||
|
*
|
||||||
|
* Everything except the workflow items and the copilot input is non-interactive.
|
||||||
|
* On mount the sidebar slides from left and the panel from right. The canvas
|
||||||
|
* background stays fully opaque; individual block nodes animate in with a
|
||||||
|
* staggered fade. Edges draw left-to-right. Animations only fire on initial
|
||||||
|
* load — workflow switches render instantly.
|
||||||
|
*/
|
||||||
|
export function HeroPreview() {
|
||||||
|
const [activeWorkflowId, setActiveWorkflowId] = useState(PREVIEW_WORKFLOWS[0].id)
|
||||||
|
const isInitialMount = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isInitialMount.current = false
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const activeWorkflow =
|
||||||
|
PREVIEW_WORKFLOWS.find((w) => w.id === activeWorkflowId) ?? PREVIEW_WORKFLOWS[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className='dark flex aspect-[1116/549] w-full overflow-hidden rounded bg-[#1b1b1b] antialiased'
|
||||||
|
initial='hidden'
|
||||||
|
animate='visible'
|
||||||
|
variants={containerVariants}
|
||||||
|
>
|
||||||
|
<motion.div className='hidden lg:flex' variants={sidebarVariants}>
|
||||||
|
<HeroPreviewSidebar
|
||||||
|
workflows={PREVIEW_WORKFLOWS}
|
||||||
|
activeWorkflowId={activeWorkflowId}
|
||||||
|
onSelectWorkflow={setActiveWorkflowId}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
<div className='relative flex-1 overflow-hidden'>
|
||||||
|
<HeroPreviewWorkflow workflow={activeWorkflow} animate={isInitialMount.current} />
|
||||||
|
</div>
|
||||||
|
<motion.div className='hidden lg:flex' variants={panelVariants}>
|
||||||
|
<HeroPreviewPanel />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
apps/sim/app/(home)/components/hero/hero.tsx
Normal file
159
apps/sim/app/(home)/components/hero/hero.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import {
|
||||||
|
BlocksLeftAnimated,
|
||||||
|
BlocksRightAnimated,
|
||||||
|
BlocksRightSideAnimated,
|
||||||
|
BlocksTopLeftAnimated,
|
||||||
|
BlocksTopRightAnimated,
|
||||||
|
useBlockCycle,
|
||||||
|
} from '@/app/(home)/components/hero/components/animated-blocks'
|
||||||
|
|
||||||
|
const HeroPreview = dynamic(
|
||||||
|
() =>
|
||||||
|
import('@/app/(home)/components/hero/components/hero-preview/hero-preview').then(
|
||||||
|
(mod) => mod.HeroPreview
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className='aspect-[1116/549] w-full rounded bg-[#1b1b1b]' />,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Shared base classes for CTA link buttons — matches Deploy/Run button styling in the preview panel. */
|
||||||
|
const CTA_BASE =
|
||||||
|
'inline-flex items-center h-[32px] rounded-[5px] border px-[10px] font-[430] font-season text-[14px]'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hero section — above-the-fold value proposition.
|
||||||
|
*
|
||||||
|
* SEO:
|
||||||
|
* - `<section id="hero" aria-labelledby="hero-heading">`.
|
||||||
|
* - Contains the page's only `<h1>`. Text aligns with the `<title>` tag keyword.
|
||||||
|
* - Subtitle `<p>` expands the H1 into a full sentence with the primary keyword.
|
||||||
|
* - Primary CTA links to `/signup` and `/login` auth pages (crawlable).
|
||||||
|
* - Canvas/animations wrapped in `aria-hidden="true"` with a text alternative.
|
||||||
|
*
|
||||||
|
* GEO:
|
||||||
|
* - H1 + subtitle answer "What is Sim?" in two sentences (answer-first pattern).
|
||||||
|
* - First 150 chars of visible text explicitly name "Sim", "AI agents", "agentic workflows".
|
||||||
|
* - `<p className="sr-only">` product summary (~50 words) is an atomic answer for AI citation.
|
||||||
|
*/
|
||||||
|
export default function Hero() {
|
||||||
|
const blockStates = useBlockCycle()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id='hero'
|
||||||
|
aria-labelledby='hero-heading'
|
||||||
|
className='relative flex flex-col items-center overflow-hidden bg-[#1C1C1C] pt-[71px]'
|
||||||
|
>
|
||||||
|
{/* Screen reader product summary */}
|
||||||
|
<p className='sr-only'>
|
||||||
|
Sim is the open-source platform to build AI agents and run your agentic workforce. Connect
|
||||||
|
1,000+ integrations and LLMs — including OpenAI, Claude, Gemini, Mistral, and xAI — to
|
||||||
|
deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables,
|
||||||
|
and docs. Trusted by over 100,000 builders at startups and Fortune 500 companies. SOC2 and
|
||||||
|
HIPAA compliant.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Left card decoration — top-left, partially off-screen */}
|
||||||
|
<div
|
||||||
|
aria-hidden='true'
|
||||||
|
className='pointer-events-none absolute top-[-0.7vw] left-[-2.8vw] z-0 aspect-[344/328] w-[23.9vw]'
|
||||||
|
>
|
||||||
|
<Image src='/landing/card-left.svg' alt='' fill className='object-contain' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right card decoration — top-right, partially off-screen */}
|
||||||
|
<div
|
||||||
|
aria-hidden='true'
|
||||||
|
className='pointer-events-none absolute top-[-2.8vw] right-[0vw] z-0 aspect-[471/470] w-[32.7vw]'
|
||||||
|
>
|
||||||
|
<Image src='/landing/card-right.svg' alt='' fill className='object-contain' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className='relative z-10 flex flex-col items-center gap-[12px]'>
|
||||||
|
<h1
|
||||||
|
id='hero-heading'
|
||||||
|
className='font-[430] font-season text-[64px] text-white leading-[100%] tracking-[-0.02em]'
|
||||||
|
>
|
||||||
|
Build Agents
|
||||||
|
</h1>
|
||||||
|
<p className='font-[430] font-season text-[#F6F6F6]/60 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||||
|
Build and deploy agentic workflows
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<div className='mt-[12px] flex items-center gap-[8px]'>
|
||||||
|
<Link
|
||||||
|
href='/login'
|
||||||
|
className={`${CTA_BASE} border-[#2A2A2A] text-[#ECECEC] transition-colors hover:bg-[#2A2A2A]`}
|
||||||
|
aria-label='Log in'
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/signup'
|
||||||
|
className={`${CTA_BASE} gap-[8px] border-[#33C482] bg-[#33C482] text-black transition-[filter] hover:brightness-110`}
|
||||||
|
aria-label='Get started with Sim'
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top-right blocks */}
|
||||||
|
<div
|
||||||
|
aria-hidden='true'
|
||||||
|
className='pointer-events-none absolute top-0 right-[13.1vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||||
|
>
|
||||||
|
<BlocksTopRightAnimated animState={blockStates.topRight} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top-left blocks */}
|
||||||
|
<div
|
||||||
|
aria-hidden='true'
|
||||||
|
className='pointer-events-none absolute top-0 left-[16vw] z-20 w-[calc(140px_+_10.76vw)] max-w-[295px]'
|
||||||
|
>
|
||||||
|
<BlocksTopLeftAnimated animState={blockStates.topLeft} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Screenshot with decorative elements */}
|
||||||
|
<div className='relative z-10 mx-auto mt-[2.4vw] w-[78.9vw] px-[1.4vw]'>
|
||||||
|
{/* Left side blocks - flush against screenshot left edge */}
|
||||||
|
<div
|
||||||
|
aria-hidden='true'
|
||||||
|
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||||
|
>
|
||||||
|
<BlocksLeftAnimated animState={blockStates.left} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side blocks - flush against screenshot right edge, mirrored to point outward */}
|
||||||
|
<div
|
||||||
|
aria-hidden='true'
|
||||||
|
className='-translate-y-1/2 pointer-events-none absolute top-[50%] left-[calc(100%-1.41vw)] z-20 w-[calc(16px_+_1.25vw)] max-w-[34px] scale-x-[-1]'
|
||||||
|
>
|
||||||
|
<BlocksRightSideAnimated animState={blockStates.rightSide} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive workspace preview */}
|
||||||
|
<div className='relative z-10 overflow-hidden rounded border border-[#2A2A2A]'>
|
||||||
|
<HeroPreview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right edge blocks - at right edge of screen */}
|
||||||
|
<div
|
||||||
|
aria-hidden='true'
|
||||||
|
className='-translate-y-1/2 pointer-events-none absolute top-[50%] right-0 z-20 w-[calc(16px_+_1.25vw)] max-w-[34px]'
|
||||||
|
>
|
||||||
|
<BlocksRightAnimated animState={blockStates.rightEdge} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
apps/sim/app/(home)/components/index.ts
Normal file
23
apps/sim/app/(home)/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Collaboration from '@/app/(home)/components/collaboration/collaboration'
|
||||||
|
import Enterprise from '@/app/(home)/components/enterprise/enterprise'
|
||||||
|
import Features from '@/app/(home)/components/features/features'
|
||||||
|
import Footer from '@/app/(home)/components/footer/footer'
|
||||||
|
import Hero from '@/app/(home)/components/hero/hero'
|
||||||
|
import Navbar from '@/app/(home)/components/navbar/navbar'
|
||||||
|
import Pricing from '@/app/(home)/components/pricing/pricing'
|
||||||
|
import StructuredData from '@/app/(home)/components/structured-data'
|
||||||
|
import Templates from '@/app/(home)/components/templates/templates'
|
||||||
|
import Testimonials from '@/app/(home)/components/testimonials/testimonials'
|
||||||
|
|
||||||
|
export {
|
||||||
|
Collaboration,
|
||||||
|
Enterprise,
|
||||||
|
Features,
|
||||||
|
Footer,
|
||||||
|
Hero,
|
||||||
|
Navbar,
|
||||||
|
Pricing,
|
||||||
|
StructuredData,
|
||||||
|
Templates,
|
||||||
|
Testimonials,
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { GithubOutlineIcon } from '@/components/icons'
|
||||||
|
import { getFormattedGitHubStars } from '@/app/(landing)/actions/github'
|
||||||
|
|
||||||
|
const logger = createLogger('github-stars')
|
||||||
|
|
||||||
|
const INITIAL_STARS = '26.4k'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client component that displays GitHub stars count.
|
||||||
|
*
|
||||||
|
* Isolated as a client component to allow the parent Navbar to remain
|
||||||
|
* a Server Component for optimal SEO/GEO crawlability.
|
||||||
|
*/
|
||||||
|
export function GitHubStars() {
|
||||||
|
const [stars, setStars] = useState(INITIAL_STARS)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getFormattedGitHubStars()
|
||||||
|
.then(setStars)
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to fetch GitHub stars', error)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href='https://github.com/simstudioai/sim'
|
||||||
|
target='_blank'
|
||||||
|
rel='noopener noreferrer'
|
||||||
|
className='flex items-center gap-[8px] px-[12px]'
|
||||||
|
aria-label={`GitHub repository — ${stars} stars`}
|
||||||
|
>
|
||||||
|
<GithubOutlineIcon className='h-[14px] w-[14px]' />
|
||||||
|
<span aria-live='polite'>{stars}</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
apps/sim/app/(home)/components/navbar/navbar.tsx
Normal file
113
apps/sim/app/(home)/components/navbar/navbar.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ChevronDown } from '@/components/emcn'
|
||||||
|
import { GitHubStars } from '@/app/(home)/components/navbar/components/github-stars'
|
||||||
|
|
||||||
|
interface NavLink {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
external?: boolean
|
||||||
|
icon?: 'chevron'
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV_LINKS: NavLink[] = [
|
||||||
|
{ label: 'Docs', href: '/docs', icon: 'chevron' },
|
||||||
|
{ label: 'Pricing', href: '/pricing' },
|
||||||
|
{ label: 'Careers', href: '/careers' },
|
||||||
|
{ label: 'Enterprise', href: '/enterprise' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Logo and nav edge: horizontal padding (px) for left/right symmetry. */
|
||||||
|
const LOGO_CELL = 'flex items-center px-[20px]'
|
||||||
|
|
||||||
|
/** Links: even spacing between items. */
|
||||||
|
const LINK_CELL = 'flex items-center px-[14px]'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing page navigation bar.
|
||||||
|
*
|
||||||
|
* Server Component for immediate crawlability. Only the GitHubStars counter
|
||||||
|
* is a client component (hydrates independently).
|
||||||
|
*
|
||||||
|
* SEO:
|
||||||
|
* - `<nav>` with schema.org `SiteNavigationElement`.
|
||||||
|
* - All routes use `<Link>` (crawlable). External links include `rel="noopener noreferrer"`.
|
||||||
|
* - Logo `<Image>` uses `priority` (LCP element).
|
||||||
|
*
|
||||||
|
* GEO:
|
||||||
|
* - Navigation items in semantic `<ul>/<li>` with descriptive anchor text.
|
||||||
|
* - Descriptive `aria-label` on interactive elements for AI intent extraction.
|
||||||
|
* - Server-rendered content available immediately to AI crawlers.
|
||||||
|
*/
|
||||||
|
export default function Navbar() {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label='Primary navigation'
|
||||||
|
className='flex h-[52px] border-[#2A2A2A] border-b-[1px] bg-[#1C1C1C] font-[430] font-season text-[#ECECEC] text-[14px]'
|
||||||
|
itemScope
|
||||||
|
itemType='https://schema.org/SiteNavigationElement'
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<Link href='/' className={LOGO_CELL} aria-label='Sim home' itemProp='url'>
|
||||||
|
<span itemProp='name' className='sr-only'>
|
||||||
|
Sim
|
||||||
|
</span>
|
||||||
|
<Image
|
||||||
|
src='/logo/sim-landing.svg'
|
||||||
|
alt='Sim'
|
||||||
|
width={71}
|
||||||
|
height={22}
|
||||||
|
className='h-[22px] w-auto'
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<ul className='mt-[0.75px] flex'>
|
||||||
|
{NAV_LINKS.map(({ label, href, external, icon }) => (
|
||||||
|
<li key={label} className='flex'>
|
||||||
|
{external ? (
|
||||||
|
<a href={href} target='_blank' rel='noopener noreferrer' className={LINK_CELL}>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={icon ? `${LINK_CELL} gap-[8px]` : LINK_CELL}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{icon === 'chevron' && (
|
||||||
|
<ChevronDown className='mt-[1.75px] h-[10px] w-[10px] flex-shrink-0 text-[#ECECEC]' />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className='flex'>
|
||||||
|
<GitHubStars />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className='flex-1' />
|
||||||
|
|
||||||
|
{/* CTAs */}
|
||||||
|
<div className='flex items-center gap-[8px] px-[20px]'>
|
||||||
|
<Link
|
||||||
|
href='/login'
|
||||||
|
className='inline-flex h-[30px] items-center rounded-[5px] border border-[#2A2A2A] px-[9px] text-[#ECECEC] text-[13.5px] transition-colors hover:bg-[#2A2A2A]'
|
||||||
|
aria-label='Log in'
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href='/signup'
|
||||||
|
className='inline-flex h-[30px] items-center gap-[7px] rounded-[5px] border border-[#33C482] bg-[#33C482] px-[9px] text-[13.5px] text-black transition-[filter] hover:brightness-110'
|
||||||
|
aria-label='Get started with Sim'
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal file
17
apps/sim/app/(home)/components/pricing/pricing.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Pricing section — tiered pricing plans with feature comparison.
|
||||||
|
*
|
||||||
|
* SEO:
|
||||||
|
* - `<section id="pricing" aria-labelledby="pricing-heading">`.
|
||||||
|
* - `<h2 id="pricing-heading">` for the section title.
|
||||||
|
* - Each tier: `<h3>` plan name + semantic `<ul>` feature list.
|
||||||
|
* - Free tier CTA uses `<Link href="/signup">` (crawlable). Enterprise CTA uses `<a>`.
|
||||||
|
*
|
||||||
|
* GEO:
|
||||||
|
* - Each plan has consistent structure: name, price, billing period, feature list.
|
||||||
|
* - Lead with a summary: "Sim offers a free Community plan, $20/mo Pro, $40/mo Team, custom Enterprise."
|
||||||
|
* - Prices must match the `Offer` items in structured-data.tsx exactly.
|
||||||
|
*/
|
||||||
|
export default function Pricing() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
224
apps/sim/app/(home)/components/structured-data.tsx
Normal file
224
apps/sim/app/(home)/components/structured-data.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* JSON-LD structured data for the landing page.
|
||||||
|
*
|
||||||
|
* Renders a `<script type="application/ld+json">` with Schema.org markup.
|
||||||
|
* Single source of truth for machine-readable page metadata.
|
||||||
|
*
|
||||||
|
* Schemas: Organization, WebSite, WebPage, BreadcrumbList, WebApplication, FAQPage.
|
||||||
|
*
|
||||||
|
* AI crawler behavior (2025-2026):
|
||||||
|
* - Google AI Overviews / Bing Copilot parse JSON-LD from their search indexes.
|
||||||
|
* - GPTBot indexes JSON-LD during crawling (92% of LLM crawlers parse JSON-LD first).
|
||||||
|
* - Perplexity / Claude prioritize visible HTML over JSON-LD during direct fetch.
|
||||||
|
* - All claims here must also appear as visible text on the page.
|
||||||
|
*
|
||||||
|
* Maintenance:
|
||||||
|
* - Offer prices must match the Pricing component exactly.
|
||||||
|
* - `sameAs` links must match the Footer social links.
|
||||||
|
* - Do not add `aggregateRating` without real, verifiable review data.
|
||||||
|
*/
|
||||||
|
export default function StructuredData() {
|
||||||
|
const structuredData = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'Organization',
|
||||||
|
'@id': 'https://sim.ai/#organization',
|
||||||
|
name: 'Sim',
|
||||||
|
alternateName: 'Sim Studio',
|
||||||
|
description:
|
||||||
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||||
|
url: 'https://sim.ai',
|
||||||
|
logo: {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
'@id': 'https://sim.ai/#logo',
|
||||||
|
url: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
|
||||||
|
contentUrl: 'https://sim.ai/logo/b%26w/text/b%26w.svg',
|
||||||
|
width: 49.78314,
|
||||||
|
height: 24.276,
|
||||||
|
caption: 'Sim Logo',
|
||||||
|
},
|
||||||
|
image: { '@id': 'https://sim.ai/#logo' },
|
||||||
|
sameAs: [
|
||||||
|
'https://x.com/simdotai',
|
||||||
|
'https://github.com/simstudioai/sim',
|
||||||
|
'https://www.linkedin.com/company/simstudioai/',
|
||||||
|
'https://discord.gg/Hr4UWYEcTT',
|
||||||
|
],
|
||||||
|
contactPoint: {
|
||||||
|
'@type': 'ContactPoint',
|
||||||
|
contactType: 'customer support',
|
||||||
|
availableLanguage: ['en'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': 'https://sim.ai/#website',
|
||||||
|
url: 'https://sim.ai',
|
||||||
|
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||||
|
description:
|
||||||
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
|
||||||
|
publisher: { '@id': 'https://sim.ai/#organization' },
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'WebPage',
|
||||||
|
'@id': 'https://sim.ai/#webpage',
|
||||||
|
url: 'https://sim.ai',
|
||||||
|
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||||
|
isPartOf: { '@id': 'https://sim.ai/#website' },
|
||||||
|
about: { '@id': 'https://sim.ai/#software' },
|
||||||
|
datePublished: '2024-01-01T00:00:00+00:00',
|
||||||
|
dateModified: new Date().toISOString(),
|
||||||
|
description:
|
||||||
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||||
|
breadcrumb: { '@id': 'https://sim.ai/#breadcrumb' },
|
||||||
|
inLanguage: 'en-US',
|
||||||
|
potentialAction: [{ '@type': 'ReadAction', target: ['https://sim.ai'] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
'@id': 'https://sim.ai/#breadcrumb',
|
||||||
|
itemListElement: [
|
||||||
|
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://sim.ai' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'WebApplication',
|
||||||
|
'@id': 'https://sim.ai/#software',
|
||||||
|
url: 'https://sim.ai',
|
||||||
|
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||||
|
description:
|
||||||
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||||
|
applicationCategory: 'DeveloperApplication',
|
||||||
|
operatingSystem: 'Web',
|
||||||
|
browserRequirements: 'Requires a modern browser with JavaScript enabled',
|
||||||
|
offers: [
|
||||||
|
{
|
||||||
|
'@type': 'Offer',
|
||||||
|
name: 'Community Plan',
|
||||||
|
price: '0',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Offer',
|
||||||
|
name: 'Pro Plan',
|
||||||
|
price: '20',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
priceSpecification: {
|
||||||
|
'@type': 'UnitPriceSpecification',
|
||||||
|
price: '20',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
unitText: 'MONTH',
|
||||||
|
billingIncrement: 1,
|
||||||
|
},
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Offer',
|
||||||
|
name: 'Team Plan',
|
||||||
|
price: '40',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
priceSpecification: {
|
||||||
|
'@type': 'UnitPriceSpecification',
|
||||||
|
price: '40',
|
||||||
|
priceCurrency: 'USD',
|
||||||
|
unitText: 'MONTH',
|
||||||
|
billingIncrement: 1,
|
||||||
|
},
|
||||||
|
availability: 'https://schema.org/InStock',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
featureList: [
|
||||||
|
'AI agent creation',
|
||||||
|
'Agentic workflow orchestration',
|
||||||
|
'1,000+ integrations',
|
||||||
|
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||||
|
'Knowledge base creation',
|
||||||
|
'Table creation',
|
||||||
|
'Document creation',
|
||||||
|
'API access',
|
||||||
|
'Custom functions',
|
||||||
|
'Scheduled workflows',
|
||||||
|
'Event triggers',
|
||||||
|
],
|
||||||
|
review: [
|
||||||
|
{
|
||||||
|
'@type': 'Review',
|
||||||
|
author: { '@type': 'Person', name: 'Hasan Toor' },
|
||||||
|
reviewBody:
|
||||||
|
'This startup just dropped the fastest way to build AI agents. This Figma-like canvas to build agents will blow your mind.',
|
||||||
|
url: 'https://x.com/hasantoxr/status/1912909502036525271',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Review',
|
||||||
|
author: { '@type': 'Person', name: 'nizzy' },
|
||||||
|
reviewBody:
|
||||||
|
'This is the zapier of agent building. I always believed that building agents and using AI should not be limited to technical people. I think this solves just that.',
|
||||||
|
url: 'https://x.com/nizzyabi/status/1907864421227180368',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Review',
|
||||||
|
author: { '@type': 'Organization', name: 'xyflow' },
|
||||||
|
reviewBody: 'A very good looking agent workflow builder and open source!',
|
||||||
|
url: 'https://x.com/xyflowdev/status/1909501499719438670',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'FAQPage',
|
||||||
|
'@id': 'https://sim.ai/#faq',
|
||||||
|
mainEntity: [
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What is Sim?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Which AI models does Sim support?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Sim supports all major AI models including OpenAI (GPT-5, GPT-4o), Anthropic (Claude), Google (Gemini), xAI (Grok), Mistral, Perplexity, and many more. You can also connect to open-source models via Ollama.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'How much does Sim cost?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Sim offers a free Community plan with $20 usage limit, a Pro plan at $20/month, a Team plan at $40/month, and custom Enterprise pricing. All plans include CLI/SDK access.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'Do I need coding skills to use Sim?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Question',
|
||||||
|
name: 'What enterprise features does Sim offer?',
|
||||||
|
acceptedAnswer: {
|
||||||
|
'@type': 'Answer',
|
||||||
|
text: 'Sim offers SOC2 and HIPAA compliance, SSO/SAML authentication, role-based access control, audit logs, dedicated support, custom SLAs, and on-premise deployment options for enterprise customers.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<script
|
||||||
|
type='application/ld+json'
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
55
apps/sim/app/(home)/components/templates/templates.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||||
|
import { Badge } from '@/components/emcn'
|
||||||
|
|
||||||
|
export default function Templates() {
|
||||||
|
const sectionRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { scrollYProgress } = useScroll({
|
||||||
|
target: sectionRef,
|
||||||
|
offset: ['start end', 'end start'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const inset = useTransform(scrollYProgress, [0.1, 0.35], [0, 16])
|
||||||
|
const borderRadius = useTransform(scrollYProgress, [0.1, 0.35], [0, 4])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={sectionRef}
|
||||||
|
id='templates'
|
||||||
|
aria-labelledby='templates-heading'
|
||||||
|
className='mt-[40px] bg-[#F6F6F6]'
|
||||||
|
>
|
||||||
|
<motion.div style={{ padding: inset }}>
|
||||||
|
<motion.div
|
||||||
|
style={{ borderRadius }}
|
||||||
|
className='bg-[#1C1C1C] px-[80px] pt-[120px] pb-[80px]'
|
||||||
|
>
|
||||||
|
<div className='flex flex-col items-start gap-[20px]'>
|
||||||
|
<Badge
|
||||||
|
variant='blue'
|
||||||
|
size='md'
|
||||||
|
dot
|
||||||
|
className='bg-[rgba(42,187,248,0.1)] font-season text-[#2ABBF8] uppercase tracking-[0.02em]'
|
||||||
|
>
|
||||||
|
Templates
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
id='templates-heading'
|
||||||
|
className='font-[430] font-season text-[40px] text-white leading-[100%] tracking-[-0.02em]'
|
||||||
|
>
|
||||||
|
Ready-made AI templates.
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className='max-w-[463px] font-[430] font-season text-[#F6F6F0]/50 text-[16px] leading-[125%] tracking-[0.02em]'>
|
||||||
|
Jump-start workflows with ready-made templates for any team—fully editable for your
|
||||||
|
stack.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
apps/sim/app/(home)/components/testimonials/testimonials.tsx
Normal file
18
apps/sim/app/(home)/components/testimonials/testimonials.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Testimonials section — social proof via user quotes.
|
||||||
|
*
|
||||||
|
* SEO:
|
||||||
|
* - `<section id="testimonials" aria-labelledby="testimonials-heading">`.
|
||||||
|
* - `<h2 id="testimonials-heading">` for the section title.
|
||||||
|
* - Each testimonial: `<blockquote cite="tweet-url">` with `<footer><cite>Author</cite></footer>`.
|
||||||
|
* - Profile images use `loading="lazy"` (below the fold).
|
||||||
|
*
|
||||||
|
* GEO:
|
||||||
|
* - Keep quote text as plain text in `<blockquote>` — not split across `<span>` elements.
|
||||||
|
* - Include full author name + handle (LLMs weigh attributed quotes higher).
|
||||||
|
* - Testimonials mentioning "Sim" by name carry more citation weight.
|
||||||
|
* - Review data here aligns with `review` entries in structured-data.tsx.
|
||||||
|
*/
|
||||||
|
export default function Testimonials() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
53
apps/sim/app/(home)/landing.tsx
Normal file
53
apps/sim/app/(home)/landing.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||||
|
import { season } from '@/app/_styles/fonts/season/season'
|
||||||
|
import {
|
||||||
|
Collaboration,
|
||||||
|
Enterprise,
|
||||||
|
Features,
|
||||||
|
Footer,
|
||||||
|
Hero,
|
||||||
|
Navbar,
|
||||||
|
Pricing,
|
||||||
|
StructuredData,
|
||||||
|
Templates,
|
||||||
|
Testimonials,
|
||||||
|
} from '@/app/(home)/components'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing page root component.
|
||||||
|
*
|
||||||
|
* ## SEO Architecture
|
||||||
|
* - Single `<h1>` inside Hero (only one per page).
|
||||||
|
* - Heading hierarchy: H1 (Hero) -> H2 (each section) -> H3 (sub-items).
|
||||||
|
* - Semantic landmarks: `<header>`, `<main>`, `<footer>`.
|
||||||
|
* - Every `<section>` has an `id` for anchor linking and `aria-labelledby` for accessibility.
|
||||||
|
* - `StructuredData` emits JSON-LD before any visible content.
|
||||||
|
*
|
||||||
|
* ## GEO Architecture
|
||||||
|
* - Above-fold content (Navbar, Hero) is statically rendered (Server Components where possible)
|
||||||
|
* for immediate availability to AI crawlers.
|
||||||
|
* - Section `id` attributes serve as fragment anchors for precise AI citations.
|
||||||
|
* - Content ordering prioritizes answer-first patterns: definition (Hero) ->
|
||||||
|
* examples (Templates) -> capabilities (Features) -> social proof (Collaboration, Testimonials) ->
|
||||||
|
* pricing (Pricing) -> enterprise (Enterprise).
|
||||||
|
*/
|
||||||
|
export default async function Landing() {
|
||||||
|
return (
|
||||||
|
<div className={`${season.variable} ${martianMono.variable} min-h-screen bg-[#1C1C1C]`}>
|
||||||
|
<StructuredData />
|
||||||
|
<header>
|
||||||
|
<Navbar />
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Hero />
|
||||||
|
<Templates />
|
||||||
|
<Features />
|
||||||
|
<Collaboration />
|
||||||
|
<Pricing />
|
||||||
|
<Enterprise />
|
||||||
|
<Testimonials />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
apps/sim/app/(home)/layout.tsx
Normal file
18
apps/sim/app/(home)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
|
||||||
|
import { season } from '@/app/_styles/fonts/season/season'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing page route-group layout.
|
||||||
|
*
|
||||||
|
* Applies landing-specific font CSS variables to the subtree:
|
||||||
|
* - `--font-season` (Season Sans): Headings and display text
|
||||||
|
* - `--font-martian-mono` (Martian Mono): Code snippets and technical accents
|
||||||
|
*
|
||||||
|
* Available to child components via Tailwind (`font-season`, `font-martian-mono`).
|
||||||
|
*
|
||||||
|
* SEO metadata for the `/` route is exported from `app/page.tsx` — not here.
|
||||||
|
* This layout only applies when a `page.tsx` exists inside the `(home)/` route group.
|
||||||
|
*/
|
||||||
|
export default function HomeLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className={`${season.variable} ${martianMono.variable}`}>{children}</div>
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export default function StructuredData() {
|
|||||||
name: 'Sim',
|
name: 'Sim',
|
||||||
alternateName: 'Sim',
|
alternateName: 'Sim',
|
||||||
description:
|
description:
|
||||||
'Open-source AI agent workflow builder used by developers at trail-blazing startups to Fortune 500 companies',
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows.',
|
||||||
url: 'https://sim.ai',
|
url: 'https://sim.ai',
|
||||||
logo: {
|
logo: {
|
||||||
'@type': 'ImageObject',
|
'@type': 'ImageObject',
|
||||||
@@ -36,9 +36,9 @@ export default function StructuredData() {
|
|||||||
'@type': 'WebSite',
|
'@type': 'WebSite',
|
||||||
'@id': 'https://sim.ai/#website',
|
'@id': 'https://sim.ai/#website',
|
||||||
url: 'https://sim.ai',
|
url: 'https://sim.ai',
|
||||||
name: 'Sim - AI Agent Workflow Builder',
|
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||||
description:
|
description:
|
||||||
'Open-source AI agent workflow builder. 60,000+ developers build and deploy agentic workflows. SOC2 and HIPAA compliant.',
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Join 100,000+ builders.',
|
||||||
publisher: {
|
publisher: {
|
||||||
'@id': 'https://sim.ai/#organization',
|
'@id': 'https://sim.ai/#organization',
|
||||||
},
|
},
|
||||||
@@ -48,7 +48,7 @@ export default function StructuredData() {
|
|||||||
'@type': 'WebPage',
|
'@type': 'WebPage',
|
||||||
'@id': 'https://sim.ai/#webpage',
|
'@id': 'https://sim.ai/#webpage',
|
||||||
url: 'https://sim.ai',
|
url: 'https://sim.ai',
|
||||||
name: 'Sim - Workflows for LLMs | Build AI Agent Workflows',
|
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||||
isPartOf: {
|
isPartOf: {
|
||||||
'@id': 'https://sim.ai/#website',
|
'@id': 'https://sim.ai/#website',
|
||||||
},
|
},
|
||||||
@@ -58,7 +58,7 @@ export default function StructuredData() {
|
|||||||
datePublished: '2024-01-01T00:00:00+00:00',
|
datePublished: '2024-01-01T00:00:00+00:00',
|
||||||
dateModified: new Date().toISOString(),
|
dateModified: new Date().toISOString(),
|
||||||
description:
|
description:
|
||||||
'Build and deploy AI agent workflows with Sim. Visual drag-and-drop interface for creating powerful LLM-powered automations.',
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs.',
|
||||||
breadcrumb: {
|
breadcrumb: {
|
||||||
'@id': 'https://sim.ai/#breadcrumb',
|
'@id': 'https://sim.ai/#breadcrumb',
|
||||||
},
|
},
|
||||||
@@ -85,9 +85,9 @@ export default function StructuredData() {
|
|||||||
{
|
{
|
||||||
'@type': 'SoftwareApplication',
|
'@type': 'SoftwareApplication',
|
||||||
'@id': 'https://sim.ai/#software',
|
'@id': 'https://sim.ai/#software',
|
||||||
name: 'Sim - AI Agent Workflow Builder',
|
name: 'Sim — Build AI Agents & Run Your Agentic Workforce',
|
||||||
description:
|
description:
|
||||||
'Open-source AI agent workflow builder used by 60,000+ developers. Build agentic workflows with visual drag-and-drop interface. SOC2 and HIPAA compliant. Integrate with 100+ apps.',
|
'Sim is the open-source platform to build AI agents and run your agentic workforce. Connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||||
applicationCategory: 'DeveloperApplication',
|
applicationCategory: 'DeveloperApplication',
|
||||||
applicationSubCategory: 'AI Development Tools',
|
applicationSubCategory: 'AI Development Tools',
|
||||||
operatingSystem: 'Web, Windows, macOS, Linux',
|
operatingSystem: 'Web, Windows, macOS, Linux',
|
||||||
@@ -159,12 +159,13 @@ export default function StructuredData() {
|
|||||||
worstRating: '1',
|
worstRating: '1',
|
||||||
},
|
},
|
||||||
featureList: [
|
featureList: [
|
||||||
'Visual workflow builder',
|
'AI agent creation',
|
||||||
'Drag-and-drop interface',
|
'Agentic workflow orchestration',
|
||||||
'100+ integrations',
|
'1,000+ integrations',
|
||||||
'AI model support (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
|
||||||
'Real-time collaboration',
|
'Knowledge base creation',
|
||||||
'Version control',
|
'Table creation',
|
||||||
|
'Document creation',
|
||||||
'API access',
|
'API access',
|
||||||
'Custom functions',
|
'Custom functions',
|
||||||
'Scheduled workflows',
|
'Scheduled workflows',
|
||||||
@@ -174,7 +175,7 @@ export default function StructuredData() {
|
|||||||
{
|
{
|
||||||
'@type': 'ImageObject',
|
'@type': 'ImageObject',
|
||||||
url: 'https://sim.ai/logo/426-240/primary/small.png',
|
url: 'https://sim.ai/logo/426-240/primary/small.png',
|
||||||
caption: 'Sim AI agent workflow builder interface',
|
caption: 'Sim — build AI agents and run your agentic workforce',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -187,7 +188,7 @@ export default function StructuredData() {
|
|||||||
name: 'What is Sim?',
|
name: 'What is Sim?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
'@type': 'Answer',
|
'@type': 'Answer',
|
||||||
text: 'Sim is an open-source AI agent workflow builder used by 60,000+ developers at trail-blazing startups to Fortune 500 companies. It provides a visual drag-and-drop interface for building and deploying agentic workflows. Sim is SOC2 and HIPAA compliant.',
|
text: 'Sim is the open-source platform to build AI agents and run your agentic workforce. Teams connect 1,000+ integrations and LLMs to deploy and orchestrate agentic workflows. Create agents, workflows, knowledge bases, tables, and docs. Trusted by over 100,000 builders. SOC2 and HIPAA compliant.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -203,7 +204,7 @@ export default function StructuredData() {
|
|||||||
name: 'Do I need coding skills to use Sim?',
|
name: 'Do I need coding skills to use Sim?',
|
||||||
acceptedAnswer: {
|
acceptedAnswer: {
|
||||||
'@type': 'Answer',
|
'@type': 'Answer',
|
||||||
text: 'No coding skills are required! Sim features a visual drag-and-drop interface that makes it easy to build AI workflows. However, developers can also use custom functions and our API for advanced use cases.',
|
text: 'No coding skills are required. Sim provides a visual interface for building AI agents and agentic workflows. Developers can also use custom functions, the API, and the CLI/SDK for advanced use cases.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
14
apps/sim/app/_styles/fonts/martian-mono/martian-mono.ts
Normal file
14
apps/sim/app/_styles/fonts/martian-mono/martian-mono.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Martian_Mono } from 'next/font/google'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Martian Mono font configuration
|
||||||
|
* Monospaced variable font used for code snippets, technical content, and accent text
|
||||||
|
* on the landing page. Supports weights 100-800.
|
||||||
|
*/
|
||||||
|
export const martianMono = Martian_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
display: 'swap',
|
||||||
|
variable: '--font-martian-mono',
|
||||||
|
weight: 'variable',
|
||||||
|
fallback: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
|
||||||
|
})
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { auditMock, createMockLogger, createMockRequest } from '@sim/testing'
|
import { createMockLogger, createMockRequest } from '@sim/testing'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
describe('OAuth Disconnect API Route', () => {
|
describe('OAuth Disconnect API Route', () => {
|
||||||
@@ -67,8 +67,6 @@ describe('OAuth Disconnect API Route', () => {
|
|||||||
vi.doMock('@/lib/webhooks/utils.server', () => ({
|
vi.doMock('@/lib/webhooks/utils.server', () => ({
|
||||||
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
|
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.doMock('@/lib/audit/log', () => auditMock)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, like, or } from 'drizzle-orm'
|
import { and, eq, like, or } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||||
@@ -119,20 +118,6 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.OAUTH_DISCONNECTED,
|
|
||||||
resourceType: AuditResourceType.OAUTH,
|
|
||||||
resourceId: providerId ?? provider,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: provider,
|
|
||||||
description: `Disconnected OAuth provider: ${provider}`,
|
|
||||||
metadata: { provider, providerId },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
|
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)
|
||||||
|
|||||||
@@ -3,12 +3,10 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { auditMock, loggerMock } from '@sim/testing'
|
import { loggerMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||||
isDev: true,
|
isDev: true,
|
||||||
isHosted: false,
|
isHosted: false,
|
||||||
@@ -218,11 +216,8 @@ describe('Chat Edit API Route', () => {
|
|||||||
workflowId: 'workflow-123',
|
workflowId: 'workflow-123',
|
||||||
}
|
}
|
||||||
|
|
||||||
mockCheckChatAccess.mockResolvedValue({
|
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||||
hasAccess: true,
|
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
|
||||||
chat: mockChat,
|
|
||||||
workspaceId: 'workspace-123',
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -316,11 +311,8 @@ describe('Chat Edit API Route', () => {
|
|||||||
workflowId: 'workflow-123',
|
workflowId: 'workflow-123',
|
||||||
}
|
}
|
||||||
|
|
||||||
mockCheckChatAccess.mockResolvedValue({
|
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||||
hasAccess: true,
|
mockLimit.mockResolvedValueOnce([])
|
||||||
chat: mockChat,
|
|
||||||
workspaceId: 'workspace-123',
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -379,11 +371,8 @@ describe('Chat Edit API Route', () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mockCheckChatAccess.mockResolvedValue({
|
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||||
hasAccess: true,
|
mockWhere.mockResolvedValue(undefined)
|
||||||
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
|
|
||||||
workspaceId: 'workspace-123',
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -404,11 +393,8 @@ describe('Chat Edit API Route', () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mockCheckChatAccess.mockResolvedValue({
|
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||||
hasAccess: true,
|
mockWhere.mockResolvedValue(undefined)
|
||||||
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
|
|
||||||
workspaceId: 'workspace-123',
|
|
||||||
})
|
|
||||||
|
|
||||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { isDev } from '@/lib/core/config/feature-flags'
|
import { isDev } from '@/lib/core/config/feature-flags'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
@@ -104,11 +103,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
try {
|
try {
|
||||||
const validatedData = chatUpdateSchema.parse(body)
|
const validatedData = chatUpdateSchema.parse(body)
|
||||||
|
|
||||||
const {
|
const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id)
|
||||||
hasAccess,
|
|
||||||
chat: existingChatRecord,
|
|
||||||
workspaceId: chatWorkspaceId,
|
|
||||||
} = await checkChatAccess(chatId, session.user.id)
|
|
||||||
|
|
||||||
if (!hasAccess || !existingChatRecord) {
|
if (!hasAccess || !existingChatRecord) {
|
||||||
return createErrorResponse('Chat not found or access denied', 404)
|
return createErrorResponse('Chat not found or access denied', 404)
|
||||||
@@ -222,19 +217,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
logger.info(`Chat "${chatId}" updated successfully`)
|
logger.info(`Chat "${chatId}" updated successfully`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: chatWorkspaceId || null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.CHAT_UPDATED,
|
|
||||||
resourceType: AuditResourceType.CHAT,
|
|
||||||
resourceId: chatId,
|
|
||||||
resourceName: title || existingChatRecord.title,
|
|
||||||
description: `Updated chat deployment "${title || existingChatRecord.title}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
id: chatId,
|
id: chatId,
|
||||||
chatUrl,
|
chatUrl,
|
||||||
@@ -270,11 +252,7 @@ export async function DELETE(
|
|||||||
return createErrorResponse('Unauthorized', 401)
|
return createErrorResponse('Unauthorized', 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { hasAccess } = await checkChatAccess(chatId, session.user.id)
|
||||||
hasAccess,
|
|
||||||
chat: chatRecord,
|
|
||||||
workspaceId: chatWorkspaceId,
|
|
||||||
} = await checkChatAccess(chatId, session.user.id)
|
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return createErrorResponse('Chat not found or access denied', 404)
|
return createErrorResponse('Chat not found or access denied', 404)
|
||||||
@@ -284,19 +262,6 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`Chat "${chatId}" deleted successfully`)
|
logger.info(`Chat "${chatId}" deleted successfully`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: chatWorkspaceId || null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.CHAT_DELETED,
|
|
||||||
resourceType: AuditResourceType.CHAT,
|
|
||||||
resourceId: chatId,
|
|
||||||
resourceName: chatRecord?.title,
|
|
||||||
description: `Deleted chat deployment "${chatRecord?.title}"`,
|
|
||||||
request: _request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
message: 'Chat deployment deleted successfully',
|
message: 'Chat deployment deleted successfully',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
/**
|
/**
|
||||||
* Tests for chat API route
|
* Tests for chat API route
|
||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { auditMock } from '@sim/testing'
|
|
||||||
import { NextRequest } from 'next/server'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
describe('Chat API Route', () => {
|
describe('Chat API Route', () => {
|
||||||
@@ -31,8 +30,6 @@ describe('Chat API Route', () => {
|
|||||||
mockInsert.mockReturnValue({ values: mockValues })
|
mockInsert.mockReturnValue({ values: mockValues })
|
||||||
mockValues.mockReturnValue({ returning: mockReturning })
|
mockValues.mockReturnValue({ returning: mockReturning })
|
||||||
|
|
||||||
vi.doMock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
vi.doMock('@sim/db', () => ({
|
vi.doMock('@sim/db', () => ({
|
||||||
db: {
|
db: {
|
||||||
select: mockSelect,
|
select: mockSelect,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { eq } from 'drizzle-orm'
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { isDev } from '@/lib/core/config/feature-flags'
|
import { isDev } from '@/lib/core/config/feature-flags'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
@@ -43,7 +42,7 @@ const chatSchema = z.object({
|
|||||||
.default([]),
|
.default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function GET(_request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
|
|
||||||
@@ -175,7 +174,7 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
identifier,
|
identifier,
|
||||||
title,
|
title,
|
||||||
description: description || null,
|
description: description || '',
|
||||||
customizations: mergedCustomizations,
|
customizations: mergedCustomizations,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
authType,
|
authType,
|
||||||
@@ -225,20 +224,6 @@ export async function POST(request: NextRequest) {
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowRecord.workspaceId || null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.CHAT_DEPLOYED,
|
|
||||||
resourceType: AuditResourceType.CHAT,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: title,
|
|
||||||
description: `Deployed chat "${title}"`,
|
|
||||||
metadata: { workflowId, identifier, authType },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
id,
|
id,
|
||||||
chatUrl,
|
chatUrl,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForChatCreation(
|
|||||||
export async function checkChatAccess(
|
export async function checkChatAccess(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ hasAccess: boolean; chat?: any; workspaceId?: string }> {
|
): Promise<{ hasAccess: boolean; chat?: any }> {
|
||||||
const chatData = await db
|
const chatData = await db
|
||||||
.select({
|
.select({
|
||||||
chat: chat,
|
chat: chat,
|
||||||
@@ -78,9 +78,7 @@ export async function checkChatAccess(
|
|||||||
action: 'admin',
|
action: 'admin',
|
||||||
})
|
})
|
||||||
|
|
||||||
return authorization.allowed
|
return authorization.allowed ? { hasAccess: true, chat: chatRecord } : { hasAccess: false }
|
||||||
? { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId }
|
|
||||||
: { hasAccess: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateChatAuth(
|
export async function validateChatAuth(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||||
@@ -176,19 +175,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
emailSent: !!email,
|
emailSent: !!email,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.CREDENTIAL_SET_INVITATION_CREATED,
|
|
||||||
resourceType: AuditResourceType.CREDENTIAL_SET,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: result.set.name,
|
|
||||||
description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
invitation: {
|
invitation: {
|
||||||
...invitation,
|
...invitation,
|
||||||
@@ -249,19 +235,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.CREDENTIAL_SET_INVITATION_REVOKED,
|
|
||||||
resourceType: AuditResourceType.CREDENTIAL_SET,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: result.set.name,
|
|
||||||
description: `Revoked an invitation for credential set "${result.set.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error cancelling invitation', error)
|
logger.error('Error cancelling invitation', error)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { account, credentialSet, credentialSetMember, member, user } from '@sim/
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
|
||||||
@@ -14,7 +13,6 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
|
|||||||
const [set] = await db
|
const [set] = await db
|
||||||
.select({
|
.select({
|
||||||
id: credentialSet.id,
|
id: credentialSet.id,
|
||||||
name: credentialSet.name,
|
|
||||||
organizationId: credentialSet.organizationId,
|
organizationId: credentialSet.organizationId,
|
||||||
providerId: credentialSet.providerId,
|
providerId: credentialSet.providerId,
|
||||||
})
|
})
|
||||||
@@ -179,19 +177,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.CREDENTIAL_SET_MEMBER_REMOVED,
|
|
||||||
resourceType: AuditResourceType.CREDENTIAL_SET,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: result.set.name,
|
|
||||||
description: `Removed member from credential set "${result.set.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error removing member from credential set', error)
|
logger.error('Error removing member from credential set', error)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
|
|
||||||
@@ -132,19 +131,6 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1)
|
const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.CREDENTIAL_SET_UPDATED,
|
|
||||||
resourceType: AuditResourceType.CREDENTIAL_SET,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: updated?.name ?? result.set.name,
|
|
||||||
description: `Updated credential set "${updated?.name ?? result.set.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ credentialSet: updated })
|
return NextResponse.json({ credentialSet: updated })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
@@ -189,19 +175,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
|
|
||||||
logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id })
|
logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id })
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.CREDENTIAL_SET_DELETED,
|
|
||||||
resourceType: AuditResourceType.CREDENTIAL_SET,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: result.set.name,
|
|
||||||
description: `Deleted credential set "${result.set.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting credential set', error)
|
logger.error('Error deleting credential set', error)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, count, desc, eq } from 'drizzle-orm'
|
import { and, count, desc, eq } from 'drizzle-orm'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasCredentialSetsAccess } from '@/lib/billing'
|
import { hasCredentialSetsAccess } from '@/lib/billing'
|
||||||
|
|
||||||
@@ -166,19 +165,6 @@ export async function POST(req: Request) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.CREDENTIAL_SET_CREATED,
|
|
||||||
resourceType: AuditResourceType.CREDENTIAL_SET,
|
|
||||||
resourceId: newCredentialSet.id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: name,
|
|
||||||
description: `Created credential set "${name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 })
|
return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
|
||||||
@@ -116,19 +115,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: targetWorkspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.FOLDER_DUPLICATED,
|
|
||||||
resourceType: AuditResourceType.FOLDER,
|
|
||||||
resourceId: newFolderId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: name,
|
|
||||||
description: `Duplicated folder "${sourceFolder.name}" as "${name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
id: newFolderId,
|
id: newFolderId,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
auditMock,
|
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
type MockUser,
|
type MockUser,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
@@ -13,8 +12,6 @@ import {
|
|||||||
} from '@sim/testing'
|
} from '@sim/testing'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
/** Type for captured folder values in tests */
|
/** Type for captured folder values in tests */
|
||||||
interface CapturedFolderValues {
|
interface CapturedFolderValues {
|
||||||
name?: string
|
name?: string
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
@@ -168,19 +167,6 @@ export async function DELETE(
|
|||||||
deletionStats,
|
deletionStats,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: existingFolder.workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.FOLDER_DELETED,
|
|
||||||
resourceType: AuditResourceType.FOLDER,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: existingFolder.name,
|
|
||||||
description: `Deleted folder "${existingFolder.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
deletedItems: deletionStats,
|
deletedItems: deletionStats,
|
||||||
|
|||||||
@@ -3,17 +3,9 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import { createMockRequest, mockAuth, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
|
||||||
auditMock,
|
|
||||||
createMockRequest,
|
|
||||||
mockAuth,
|
|
||||||
mockConsoleLogger,
|
|
||||||
setupCommonApiMocks,
|
|
||||||
} from '@sim/testing'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
interface CapturedFolderValues {
|
interface CapturedFolderValues {
|
||||||
name?: string
|
name?: string
|
||||||
color?: string
|
color?: string
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { workflowFolder } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
|
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
|
|
||||||
@@ -120,20 +119,6 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
logger.info('Created new folder:', { id, name, workspaceId, parentId })
|
logger.info('Created new folder:', { id, name, workspaceId, parentId })
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.FOLDER_CREATED,
|
|
||||||
resourceType: AuditResourceType.FOLDER,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: name.trim(),
|
|
||||||
description: `Created folder "${name.trim()}"`,
|
|
||||||
metadata: { name: name.trim() },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ folder: newFolder })
|
return NextResponse.json({ folder: newFolder })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating folder:', { error })
|
logger.error('Error creating folder:', { error })
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils'
|
import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils'
|
||||||
@@ -103,11 +102,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const {
|
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
|
||||||
hasAccess,
|
|
||||||
form: formRecord,
|
|
||||||
workspaceId: formWorkspaceId,
|
|
||||||
} = await checkFormAccess(id, session.user.id)
|
|
||||||
|
|
||||||
if (!hasAccess || !formRecord) {
|
if (!hasAccess || !formRecord) {
|
||||||
return createErrorResponse('Form not found or access denied', 404)
|
return createErrorResponse('Form not found or access denied', 404)
|
||||||
@@ -189,19 +184,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
logger.info(`Form ${id} updated successfully`)
|
logger.info(`Form ${id} updated successfully`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: formWorkspaceId ?? null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.FORM_UPDATED,
|
|
||||||
resourceType: AuditResourceType.FORM,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: formRecord.title ?? undefined,
|
|
||||||
description: `Updated form "${formRecord.title}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
message: 'Form updated successfully',
|
message: 'Form updated successfully',
|
||||||
})
|
})
|
||||||
@@ -231,11 +213,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const {
|
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
|
||||||
hasAccess,
|
|
||||||
form: formRecord,
|
|
||||||
workspaceId: formWorkspaceId,
|
|
||||||
} = await checkFormAccess(id, session.user.id)
|
|
||||||
|
|
||||||
if (!hasAccess || !formRecord) {
|
if (!hasAccess || !formRecord) {
|
||||||
return createErrorResponse('Form not found or access denied', 404)
|
return createErrorResponse('Form not found or access denied', 404)
|
||||||
@@ -245,19 +223,6 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`Form ${id} deleted (soft delete)`)
|
logger.info(`Form ${id} deleted (soft delete)`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: formWorkspaceId ?? null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.FORM_DELETED,
|
|
||||||
resourceType: AuditResourceType.FORM,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: formRecord.title ?? undefined,
|
|
||||||
description: `Deleted form "${formRecord.title}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
message: 'Form deleted successfully',
|
message: 'Form deleted successfully',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { eq } from 'drizzle-orm'
|
|||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { isDev } from '@/lib/core/config/feature-flags'
|
import { isDev } from '@/lib/core/config/feature-flags'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
@@ -179,7 +178,7 @@ export async function POST(request: NextRequest) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
identifier,
|
identifier,
|
||||||
title,
|
title,
|
||||||
description: description || null,
|
description: description || '',
|
||||||
customizations: mergedCustomizations,
|
customizations: mergedCustomizations,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
authType,
|
authType,
|
||||||
@@ -196,19 +195,6 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
logger.info(`Form "${title}" deployed successfully at ${formUrl}`)
|
logger.info(`Form "${title}" deployed successfully at ${formUrl}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowRecord.workspaceId ?? null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.FORM_CREATED,
|
|
||||||
resourceType: AuditResourceType.FORM,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: title,
|
|
||||||
description: `Created form "${title}" for workflow "${workflowRecord.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
id,
|
id,
|
||||||
formUrl,
|
formUrl,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForFormCreation(
|
|||||||
export async function checkFormAccess(
|
export async function checkFormAccess(
|
||||||
formId: string,
|
formId: string,
|
||||||
userId: string
|
userId: string
|
||||||
): Promise<{ hasAccess: boolean; form?: any; workspaceId?: string }> {
|
): Promise<{ hasAccess: boolean; form?: any }> {
|
||||||
const formData = await db
|
const formData = await db
|
||||||
.select({ form: form, workflowWorkspaceId: workflow.workspaceId })
|
.select({ form: form, workflowWorkspaceId: workflow.workspaceId })
|
||||||
.from(form)
|
.from(form)
|
||||||
@@ -75,9 +75,7 @@ export async function checkFormAccess(
|
|||||||
action: 'admin',
|
action: 'admin',
|
||||||
})
|
})
|
||||||
|
|
||||||
return authorization.allowed
|
return authorization.allowed ? { hasAccess: true, form: formRecord } : { hasAccess: false }
|
||||||
? { hasAccess: true, form: formRecord, workspaceId: workflowWorkspaceId }
|
|
||||||
: { hasAccess: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validateFormAuth(
|
export async function validateFormAuth(
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
auditMock,
|
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
mockConsoleLogger,
|
mockConsoleLogger,
|
||||||
@@ -36,8 +35,6 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
|
|||||||
mockDrizzleOrm()
|
mockDrizzleOrm()
|
||||||
mockConsoleLogger()
|
mockConsoleLogger()
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
describe('Document By ID API Route', () => {
|
describe('Document By ID API Route', () => {
|
||||||
const mockAuth$ = mockAuth()
|
const mockAuth$ = mockAuth()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import {
|
import {
|
||||||
@@ -198,19 +197,6 @@ export async function PUT(
|
|||||||
`[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}`
|
`[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}`
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.DOCUMENT_UPDATED,
|
|
||||||
resourceType: AuditResourceType.DOCUMENT,
|
|
||||||
resourceId: documentId,
|
|
||||||
resourceName: validatedData.filename ?? accessCheck.document?.filename,
|
|
||||||
description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${accessCheck.knowledgeBase?.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: updatedDocument,
|
data: updatedDocument,
|
||||||
@@ -271,19 +257,6 @@ export async function DELETE(
|
|||||||
`[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}`
|
`[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}`
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.DOCUMENT_DELETED,
|
|
||||||
resourceType: AuditResourceType.DOCUMENT,
|
|
||||||
resourceId: documentId,
|
|
||||||
resourceName: accessCheck.document?.filename,
|
|
||||||
description: `Deleted document "${accessCheck.document?.filename}" from knowledge base "${accessCheck.knowledgeBase?.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: result,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
auditMock,
|
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
mockConsoleLogger,
|
mockConsoleLogger,
|
||||||
@@ -41,8 +40,6 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
|
|||||||
mockDrizzleOrm()
|
mockDrizzleOrm()
|
||||||
mockConsoleLogger()
|
mockConsoleLogger()
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
describe('Knowledge Base Documents API Route', () => {
|
describe('Knowledge Base Documents API Route', () => {
|
||||||
const mockAuth$ = mockAuth()
|
const mockAuth$ = mockAuth()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { randomUUID } from 'crypto'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import {
|
import {
|
||||||
@@ -245,19 +244,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
|
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.DOCUMENT_UPLOADED,
|
|
||||||
resourceType: AuditResourceType.DOCUMENT,
|
|
||||||
resourceId: knowledgeBaseId,
|
|
||||||
resourceName: `${createdDocuments.length} document(s)`,
|
|
||||||
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${accessCheck.knowledgeBase?.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -306,19 +292,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.DOCUMENT_UPLOADED,
|
|
||||||
resourceType: AuditResourceType.DOCUMENT,
|
|
||||||
resourceId: knowledgeBaseId,
|
|
||||||
resourceName: validatedData.filename,
|
|
||||||
description: `Uploaded document "${validatedData.filename}" to knowledge base "${accessCheck.knowledgeBase?.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: newDocument,
|
data: newDocument,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
auditMock,
|
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
mockConsoleLogger,
|
mockConsoleLogger,
|
||||||
@@ -17,8 +16,6 @@ mockKnowledgeSchemas()
|
|||||||
mockDrizzleOrm()
|
mockDrizzleOrm()
|
||||||
mockConsoleLogger()
|
mockConsoleLogger()
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
vi.mock('@/lib/knowledge/service', () => ({
|
vi.mock('@/lib/knowledge/service', () => ({
|
||||||
getKnowledgeBaseById: vi.fn(),
|
getKnowledgeBaseById: vi.fn(),
|
||||||
updateKnowledgeBase: vi.fn(),
|
updateKnowledgeBase: vi.fn(),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -136,19 +135,6 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`)
|
logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.KNOWLEDGE_BASE_UPDATED,
|
|
||||||
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: validatedData.name ?? updatedKnowledgeBase.name,
|
|
||||||
description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: updatedKnowledgeBase,
|
data: updatedKnowledgeBase,
|
||||||
@@ -211,19 +197,6 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`)
|
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.KNOWLEDGE_BASE_DELETED,
|
|
||||||
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: accessCheck.knowledgeBase.name,
|
|
||||||
description: `Deleted knowledge base "${accessCheck.knowledgeBase.name}"`,
|
|
||||||
request: _request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: { message: 'Knowledge base deleted successfully' },
|
data: { message: 'Knowledge base deleted successfully' },
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
auditMock,
|
|
||||||
createMockRequest,
|
createMockRequest,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
mockConsoleLogger,
|
mockConsoleLogger,
|
||||||
@@ -17,8 +16,6 @@ mockKnowledgeSchemas()
|
|||||||
mockDrizzleOrm()
|
mockDrizzleOrm()
|
||||||
mockConsoleLogger()
|
mockConsoleLogger()
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||||
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -110,20 +109,6 @@ export async function POST(req: NextRequest) {
|
|||||||
`[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
|
`[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: validatedData.workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.KNOWLEDGE_BASE_CREATED,
|
|
||||||
resourceType: AuditResourceType.KNOWLEDGE_BASE,
|
|
||||||
resourceId: newKnowledgeBase.id,
|
|
||||||
resourceName: validatedData.name,
|
|
||||||
description: `Created knowledge base "${validatedData.name}"`,
|
|
||||||
metadata: { name: validatedData.name },
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: newKnowledgeBase,
|
data: newKnowledgeBase,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export interface EmbeddingData {
|
|||||||
|
|
||||||
export interface KnowledgeBaseAccessResult {
|
export interface KnowledgeBaseAccessResult {
|
||||||
hasAccess: true
|
hasAccess: true
|
||||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
|
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnowledgeBaseAccessDenied {
|
export interface KnowledgeBaseAccessDenied {
|
||||||
@@ -113,7 +113,7 @@ export type KnowledgeBaseAccessCheck = KnowledgeBaseAccessResult | KnowledgeBase
|
|||||||
export interface DocumentAccessResult {
|
export interface DocumentAccessResult {
|
||||||
hasAccess: true
|
hasAccess: true
|
||||||
document: DocumentData
|
document: DocumentData
|
||||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
|
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentAccessDenied {
|
export interface DocumentAccessDenied {
|
||||||
@@ -128,7 +128,7 @@ export interface ChunkAccessResult {
|
|||||||
hasAccess: true
|
hasAccess: true
|
||||||
chunk: EmbeddingData
|
chunk: EmbeddingData
|
||||||
document: DocumentData
|
document: DocumentData
|
||||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
|
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChunkAccessDenied {
|
export interface ChunkAccessDenied {
|
||||||
@@ -151,7 +151,6 @@ export async function checkKnowledgeBaseAccess(
|
|||||||
id: knowledgeBase.id,
|
id: knowledgeBase.id,
|
||||||
userId: knowledgeBase.userId,
|
userId: knowledgeBase.userId,
|
||||||
workspaceId: knowledgeBase.workspaceId,
|
workspaceId: knowledgeBase.workspaceId,
|
||||||
name: knowledgeBase.name,
|
|
||||||
})
|
})
|
||||||
.from(knowledgeBase)
|
.from(knowledgeBase)
|
||||||
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
||||||
@@ -194,7 +193,6 @@ export async function checkKnowledgeBaseWriteAccess(
|
|||||||
id: knowledgeBase.id,
|
id: knowledgeBase.id,
|
||||||
userId: knowledgeBase.userId,
|
userId: knowledgeBase.userId,
|
||||||
workspaceId: knowledgeBase.workspaceId,
|
workspaceId: knowledgeBase.workspaceId,
|
||||||
name: knowledgeBase.name,
|
|
||||||
})
|
})
|
||||||
.from(knowledgeBase)
|
.from(knowledgeBase)
|
||||||
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { mcpServers } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, isNull } from 'drizzle-orm'
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpService } from '@/lib/mcp/service'
|
import { mcpService } from '@/lib/mcp/service'
|
||||||
@@ -17,11 +16,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
* PATCH - Update an MCP server in the workspace (requires write or admin permission)
|
* PATCH - Update an MCP server in the workspace (requires write or admin permission)
|
||||||
*/
|
*/
|
||||||
export const PATCH = withMcpAuth<{ id: string }>('write')(
|
export const PATCH = withMcpAuth<{ id: string }>('write')(
|
||||||
async (
|
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||||
request: NextRequest,
|
|
||||||
{ userId, userName, userEmail, workspaceId, requestId },
|
|
||||||
{ params }
|
|
||||||
) => {
|
|
||||||
const { id: serverId } = await params
|
const { id: serverId } = await params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -90,20 +85,6 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
|
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_UPDATED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId,
|
|
||||||
resourceName: updatedServer.name,
|
|
||||||
description: `Updated MCP server "${updatedServer.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ server: updatedServer })
|
return createMcpSuccessResponse({ server: updatedServer })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error updating MCP server:`, error)
|
logger.error(`[${requestId}] Error updating MCP server:`, error)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { mcpServers } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, isNull } from 'drizzle-orm'
|
import { and, eq, isNull } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpService } from '@/lib/mcp/service'
|
import { mcpService } from '@/lib/mcp/service'
|
||||||
@@ -56,7 +55,7 @@ export const GET = withMcpAuth('read')(
|
|||||||
* it will be updated instead of creating a duplicate.
|
* it will be updated instead of creating a duplicate.
|
||||||
*/
|
*/
|
||||||
export const POST = withMcpAuth('write')(
|
export const POST = withMcpAuth('write')(
|
||||||
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
|
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||||
try {
|
try {
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
|
|
||||||
@@ -162,20 +161,6 @@ export const POST = withMcpAuth('write')(
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_ADDED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId,
|
|
||||||
resourceName: body.name,
|
|
||||||
description: `Added MCP server "${body.name}"`,
|
|
||||||
metadata: { serverName: body.name, transport: body.transport },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ serverId }, 201)
|
return createMcpSuccessResponse({ serverId }, 201)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error registering MCP server:`, error)
|
logger.error(`[${requestId}] Error registering MCP server:`, error)
|
||||||
@@ -192,7 +177,7 @@ export const POST = withMcpAuth('write')(
|
|||||||
* DELETE - Delete an MCP server from the workspace (requires admin permission)
|
* DELETE - Delete an MCP server from the workspace (requires admin permission)
|
||||||
*/
|
*/
|
||||||
export const DELETE = withMcpAuth('admin')(
|
export const DELETE = withMcpAuth('admin')(
|
||||||
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
|
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const serverId = searchParams.get('serverId')
|
const serverId = searchParams.get('serverId')
|
||||||
@@ -223,20 +208,6 @@ export const DELETE = withMcpAuth('admin')(
|
|||||||
await mcpService.clearCache(workspaceId)
|
await mcpService.clearCache(workspaceId)
|
||||||
|
|
||||||
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
|
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_REMOVED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId!,
|
|
||||||
resourceName: deletedServer.name,
|
|
||||||
description: `Removed MCP server "${deletedServer.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error deleting MCP server:`, error)
|
logger.error(`[${requestId}] Error deleting MCP server:`, error)
|
||||||
|
|||||||
@@ -105,16 +105,6 @@ export const POST = withMcpAuth('write')(
|
|||||||
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
|
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-validate domain after env var resolution
|
|
||||||
try {
|
|
||||||
validateMcpDomain(testConfig.url)
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof McpDomainNotAllowedError) {
|
|
||||||
return createMcpErrorResponse(e, e.message, 403)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
const testSecurityPolicy = {
|
const testSecurityPolicy = {
|
||||||
requireConsent: false,
|
requireConsent: false,
|
||||||
auditLevel: 'none' as const,
|
auditLevel: 'none' as const,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
@@ -72,11 +71,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
|||||||
* PATCH - Update a workflow MCP server
|
* PATCH - Update a workflow MCP server
|
||||||
*/
|
*/
|
||||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||||
async (
|
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||||
request: NextRequest,
|
|
||||||
{ userId, userName, userEmail, workspaceId, requestId },
|
|
||||||
{ params }
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const { id: serverId } = await params
|
const { id: serverId } = await params
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
@@ -117,19 +112,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
|
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_UPDATED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId,
|
|
||||||
resourceName: updatedServer.name,
|
|
||||||
description: `Updated workflow MCP server "${updatedServer.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ server: updatedServer })
|
return createMcpSuccessResponse({ server: updatedServer })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
|
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
|
||||||
@@ -146,11 +128,7 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
|||||||
* DELETE - Delete a workflow MCP server and all its tools
|
* DELETE - Delete a workflow MCP server and all its tools
|
||||||
*/
|
*/
|
||||||
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
export const DELETE = withMcpAuth<RouteParams>('admin')(
|
||||||
async (
|
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||||
request: NextRequest,
|
|
||||||
{ userId, userName, userEmail, workspaceId, requestId },
|
|
||||||
{ params }
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const { id: serverId } = await params
|
const { id: serverId } = await params
|
||||||
|
|
||||||
@@ -171,19 +149,6 @@ export const DELETE = withMcpAuth<RouteParams>('admin')(
|
|||||||
|
|
||||||
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_REMOVED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId,
|
|
||||||
resourceName: deletedServer.name,
|
|
||||||
description: `Unpublished workflow MCP server "${deletedServer.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
|
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
@@ -66,11 +65,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
|||||||
* PATCH - Update a tool's configuration
|
* PATCH - Update a tool's configuration
|
||||||
*/
|
*/
|
||||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||||
async (
|
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||||
request: NextRequest,
|
|
||||||
{ userId, userName, userEmail, workspaceId, requestId },
|
|
||||||
{ params }
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const { id: serverId, toolId } = await params
|
const { id: serverId, toolId } = await params
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
@@ -123,20 +118,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
|||||||
|
|
||||||
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_UPDATED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId,
|
|
||||||
resourceName: updatedTool.toolName,
|
|
||||||
description: `Updated tool "${updatedTool.toolName}" in MCP server`,
|
|
||||||
metadata: { toolId, toolName: updatedTool.toolName },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ tool: updatedTool })
|
return createMcpSuccessResponse({ tool: updatedTool })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error updating tool:`, error)
|
logger.error(`[${requestId}] Error updating tool:`, error)
|
||||||
@@ -153,11 +134,7 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
|||||||
* DELETE - Remove a tool from an MCP server
|
* DELETE - Remove a tool from an MCP server
|
||||||
*/
|
*/
|
||||||
export const DELETE = withMcpAuth<RouteParams>('write')(
|
export const DELETE = withMcpAuth<RouteParams>('write')(
|
||||||
async (
|
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||||
request: NextRequest,
|
|
||||||
{ userId, userName, userEmail, workspaceId, requestId },
|
|
||||||
{ params }
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const { id: serverId, toolId } = await params
|
const { id: serverId, toolId } = await params
|
||||||
|
|
||||||
@@ -188,20 +165,6 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
|
|||||||
|
|
||||||
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_UPDATED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId,
|
|
||||||
resourceName: deletedTool.toolName,
|
|
||||||
description: `Removed tool "${deletedTool.toolName}" from MCP server`,
|
|
||||||
metadata: { toolId, toolName: deletedTool.toolName },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
|
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error deleting tool:`, error)
|
logger.error(`[${requestId}] Error deleting tool:`, error)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
@@ -77,11 +76,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
|||||||
* POST - Add a workflow as a tool to an MCP server
|
* POST - Add a workflow as a tool to an MCP server
|
||||||
*/
|
*/
|
||||||
export const POST = withMcpAuth<RouteParams>('write')(
|
export const POST = withMcpAuth<RouteParams>('write')(
|
||||||
async (
|
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||||
request: NextRequest,
|
|
||||||
{ userId, userName, userEmail, workspaceId, requestId },
|
|
||||||
{ params }
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
const { id: serverId } = await params
|
const { id: serverId } = await params
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
@@ -202,20 +197,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
|
|||||||
|
|
||||||
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_UPDATED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId,
|
|
||||||
resourceName: toolName,
|
|
||||||
description: `Added tool "${toolName}" to MCP server`,
|
|
||||||
metadata: { toolId, toolName, workflowId: body.workflowId },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ tool }, 201)
|
return createMcpSuccessResponse({ tool }, 201)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error adding tool:`, error)
|
logger.error(`[${requestId}] Error adding tool:`, error)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { eq, inArray, sql } from 'drizzle-orm'
|
import { eq, inArray, sql } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
|
||||||
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
import { mcpPubSub } from '@/lib/mcp/pubsub'
|
||||||
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
|
||||||
@@ -86,7 +85,7 @@ export const GET = withMcpAuth('read')(
|
|||||||
* POST - Create a new workflow MCP server
|
* POST - Create a new workflow MCP server
|
||||||
*/
|
*/
|
||||||
export const POST = withMcpAuth('write')(
|
export const POST = withMcpAuth('write')(
|
||||||
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
|
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||||
try {
|
try {
|
||||||
const body = getParsedBody(request) || (await request.json())
|
const body = getParsedBody(request) || (await request.json())
|
||||||
|
|
||||||
@@ -189,19 +188,6 @@ export const POST = withMcpAuth('write')(
|
|||||||
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
|
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: userName,
|
|
||||||
actorEmail: userEmail,
|
|
||||||
action: AuditAction.MCP_SERVER_ADDED,
|
|
||||||
resourceType: AuditResourceType.MCP_SERVER,
|
|
||||||
resourceId: serverId,
|
|
||||||
resourceName: body.name.trim(),
|
|
||||||
description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createMcpSuccessResponse({ server, addedTools }, 201)
|
return createMcpSuccessResponse({ server, addedTools }, 201)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
|
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasAccessControlAccess } from '@/lib/billing'
|
import { hasAccessControlAccess } from '@/lib/billing'
|
||||||
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
|
||||||
@@ -553,26 +552,6 @@ export async function PUT(
|
|||||||
email: orgInvitation.email,
|
email: orgInvitation.email,
|
||||||
})
|
})
|
||||||
|
|
||||||
const auditActionMap = {
|
|
||||||
accepted: AuditAction.ORG_INVITATION_ACCEPTED,
|
|
||||||
rejected: AuditAction.ORG_INVITATION_REJECTED,
|
|
||||||
cancelled: AuditAction.ORG_INVITATION_CANCELLED,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: auditActionMap[status],
|
|
||||||
resourceType: AuditResourceType.ORGANIZATION,
|
|
||||||
resourceId: organizationId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: orgInvitation.email,
|
|
||||||
description: `Organization invitation ${status} for ${orgInvitation.email}`,
|
|
||||||
metadata: { invitationId, email: orgInvitation.email, status },
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Invitation ${status} successfully`,
|
message: `Invitation ${status} successfully`,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
renderBatchInvitationEmail,
|
renderBatchInvitationEmail,
|
||||||
renderInvitationEmail,
|
renderInvitationEmail,
|
||||||
} from '@/components/emails'
|
} from '@/components/emails'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
validateBulkInvitations,
|
validateBulkInvitations,
|
||||||
@@ -412,22 +411,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
workspaceInvitationCount: workspaceInvitationIds.length,
|
workspaceInvitationCount: workspaceInvitationIds.length,
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const inv of invitationsToCreate) {
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.ORG_INVITATION_CREATED,
|
|
||||||
resourceType: AuditResourceType.ORGANIZATION,
|
|
||||||
resourceId: organizationId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: organizationEntry[0]?.name,
|
|
||||||
description: `Invited ${inv.email} to organization as ${role}`,
|
|
||||||
metadata: { invitationId: inv.id, email: inv.email, role },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${invitationsToCreate.length} invitation(s) sent successfully`,
|
message: `${invitationsToCreate.length} invitation(s) sent successfully`,
|
||||||
@@ -549,20 +532,6 @@ export async function DELETE(
|
|||||||
email: result[0].email,
|
email: result[0].email,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.ORG_INVITATION_REVOKED,
|
|
||||||
resourceType: AuditResourceType.ORGANIZATION,
|
|
||||||
resourceId: organizationId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: result[0].email,
|
|
||||||
description: `Revoked organization invitation for ${result[0].email}`,
|
|
||||||
metadata: { invitationId, email: result[0].email },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Invitation cancelled successfully',
|
message: 'Invitation cancelled successfully',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||||
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
|
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
|
||||||
@@ -214,19 +213,6 @@ export async function PUT(
|
|||||||
updatedBy: session.user.id,
|
updatedBy: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
|
|
||||||
resourceType: AuditResourceType.ORGANIZATION,
|
|
||||||
resourceId: organizationId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
description: `Changed member role to ${role}`,
|
|
||||||
metadata: { targetUserId: memberId, newRole: role },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Member role updated successfully',
|
message: 'Member role updated successfully',
|
||||||
@@ -319,22 +305,6 @@ export async function DELETE(
|
|||||||
billingActions: result.billingActions,
|
billingActions: result.billingActions,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.ORG_MEMBER_REMOVED,
|
|
||||||
resourceType: AuditResourceType.ORGANIZATION,
|
|
||||||
resourceId: organizationId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
description:
|
|
||||||
session.user.id === targetUserId
|
|
||||||
? 'Left the organization'
|
|
||||||
: 'Removed a member from the organization',
|
|
||||||
metadata: { targetUserId, wasSelfRemoval: session.user.id === targetUserId },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||||
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
|
||||||
@@ -286,20 +285,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
// Don't fail the request if email fails
|
// Don't fail the request if email fails
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.ORG_INVITATION_CREATED,
|
|
||||||
resourceType: AuditResourceType.ORGANIZATION,
|
|
||||||
resourceId: organizationId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: normalizedEmail,
|
|
||||||
description: `Invited ${normalizedEmail} to organization as ${role}`,
|
|
||||||
metadata: { invitationId, email: normalizedEmail, role },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `Invitation sent to ${normalizedEmail}`,
|
message: `Invitation sent to ${normalizedEmail}`,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, ne } from 'drizzle-orm'
|
import { and, eq, ne } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
getOrganizationSeatAnalytics,
|
getOrganizationSeatAnalytics,
|
||||||
@@ -193,20 +192,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
changes: { name, slug, logo },
|
changes: { name, slug, logo },
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.ORGANIZATION_UPDATED,
|
|
||||||
resourceType: AuditResourceType.ORGANIZATION,
|
|
||||||
resourceId: organizationId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: updatedOrg[0].name,
|
|
||||||
description: `Updated organization settings`,
|
|
||||||
metadata: { changes: { name, slug, logo } },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Organization updated successfully',
|
message: 'Organization updated successfully',
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { member, organization } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq, or } from 'drizzle-orm'
|
import { and, eq, or } from 'drizzle-orm'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
|
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
|
||||||
|
|
||||||
@@ -116,19 +115,6 @@ export async function POST(request: Request) {
|
|||||||
organizationId,
|
organizationId,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: user.id,
|
|
||||||
action: AuditAction.ORGANIZATION_CREATED,
|
|
||||||
resourceType: AuditResourceType.ORGANIZATION,
|
|
||||||
resourceId: organizationId,
|
|
||||||
actorName: user.name ?? undefined,
|
|
||||||
actorEmail: user.email ?? undefined,
|
|
||||||
resourceName: organizationName ?? undefined,
|
|
||||||
description: `Created organization "${organizationName}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasAccessControlAccess } from '@/lib/billing'
|
import { hasAccessControlAccess } from '@/lib/billing'
|
||||||
|
|
||||||
@@ -14,7 +13,6 @@ async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
|||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select({
|
.select({
|
||||||
id: permissionGroup.id,
|
id: permissionGroup.id,
|
||||||
name: permissionGroup.name,
|
|
||||||
organizationId: permissionGroup.organizationId,
|
organizationId: permissionGroup.organizationId,
|
||||||
})
|
})
|
||||||
.from(permissionGroup)
|
.from(permissionGroup)
|
||||||
@@ -153,20 +151,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
assignedBy: session.user.id,
|
assignedBy: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
|
|
||||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: result.group.name,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
description: `Added a member to permission group "${result.group.name}"`,
|
|
||||||
metadata: { targetUserId: userId, permissionGroupId: id },
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ member: newMember }, { status: 201 })
|
return NextResponse.json({ member: newMember }, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
@@ -237,20 +221,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED,
|
|
||||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: result.group.name,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
description: `Removed a member from permission group "${result.group.name}"`,
|
|
||||||
metadata: { targetUserId: memberToRemove.userId, memberId, permissionGroupId: id },
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error removing member from permission group', error)
|
logger.error('Error removing member from permission group', error)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasAccessControlAccess } from '@/lib/billing'
|
import { hasAccessControlAccess } from '@/lib/billing'
|
||||||
import {
|
import {
|
||||||
@@ -182,19 +181,6 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
.where(eq(permissionGroup.id, id))
|
.where(eq(permissionGroup.id, id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.PERMISSION_GROUP_UPDATED,
|
|
||||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: updated.name,
|
|
||||||
description: `Updated permission group "${updated.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
permissionGroup: {
|
permissionGroup: {
|
||||||
...updated,
|
...updated,
|
||||||
@@ -243,19 +229,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
|
|||||||
|
|
||||||
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
|
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.PERMISSION_GROUP_DELETED,
|
|
||||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: result.group.name,
|
|
||||||
description: `Deleted permission group "${result.group.name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting permission group', error)
|
logger.error('Error deleting permission group', error)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, count, desc, eq } from 'drizzle-orm'
|
import { and, count, desc, eq } from 'drizzle-orm'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { hasAccessControlAccess } from '@/lib/billing'
|
import { hasAccessControlAccess } from '@/lib/billing'
|
||||||
import {
|
import {
|
||||||
@@ -199,19 +198,6 @@ export async function POST(req: Request) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.PERMISSION_GROUP_CREATED,
|
|
||||||
resourceType: AuditResourceType.PERMISSION_GROUP,
|
|
||||||
resourceId: newGroup.id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: name,
|
|
||||||
description: `Created permission group "${name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
|
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import { auditMock, databaseMock, loggerMock } from '@sim/testing'
|
import { databaseMock, loggerMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -37,8 +37,6 @@ vi.mock('@/lib/core/utils/request', () => ({
|
|||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
import { PUT } from './route'
|
import { PUT } from './route'
|
||||||
|
|
||||||
function createRequest(body: Record<string, unknown>): NextRequest {
|
function createRequest(body: Record<string, unknown>): NextRequest {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||||
@@ -107,19 +106,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`)
|
logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: authorization.workflow.workspaceId ?? null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.SCHEDULE_UPDATED,
|
|
||||||
resourceType: AuditResourceType.SCHEDULE,
|
|
||||||
resourceId: scheduleId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: authorization.workflow?.name,
|
|
||||||
description: `Reactivated schedule for workflow "${authorization.workflow?.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Schedule activated successfully',
|
message: 'Schedule activated successfully',
|
||||||
nextRunAt,
|
nextRunAt,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { getSession } from '@/lib/auth'
|
|
||||||
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
allowedIntegrations: getAllowedIntegrationsFromEnv(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { apiKey } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
|
|
||||||
@@ -35,27 +34,12 @@ export async function DELETE(
|
|||||||
const result = await db
|
const result = await db
|
||||||
.delete(apiKey)
|
.delete(apiKey)
|
||||||
.where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId)))
|
.where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId)))
|
||||||
.returning({ id: apiKey.id, name: apiKey.name })
|
.returning({ id: apiKey.id })
|
||||||
|
|
||||||
if (!result.length) {
|
if (!result.length) {
|
||||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedKey = result[0]
|
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: userId,
|
|
||||||
action: AuditAction.PERSONAL_API_KEY_REVOKED,
|
|
||||||
resourceType: AuditResourceType.API_KEY,
|
|
||||||
resourceId: keyId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: deletedKey.name,
|
|
||||||
description: `Revoked personal API key: ${deletedKey.name}`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to delete API key', { error })
|
logger.error('Failed to delete API key', { error })
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
const logger = createLogger('ApiKeysAPI')
|
const logger = createLogger('ApiKeysAPI')
|
||||||
@@ -111,19 +110,6 @@ export async function POST(request: NextRequest) {
|
|||||||
createdAt: apiKey.createdAt,
|
createdAt: apiKey.createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: userId,
|
|
||||||
action: AuditAction.PERSONAL_API_KEY_CREATED,
|
|
||||||
resourceType: AuditResourceType.API_KEY,
|
|
||||||
resourceId: newKey.id,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: name,
|
|
||||||
description: `Created personal API key: ${name}`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
key: {
|
key: {
|
||||||
...newKey,
|
...newKey,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { webhook, workflow } from '@sim/db/schema'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { validateInteger } from '@/lib/core/security/input-validation'
|
import { validateInteger } from '@/lib/core/security/input-validation'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
@@ -262,20 +261,6 @@ export async function DELETE(
|
|||||||
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
|
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: webhookData.workflow.workspaceId || null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.WEBHOOK_DELETED,
|
|
||||||
resourceType: AuditResourceType.WEBHOOK,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: foundWebhook.provider || 'generic',
|
|
||||||
description: 'Deleted webhook',
|
|
||||||
metadata: { workflowId: webhookData.workflow.id },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Error deleting webhook`, {
|
logger.error(`[${requestId}] Error deleting webhook`, {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
|
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -146,8 +145,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// Create or Update a webhook
|
// Create or Update a webhook
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const requestId = generateRequestId()
|
const requestId = generateRequestId()
|
||||||
const session = await getSession()
|
const userId = (await getSession())?.user?.id
|
||||||
const userId = session?.user?.id
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
|
logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
|
||||||
@@ -680,20 +678,6 @@ export async function POST(request: NextRequest) {
|
|||||||
} catch {
|
} catch {
|
||||||
// Telemetry should not fail the operation
|
// Telemetry should not fail the operation
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowRecord.workspaceId || null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: session?.user?.name,
|
|
||||||
actorEmail: session?.user?.email,
|
|
||||||
action: AuditAction.WEBHOOK_CREATED,
|
|
||||||
resourceType: AuditResourceType.WEBHOOK,
|
|
||||||
resourceId: savedWebhook.id,
|
|
||||||
resourceName: provider || 'generic',
|
|
||||||
description: `Created ${provider || 'generic'} webhook`,
|
|
||||||
metadata: { provider, workflowId },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = targetWebhookId ? 200 : 201
|
const status = targetWebhookId ? 200 : 201
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, desc, eq } from 'drizzle-orm'
|
import { and, desc, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||||
import {
|
import {
|
||||||
@@ -259,19 +258,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
// Sync MCP tools with the latest parameter schema
|
// Sync MCP tools with the latest parameter schema
|
||||||
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
|
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowData?.workspaceId || null,
|
|
||||||
actorId: actorUserId,
|
|
||||||
actorName: session?.user?.name,
|
|
||||||
actorEmail: session?.user?.email,
|
|
||||||
action: AuditAction.WORKFLOW_DEPLOYED,
|
|
||||||
resourceType: AuditResourceType.WORKFLOW,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: workflowData?.name,
|
|
||||||
description: `Deployed workflow "${workflowData.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
const responseApiKeyInfo = workflowData!.workspaceId
|
const responseApiKeyInfo = workflowData!.workspaceId
|
||||||
? 'Workspace API keys'
|
? 'Workspace API keys'
|
||||||
: 'Personal API keys'
|
: 'Personal API keys'
|
||||||
@@ -311,11 +297,11 @@ export async function DELETE(
|
|||||||
try {
|
try {
|
||||||
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
|
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
|
||||||
|
|
||||||
const {
|
const { error, workflow: workflowData } = await validateWorkflowPermissions(
|
||||||
error,
|
id,
|
||||||
session,
|
requestId,
|
||||||
workflow: workflowData,
|
'admin'
|
||||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
)
|
||||||
if (error) {
|
if (error) {
|
||||||
return createErrorResponse(error.message, error.status)
|
return createErrorResponse(error.message, error.status)
|
||||||
}
|
}
|
||||||
@@ -339,19 +325,6 @@ export async function DELETE(
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowData?.workspaceId || null,
|
|
||||||
actorId: session!.user.id,
|
|
||||||
actorName: session?.user?.name,
|
|
||||||
actorEmail: session?.user?.email,
|
|
||||||
action: AuditAction.WORKFLOW_UNDEPLOYED,
|
|
||||||
resourceType: AuditResourceType.WORKFLOW,
|
|
||||||
resourceId: id,
|
|
||||||
resourceName: workflowData?.name,
|
|
||||||
description: `Undeployed workflow "${workflowData.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
isDeployed: false,
|
isDeployed: false,
|
||||||
deployedAt: null,
|
deployedAt: null,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db'
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
|
||||||
@@ -23,11 +22,7 @@ export async function POST(
|
|||||||
const { id, version } = await params
|
const { id, version } = await params
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {
|
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||||
error,
|
|
||||||
session,
|
|
||||||
workflow: workflowRecord,
|
|
||||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return createErrorResponse(error.message, error.status)
|
return createErrorResponse(error.message, error.status)
|
||||||
}
|
}
|
||||||
@@ -112,19 +107,6 @@ export async function POST(
|
|||||||
logger.error('Error sending workflow reverted event to socket server', e)
|
logger.error('Error sending workflow reverted event to socket server', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowRecord?.workspaceId ?? null,
|
|
||||||
actorId: session!.user.id,
|
|
||||||
action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED,
|
|
||||||
resourceType: AuditResourceType.WORKFLOW,
|
|
||||||
resourceId: id,
|
|
||||||
actorName: session!.user.name ?? undefined,
|
|
||||||
actorEmail: session!.user.email ?? undefined,
|
|
||||||
resourceName: workflowRecord?.name ?? undefined,
|
|
||||||
description: `Reverted workflow to deployment version ${version}`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return createSuccessResponse({
|
return createSuccessResponse({
|
||||||
message: 'Reverted to deployment version',
|
message: 'Reverted to deployment version',
|
||||||
lastSaved: Date.now(),
|
lastSaved: Date.now(),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -62,20 +61,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
|
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workspaceId || null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.WORKFLOW_DUPLICATED,
|
|
||||||
resourceType: AuditResourceType.WORKFLOW,
|
|
||||||
resourceId: result.id,
|
|
||||||
resourceName: result.name,
|
|
||||||
description: `Duplicated workflow as "${result.name}"`,
|
|
||||||
metadata: { sourceWorkflowId },
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(result, { status: 201 })
|
return NextResponse.json(result, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { auditMock, loggerMock, setupGlobalFetchMock } from '@sim/testing'
|
import { loggerMock, setupGlobalFetchMock } from '@sim/testing'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -23,8 +23,6 @@ vi.mock('@/lib/auth', () => ({
|
|||||||
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
vi.mock('@sim/logger', () => loggerMock)
|
||||||
|
|
||||||
vi.mock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
vi.mock('@/lib/workflows/persistence/utils', () => ({
|
||||||
loadWorkflowFromNormalizedTables: (workflowId: string) =>
|
loadWorkflowFromNormalizedTables: (workflowId: string) =>
|
||||||
mockLoadWorkflowFromNormalizedTables(workflowId),
|
mockLoadWorkflowFromNormalizedTables(workflowId),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
@@ -337,19 +336,6 @@ export async function DELETE(
|
|||||||
// Don't fail the deletion if Socket.IO notification fails
|
// Don't fail the deletion if Socket.IO notification fails
|
||||||
}
|
}
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowData.workspaceId || null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.WORKFLOW_DELETED,
|
|
||||||
resourceType: AuditResourceType.WORKFLOW,
|
|
||||||
resourceId: workflowId,
|
|
||||||
resourceName: workflowData.name,
|
|
||||||
description: `Deleted workflow "${workflowData.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const elapsed = Date.now() - startTime
|
const elapsed = Date.now() - startTime
|
||||||
@@ -423,20 +409,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
updates: updateData,
|
updates: updateData,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowData.workspaceId || null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.WORKFLOW_UPDATED,
|
|
||||||
resourceType: AuditResourceType.WORKFLOW,
|
|
||||||
resourceId: workflowId,
|
|
||||||
resourceName: updatedWorkflow?.name ?? workflowData.name,
|
|
||||||
description: `Updated workflow "${updatedWorkflow?.name ?? workflowData.name}"`,
|
|
||||||
metadata: updates,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 })
|
return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const elapsed = Date.now() - startTime
|
const elapsed = Date.now() - startTime
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
auditMock,
|
|
||||||
databaseMock,
|
databaseMock,
|
||||||
defaultMockUser,
|
defaultMockUser,
|
||||||
mockAuth,
|
mockAuth,
|
||||||
@@ -28,8 +27,6 @@ describe('Workflow Variables API Route', () => {
|
|||||||
|
|
||||||
vi.doMock('@sim/db', () => databaseMock)
|
vi.doMock('@sim/db', () => databaseMock)
|
||||||
|
|
||||||
vi.doMock('@/lib/audit/log', () => auditMock)
|
|
||||||
|
|
||||||
vi.doMock('@/lib/workflows/utils', () => ({
|
vi.doMock('@/lib/workflows/utils', () => ({
|
||||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||||
@@ -80,19 +79,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
})
|
})
|
||||||
.where(eq(workflow.id, workflowId))
|
.where(eq(workflow.id, workflowId))
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: workflowData.workspaceId ?? null,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
|
|
||||||
resourceType: AuditResourceType.WORKFLOW,
|
|
||||||
resourceId: workflowId,
|
|
||||||
resourceName: workflowData.name ?? undefined,
|
|
||||||
description: `Updated workflow variables`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
if (validationError instanceof z.ZodError) {
|
if (validationError instanceof z.ZodError) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
|
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -189,20 +188,6 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`)
|
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: auth.userName,
|
|
||||||
actorEmail: auth.userEmail,
|
|
||||||
action: AuditAction.WORKFLOW_CREATED,
|
|
||||||
resourceType: AuditResourceType.WORKFLOW,
|
|
||||||
resourceId: workflowId,
|
|
||||||
resourceName: name,
|
|
||||||
description: `Created workflow "${name}"`,
|
|
||||||
metadata: { name },
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
id: workflowId,
|
id: workflowId,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, not } from 'drizzle-orm'
|
import { and, eq, not } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -87,19 +86,6 @@ export async function PUT(
|
|||||||
updatedAt: apiKey.updatedAt,
|
updatedAt: apiKey.updatedAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
action: AuditAction.API_KEY_UPDATED,
|
|
||||||
resourceType: AuditResourceType.API_KEY,
|
|
||||||
resourceId: keyId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: name,
|
|
||||||
description: `Updated workspace API key: ${name}`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`)
|
||||||
return NextResponse.json({ key: updatedKey })
|
return NextResponse.json({ key: updatedKey })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -137,27 +123,12 @@ export async function DELETE(
|
|||||||
.where(
|
.where(
|
||||||
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
|
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
|
||||||
)
|
)
|
||||||
.returning({ id: apiKey.id, name: apiKey.name })
|
.returning({ id: apiKey.id })
|
||||||
|
|
||||||
if (deletedRows.length === 0) {
|
if (deletedRows.length === 0) {
|
||||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedKey = deletedRows[0]
|
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
action: AuditAction.API_KEY_REVOKED,
|
|
||||||
resourceType: AuditResourceType.API_KEY,
|
|
||||||
resourceId: keyId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: deletedKey.name,
|
|
||||||
description: `Revoked workspace API key: ${deletedKey.name}`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`)
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { nanoid } from 'nanoid'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -160,20 +159,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: session?.user?.name,
|
|
||||||
actorEmail: session?.user?.email,
|
|
||||||
action: AuditAction.API_KEY_CREATED,
|
|
||||||
resourceType: AuditResourceType.API_KEY,
|
|
||||||
resourceId: newKey.id,
|
|
||||||
resourceName: name,
|
|
||||||
description: `Created API key "${name}"`,
|
|
||||||
metadata: { keyName: name },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
key: {
|
key: {
|
||||||
...newKey,
|
...newKey,
|
||||||
@@ -237,19 +222,6 @@ export async function DELETE(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
|
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: session?.user?.name,
|
|
||||||
actorEmail: session?.user?.email,
|
|
||||||
action: AuditAction.API_KEY_REVOKED,
|
|
||||||
resourceType: AuditResourceType.API_KEY,
|
|
||||||
description: `Revoked ${deletedCount} API key(s)`,
|
|
||||||
metadata: { keyIds: keys, deletedCount },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true, deletedCount })
|
return NextResponse.json({ success: true, deletedCount })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { and, eq } from 'drizzle-orm'
|
|||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -186,20 +185,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: session?.user?.name,
|
|
||||||
actorEmail: session?.user?.email,
|
|
||||||
action: AuditAction.BYOK_KEY_CREATED,
|
|
||||||
resourceType: AuditResourceType.BYOK_KEY,
|
|
||||||
resourceId: newKey.id,
|
|
||||||
resourceName: providerId,
|
|
||||||
description: `Added BYOK key for ${providerId}`,
|
|
||||||
metadata: { providerId },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
key: {
|
key: {
|
||||||
@@ -257,20 +242,6 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`)
|
logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: session?.user?.name,
|
|
||||||
actorEmail: session?.user?.email,
|
|
||||||
action: AuditAction.BYOK_KEY_DELETED,
|
|
||||||
resourceType: AuditResourceType.BYOK_KEY,
|
|
||||||
resourceId: providerId,
|
|
||||||
resourceName: providerId,
|
|
||||||
description: `Removed BYOK key for ${providerId}`,
|
|
||||||
metadata: { providerId },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(`[${requestId}] BYOK key DELETE error`, error)
|
logger.error(`[${requestId}] BYOK key DELETE error`, error)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { duplicateWorkspace } from '@/lib/workspaces/duplicate'
|
import { duplicateWorkspace } from '@/lib/workspaces/duplicate'
|
||||||
@@ -46,19 +45,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
|||||||
`[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms`
|
`[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms`
|
||||||
)
|
)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: sourceWorkspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.WORKSPACE_DUPLICATED,
|
|
||||||
resourceType: AuditResourceType.WORKSPACE,
|
|
||||||
resourceId: result.id,
|
|
||||||
resourceName: name,
|
|
||||||
description: `Duplicated workspace to "${name}"`,
|
|
||||||
request: req,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(result, { status: 201 })
|
return NextResponse.json(result, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
@@ -157,20 +156,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
|||||||
set: { variables: merged, updatedAt: new Date() },
|
set: { variables: merged, updatedAt: new Date() },
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: userId,
|
|
||||||
actorName: session?.user?.name,
|
|
||||||
actorEmail: session?.user?.email,
|
|
||||||
action: AuditAction.ENVIRONMENT_UPDATED,
|
|
||||||
resourceType: AuditResourceType.ENVIRONMENT,
|
|
||||||
resourceId: workspaceId,
|
|
||||||
resourceName: 'Environment Variables',
|
|
||||||
description: `Updated environment variables`,
|
|
||||||
metadata: { keysUpdated: Object.keys(variables) },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
logger.error(`[${requestId}] Workspace env PUT error`, error)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
import { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||||
@@ -40,18 +39,6 @@ export async function DELETE(
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Deleted workspace file: ${fileId}`)
|
logger.info(`[${requestId}] Deleted workspace file: ${fileId}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.FILE_DELETED,
|
|
||||||
resourceType: AuditResourceType.FILE,
|
|
||||||
resourceId: fileId,
|
|
||||||
description: `Deleted file "${fileId}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
import { generateRequestId } from '@/lib/core/utils/request'
|
||||||
import { listWorkspaceFiles, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
import { listWorkspaceFiles, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
|
||||||
@@ -105,19 +104,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
|
|
||||||
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
|
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.FILE_UPLOADED,
|
|
||||||
resourceType: AuditResourceType.FILE,
|
|
||||||
resourceId: userFile.id,
|
|
||||||
resourceName: file.name,
|
|
||||||
description: `Uploaded file "${file.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
file: userFile,
|
file: userFile,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -252,19 +251,6 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
|||||||
subscriptionId: subscription.id,
|
subscriptionId: subscription.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.NOTIFICATION_UPDATED,
|
|
||||||
resourceType: AuditResourceType.NOTIFICATION,
|
|
||||||
resourceId: notificationId,
|
|
||||||
resourceName: subscription.notificationType,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
description: `Updated ${subscription.notificationType} notification subscription`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
@@ -314,35 +300,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
eq(workspaceNotificationSubscription.workspaceId, workspaceId)
|
eq(workspaceNotificationSubscription.workspaceId, workspaceId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.returning({
|
.returning({ id: workspaceNotificationSubscription.id })
|
||||||
id: workspaceNotificationSubscription.id,
|
|
||||||
notificationType: workspaceNotificationSubscription.notificationType,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (deleted.length === 0) {
|
if (deleted.length === 0) {
|
||||||
return NextResponse.json({ error: 'Notification not found' }, { status: 404 })
|
return NextResponse.json({ error: 'Notification not found' }, { status: 404 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const deletedSubscription = deleted[0]
|
|
||||||
|
|
||||||
logger.info('Deleted notification subscription', {
|
logger.info('Deleted notification subscription', {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
subscriptionId: notificationId,
|
subscriptionId: notificationId,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.NOTIFICATION_DELETED,
|
|
||||||
resourceType: AuditResourceType.NOTIFICATION,
|
|
||||||
resourceId: notificationId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
resourceName: deletedSubscription.notificationType,
|
|
||||||
description: `Deleted ${deletedSubscription.notificationType} notification subscription`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting notification', { error })
|
logger.error('Error deleting notification', { error })
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { and, eq, inArray } from 'drizzle-orm'
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { encryptSecret } from '@/lib/core/security/encryption'
|
import { encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||||
@@ -257,19 +256,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
type: data.notificationType,
|
type: data.notificationType,
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.NOTIFICATION_CREATED,
|
|
||||||
resourceType: AuditResourceType.NOTIFICATION,
|
|
||||||
resourceId: subscription.id,
|
|
||||||
resourceName: data.notificationType,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
description: `Created ${data.notificationType} notification subscription`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq } from 'drizzle-orm'
|
import { and, eq } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
getUsersWithPermissions,
|
getUsersWithPermissions,
|
||||||
@@ -157,21 +156,6 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
|
|
||||||
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
const updatedUsers = await getUsersWithPermissions(workspaceId)
|
||||||
|
|
||||||
for (const update of body.updates) {
|
|
||||||
recordAudit({
|
|
||||||
workspaceId,
|
|
||||||
actorId: session.user.id,
|
|
||||||
action: AuditAction.MEMBER_ROLE_CHANGED,
|
|
||||||
resourceType: AuditResourceType.WORKSPACE,
|
|
||||||
resourceId: workspaceId,
|
|
||||||
actorName: session.user.name ?? undefined,
|
|
||||||
actorEmail: session.user.email ?? undefined,
|
|
||||||
description: `Changed workspace permissions to ${update.permissions}`,
|
|
||||||
metadata: { targetUserId: update.userId, newPermissions: update.permissions },
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: 'Permissions updated successfully',
|
message: 'Permissions updated successfully',
|
||||||
users: updatedUsers,
|
users: updatedUsers,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createLogger } from '@sim/logger'
|
|||||||
import { and, eq, inArray } from 'drizzle-orm'
|
import { and, eq, inArray } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
const logger = createLogger('WorkspaceByIdAPI')
|
const logger = createLogger('WorkspaceByIdAPI')
|
||||||
@@ -229,13 +228,6 @@ export async function DELETE(
|
|||||||
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
|
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch workspace name before deletion for audit logging
|
|
||||||
const [workspaceRecord] = await db
|
|
||||||
.select({ name: workspace.name })
|
|
||||||
.from(workspace)
|
|
||||||
.where(eq(workspace.id, workspaceId))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
// Delete workspace and all related data in a transaction
|
// Delete workspace and all related data in a transaction
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
// Get all workflows in this workspace before deletion
|
// Get all workflows in this workspace before deletion
|
||||||
@@ -289,19 +281,6 @@ export async function DELETE(
|
|||||||
logger.info(`Successfully deleted workspace ${workspaceId} and all related data`)
|
logger.info(`Successfully deleted workspace ${workspaceId} and all related data`)
|
||||||
})
|
})
|
||||||
|
|
||||||
recordAudit({
|
|
||||||
workspaceId: null,
|
|
||||||
actorId: session.user.id,
|
|
||||||
actorName: session.user.name,
|
|
||||||
actorEmail: session.user.email,
|
|
||||||
action: AuditAction.WORKSPACE_DELETED,
|
|
||||||
resourceType: AuditResourceType.WORKSPACE,
|
|
||||||
resourceId: workspaceId,
|
|
||||||
resourceName: workspaceRecord?.name,
|
|
||||||
description: `Deleted workspace "${workspaceRecord?.name}"`,
|
|
||||||
request,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error deleting workspace ${workspaceId}:`, error)
|
logger.error(`Error deleting workspace ${workspaceId}:`, error)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user