improvement(templates): make it top-level route and change management/editing process (#1834)

* fix(billing): should allow restoring subscription (#1728)

* fix(already-cancelled-sub): UI should allow restoring subscription

* restore functionality fixed

* fix

* make templates root level url and make it part of deployment system

* separate updating template and deployment versions

* add tags

* add credentials extraction logic + use should import with workflow variables

* fix credential extraction

* add trigger mode indicator

* add starred tracking

* last updated field

* progress on creator profiles

* revert creator profile context type

* progress fix image uploads

* render templates details with creator details

* fix collab rules for workflow edit button

* creator profile perm check improvements

* restore accidental changes

* fix accessibility issues for non logged in users

* remove unused code

* fix type errors

---------

Co-authored-by: Waleed <walif6@gmail.com>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
This commit is contained in:
Vikhyath Mondreti
2025-11-07 17:57:53 -08:00
committed by GitHub
parent 6cdee5351c
commit a73e2aaa8b
54 changed files with 13451 additions and 878 deletions

View File

@@ -0,0 +1,180 @@
import { db } from '@sim/db'
import { member, templateCreators } from '@sim/db/schema'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
const logger = createLogger('CreatorProfileByIdAPI')
const CreatorProfileDetailsSchema = z.object({
about: z.string().max(2000, 'Max 2000 characters').optional(),
xUrl: z.string().url().optional().or(z.literal('')),
linkedinUrl: z.string().url().optional().or(z.literal('')),
websiteUrl: z.string().url().optional().or(z.literal('')),
contactEmail: z.string().email().optional().or(z.literal('')),
})
const UpdateCreatorProfileSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Max 100 characters').optional(),
profileImageUrl: z.string().optional().or(z.literal('')),
details: CreatorProfileDetailsSchema.optional(),
})
// Helper to check if user has permission to manage profile
async function hasPermission(userId: string, profile: any): Promise<boolean> {
if (profile.referenceType === 'user') {
return profile.referenceId === userId
}
if (profile.referenceType === 'organization') {
const membership = await db
.select()
.from(member)
.where(
and(
eq(member.userId, userId),
eq(member.organizationId, profile.referenceId),
or(eq(member.role, 'owner'), eq(member.role, 'admin'))
)
)
.limit(1)
return membership.length > 0
}
return false
}
// GET /api/creator-profiles/[id] - Get a specific creator profile
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const profile = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, id))
.limit(1)
if (profile.length === 0) {
logger.warn(`[${requestId}] Profile not found: ${id}`)
return NextResponse.json({ error: 'Profile not found' }, { status: 404 })
}
logger.info(`[${requestId}] Retrieved creator profile: ${id}`)
return NextResponse.json({ data: profile[0] })
} catch (error: any) {
logger.error(`[${requestId}] Error fetching creator profile: ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// PUT /api/creator-profiles/[id] - Update a creator profile
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized update attempt for profile: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = UpdateCreatorProfileSchema.parse(body)
// Check if profile exists
const existing = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, id))
.limit(1)
if (existing.length === 0) {
logger.warn(`[${requestId}] Profile not found for update: ${id}`)
return NextResponse.json({ error: 'Profile not found' }, { status: 404 })
}
// Check permissions
const canEdit = await hasPermission(session.user.id, existing[0])
if (!canEdit) {
logger.warn(`[${requestId}] User denied permission to update profile: ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
const updateData: any = {
updatedAt: new Date(),
}
if (data.name !== undefined) updateData.name = data.name
if (data.profileImageUrl !== undefined) updateData.profileImageUrl = data.profileImageUrl
if (data.details !== undefined) updateData.details = data.details
const updated = await db
.update(templateCreators)
.set(updateData)
.where(eq(templateCreators.id, id))
.returning()
logger.info(`[${requestId}] Successfully updated creator profile: ${id}`)
return NextResponse.json({ data: updated[0] })
} catch (error: any) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid update data for profile: ${id}`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid update data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error updating creator profile: ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// DELETE /api/creator-profiles/[id] - Delete a creator profile
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized delete attempt for profile: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if profile exists
const existing = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, id))
.limit(1)
if (existing.length === 0) {
logger.warn(`[${requestId}] Profile not found for delete: ${id}`)
return NextResponse.json({ error: 'Profile not found' }, { status: 404 })
}
// Check permissions
const canDelete = await hasPermission(session.user.id, existing[0])
if (!canDelete) {
logger.warn(`[${requestId}] User denied permission to delete profile: ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
await db.delete(templateCreators).where(eq(templateCreators.id, id))
logger.info(`[${requestId}] Successfully deleted creator profile: ${id}`)
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting creator profile: ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,194 @@
import { db } from '@sim/db'
import { member, templateCreators } from '@sim/db/schema'
import { and, eq, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import type { CreatorProfileDetails } from '@/types/creator-profile'
const logger = createLogger('CreatorProfilesAPI')
const CreatorProfileDetailsSchema = z.object({
about: z.string().max(2000, 'Max 2000 characters').optional(),
xUrl: z.string().url().optional().or(z.literal('')),
linkedinUrl: z.string().url().optional().or(z.literal('')),
websiteUrl: z.string().url().optional().or(z.literal('')),
contactEmail: z.string().email().optional().or(z.literal('')),
})
const CreateCreatorProfileSchema = z.object({
referenceType: z.enum(['user', 'organization']),
referenceId: z.string().min(1, 'Reference ID is required'),
name: z.string().min(1, 'Name is required').max(100, 'Max 100 characters'),
profileImageUrl: z.string().min(1, 'Profile image is required'),
details: CreatorProfileDetailsSchema.optional(),
})
// GET /api/creator-profiles - Get creator profiles for current user
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized access attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get user's organizations where they're admin or owner
const userOrgs = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(
and(
eq(member.userId, session.user.id),
or(eq(member.role, 'owner'), eq(member.role, 'admin'))
)
)
const orgIds = userOrgs.map((m) => m.organizationId)
// Get creator profiles for user and their organizations
const profiles = await db
.select()
.from(templateCreators)
.where(
or(
and(
eq(templateCreators.referenceType, 'user'),
eq(templateCreators.referenceId, session.user.id)
),
...orgIds.map((orgId) =>
and(
eq(templateCreators.referenceType, 'organization'),
eq(templateCreators.referenceId, orgId)
)
)
)
)
logger.info(`[${requestId}] Retrieved ${profiles.length} creator profiles`)
return NextResponse.json({ profiles })
} catch (error: any) {
logger.error(`[${requestId}] Error fetching creator profiles`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// POST /api/creator-profiles - Create a new creator profile
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized creation attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const data = CreateCreatorProfileSchema.parse(body)
logger.debug(`[${requestId}] Creating creator profile:`, {
referenceType: data.referenceType,
referenceId: data.referenceId,
})
// Validate permissions
if (data.referenceType === 'user') {
if (data.referenceId !== session.user.id) {
logger.warn(`[${requestId}] User tried to create profile for another user`)
return NextResponse.json(
{ error: 'Cannot create profile for another user' },
{ status: 403 }
)
}
} else if (data.referenceType === 'organization') {
// Check if user is admin/owner of the organization
const membership = await db
.select()
.from(member)
.where(
and(
eq(member.userId, session.user.id),
eq(member.organizationId, data.referenceId),
or(eq(member.role, 'owner'), eq(member.role, 'admin'))
)
)
.limit(1)
if (membership.length === 0) {
logger.warn(`[${requestId}] User not authorized for organization: ${data.referenceId}`)
return NextResponse.json(
{ error: 'You must be an admin or owner to create an organization profile' },
{ status: 403 }
)
}
}
// Check if profile already exists
const existing = await db
.select()
.from(templateCreators)
.where(
and(
eq(templateCreators.referenceType, data.referenceType),
eq(templateCreators.referenceId, data.referenceId)
)
)
.limit(1)
if (existing.length > 0) {
logger.warn(
`[${requestId}] Profile already exists for ${data.referenceType}:${data.referenceId}`
)
return NextResponse.json({ error: 'Creator profile already exists' }, { status: 409 })
}
// Create the profile
const profileId = uuidv4()
const now = new Date()
const details: CreatorProfileDetails = {}
if (data.details?.about) details.about = data.details.about
if (data.details?.xUrl) details.xUrl = data.details.xUrl
if (data.details?.linkedinUrl) details.linkedinUrl = data.details.linkedinUrl
if (data.details?.websiteUrl) details.websiteUrl = data.details.websiteUrl
if (data.details?.contactEmail) details.contactEmail = data.details.contactEmail
const newProfile = {
id: profileId,
referenceType: data.referenceType,
referenceId: data.referenceId,
name: data.name,
profileImageUrl: data.profileImageUrl || null,
details: Object.keys(details).length > 0 ? details : null,
createdBy: session.user.id,
createdAt: now,
updatedAt: now,
}
await db.insert(templateCreators).values(newProfile)
logger.info(`[${requestId}] Successfully created creator profile: ${profileId}`)
return NextResponse.json({ data: newProfile }, { status: 201 })
} catch (error: any) {
if (error instanceof z.ZodError) {
logger.warn(`[${requestId}] Invalid profile data`, { errors: error.errors })
return NextResponse.json(
{ error: 'Invalid profile data', details: error.errors },
{ status: 400 }
)
}
logger.error(`[${requestId}] Error creating creator profile`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -116,6 +116,12 @@ export async function verifyFileAccess(
// Infer context from key if not explicitly provided
const inferredContext = context || inferContextFromKey(cloudKey)
// 0. Profile pictures: Public access (anyone can view creator profile pictures)
if (inferredContext === 'profile-pictures') {
logger.info('Profile picture access allowed (public)', { cloudKey })
return true
}
// 1. Workspace files: Check database first (most reliable for both local and cloud)
if (inferredContext === 'workspace') {
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal)

View File

@@ -31,6 +31,25 @@ export async function GET(
logger.info('File serve request:', { path })
const fullPath = path.join('/')
const isS3Path = path[0] === 's3'
const isBlobPath = path[0] === 'blob'
const isCloudPath = isS3Path || isBlobPath
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
const contextParam = request.nextUrl.searchParams.get('context')
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)
if (context === 'profile-pictures') {
logger.info('Serving public profile picture:', { cloudKey })
if (isUsingCloudStorage() || isCloudPath) {
return await handleCloudProxyPublic(cloudKey, context, legacyBucketType)
}
return await handleLocalFilePublic(fullPath)
}
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
@@ -42,14 +61,6 @@ export async function GET(
}
const userId = authResult.userId
const fullPath = path.join('/')
const isS3Path = path[0] === 's3'
const isBlobPath = path[0] === 'blob'
const isCloudPath = isS3Path || isBlobPath
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
const contextParam = request.nextUrl.searchParams.get('context')
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
if (isUsingCloudStorage() || isCloudPath) {
return await handleCloudProxy(cloudKey, userId, contextParam, legacyBucketType)
@@ -174,3 +185,64 @@ async function handleCloudProxy(
throw error
}
}
async function handleCloudProxyPublic(
cloudKey: string,
context: StorageContext,
legacyBucketType?: string | null
): Promise<NextResponse> {
try {
let fileBuffer: Buffer
if (context === 'copilot') {
fileBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
} else {
fileBuffer = await downloadFile({
key: cloudKey,
context,
})
}
const originalFilename = cloudKey.split('/').pop() || 'download'
const contentType = getContentType(originalFilename)
logger.info('Public cloud file served', {
key: cloudKey,
size: fileBuffer.length,
context,
})
return createFileResponse({
buffer: fileBuffer,
contentType,
filename: originalFilename,
})
} catch (error) {
logger.error('Error serving public cloud file:', error)
throw error
}
}
async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
try {
const filePath = findLocalFile(filename)
if (!filePath) {
throw new FileNotFoundError(`File not found: ${filename}`)
}
const fileBuffer = await readFile(filePath)
const contentType = getContentType(filename)
logger.info('Public local file served', { filename, size: fileBuffer.length })
return createFileResponse({
buffer: fileBuffer,
contentType,
filename,
})
} catch (error) {
logger.error('Error reading public local file:', error)
throw error
}
}

View File

@@ -0,0 +1,106 @@
import { db } from '@sim/db'
import { templates, user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
const logger = createLogger('TemplateApprovalAPI')
export const revalidate = 0
// POST /api/templates/[id]/approve - Approve a template (super users only)
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template approval attempt for ID: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to approve template: ${id}`)
return NextResponse.json({ error: 'Only super users can approve templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for approval: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to approved
await db
.update(templates)
.set({ status: 'approved', updatedAt: new Date() })
.where(eq(templates.id, id))
logger.info(`[${requestId}] Template approved: ${id} by super user: ${session.user.id}`)
return NextResponse.json({
message: 'Template approved successfully',
templateId: id,
})
} catch (error) {
logger.error(`[${requestId}] Error approving template ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// POST /api/templates/[id]/reject - Reject a template (super users only)
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template rejection attempt for ID: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to rejected
await db
.update(templates)
.set({ status: 'rejected', updatedAt: new Date() })
.where(eq(templates.id, id))
logger.info(`[${requestId}] Template rejected: ${id} by super user: ${session.user.id}`)
return NextResponse.json({
message: 'Template rejected successfully',
templateId: id,
})
} catch (error) {
logger.error(`[${requestId}] Error rejecting template ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
import { db } from '@sim/db'
import { templates, user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
const logger = createLogger('TemplateRejectionAPI')
export const revalidate = 0
// POST /api/templates/[id]/reject - Reject a template (super users only)
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
const { id } = await params
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template rejection attempt for ID: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
if (!currentUser[0]?.isSuperUser) {
logger.warn(`[${requestId}] Non-super user attempted to reject template: ${id}`)
return NextResponse.json({ error: 'Only super users can reject templates' }, { status: 403 })
}
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
if (existingTemplate.length === 0) {
logger.warn(`[${requestId}] Template not found for rejection: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Update template status to rejected
await db
.update(templates)
.set({ status: 'rejected', updatedAt: new Date() })
.where(eq(templates.id, id))
logger.info(`[${requestId}] Template rejected: ${id} by super user: ${session.user.id}`)
return NextResponse.json({
message: 'Template rejected successfully',
templateId: id,
})
} catch (error) {
logger.error(`[${requestId}] Error rejecting template ${id}`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -1,12 +1,15 @@
import { db } from '@sim/db'
import { templates, workflow } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { member, templateCreators, templates, workflow } from '@sim/db/schema'
import { and, eq, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils'
import {
extractRequiredCredentials,
sanitizeCredentials,
} from '@/lib/workflows/credential-extractor'
const logger = createLogger('TemplateByIdAPI')
@@ -19,45 +22,76 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized template access attempt for ID: ${id}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
logger.debug(`[${requestId}] Fetching template: ${id}`)
// Fetch the template by ID
const result = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
// Fetch the template by ID with creator info
const result = await db
.select({
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
if (result.length === 0) {
logger.warn(`[${requestId}] Template not found: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
const template = result[0]
const { template, creator } = result[0]
const templateWithCreator = {
...template,
creator: creator || undefined,
}
// Increment the view count
try {
await db
.update(templates)
.set({
views: sql`${templates.views} + 1`,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
// Only show approved templates to non-authenticated users
if (!session?.user?.id && template.status !== 'approved') {
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
} catch (viewError) {
// Log the error but don't fail the request
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
// Check if user has starred (only if authenticated)
let isStarred = false
if (session?.user?.id) {
const { templateStars } = await import('@sim/db/schema')
const starResult = await db
.select()
.from(templateStars)
.where(
sql`${templateStars.templateId} = ${id} AND ${templateStars.userId} = ${session.user.id}`
)
.limit(1)
isStarred = starResult.length > 0
}
const shouldIncrementView = template.status === 'approved'
if (shouldIncrementView) {
try {
await db
.update(templates)
.set({
views: sql`${templates.views} + 1`,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
} catch (viewError) {
// Log the error but don't fail the request
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
}
}
logger.info(`[${requestId}] Successfully retrieved template: ${id}`)
return NextResponse.json({
data: {
...template,
views: template.views + 1, // Return the incremented view count
...templateWithCreator,
views: template.views + (shouldIncrementView ? 1 : 0),
isStarred,
},
})
} catch (error: any) {
@@ -67,13 +101,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
const updateTemplateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().min(1).max(500),
author: z.string().min(1).max(100),
category: z.string().min(1),
icon: z.string().min(1),
color: z.string().regex(/^#[0-9A-F]{6}$/i),
state: z.any().optional(), // Workflow state
name: z.string().min(1).max(100).optional(),
details: z
.object({
tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(),
about: z.string().optional(), // Markdown long description
})
.optional(),
creatorId: z.string().optional(), // Creator profile ID
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional(),
updateState: z.boolean().optional(), // Explicitly request state update from current workflow
})
// PUT /api/templates/[id] - Update a template
@@ -99,7 +136,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
)
}
const { name, description, author, category, icon, color, state } = validationResult.data
const { name, details, creatorId, tags, updateState } = validationResult.data
// Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
@@ -109,41 +146,63 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
}
// Permission: template owner OR admin of the workflow's workspace (if any)
let canUpdate = existingTemplate[0].userId === session.user.id
// No permission check needed - template updates only happen from within the workspace
// where the user is already editing the connected workflow
if (!canUpdate && existingTemplate[0].workflowId) {
const wfRows = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, existingTemplate[0].workflowId))
.limit(1)
// Prepare update data - only include fields that were provided
const updateData: any = {
updatedAt: new Date(),
}
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
if (workspaceId) {
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
if (hasAdmin) canUpdate = true
// Only update fields that were provided
if (name !== undefined) updateData.name = name
if (details !== undefined) updateData.details = details
if (tags !== undefined) updateData.tags = tags
if (creatorId !== undefined) updateData.creatorId = creatorId
// Only update the state if explicitly requested and the template has a connected workflow
if (updateState && existingTemplate[0].workflowId) {
// Load the current workflow state from normalized tables
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/db-helpers')
const normalizedData = await loadWorkflowFromNormalizedTables(existingTemplate[0].workflowId)
if (normalizedData) {
// Also fetch workflow variables
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, existingTemplate[0].workflowId))
.limit(1)
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || undefined,
lastSaved: Date.now(),
}
// Extract credential requirements from the new state
const requiredCredentials = extractRequiredCredentials(currentState)
// Sanitize the state before storing
const sanitizedState = sanitizeCredentials(currentState)
updateData.state = sanitizedState
updateData.requiredCredentials = requiredCredentials
logger.info(
`[${requestId}] Updating template state and credentials from current workflow: ${existingTemplate[0].workflowId}`
)
} else {
logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`)
}
}
if (!canUpdate) {
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
// Update the template
const updatedTemplate = await db
.update(templates)
.set({
name,
description,
author,
category,
icon,
color,
...(state && { state }),
updatedAt: new Date(),
})
.set(updateData)
.where(eq(templates.id, id))
.returning()
@@ -183,27 +242,41 @@ export async function DELETE(
const template = existing[0]
// Permission: owner or admin of the workflow's workspace (if any)
let canDelete = template.userId === session.user.id
if (!canDelete && template.workflowId) {
// Look up workflow to get workspaceId
const wfRows = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, template.workflowId))
// Permission: Only admin/owner of creator profile can delete
if (template.creatorId) {
const creatorProfile = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, template.creatorId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
if (workspaceId) {
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
if (hasAdmin) canDelete = true
}
}
if (creatorProfile.length > 0) {
const creator = creatorProfile[0]
let hasPermission = false
if (!canDelete) {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
if (creator.referenceType === 'user') {
hasPermission = creator.referenceId === session.user.id
} else if (creator.referenceType === 'organization') {
// For delete, require admin/owner role
const membership = await db
.select()
.from(member)
.where(
and(
eq(member.userId, session.user.id),
eq(member.organizationId, creator.referenceId),
or(eq(member.role, 'admin'), eq(member.role, 'owner'))
)
)
.limit(1)
hasPermission = membership.length > 0
}
if (!hasPermission) {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
}
}
await db.delete(templates).where(eq(templates.id, id))

View File

@@ -1,17 +1,25 @@
import { db } from '@sim/db'
import { templates, workflow, workflowBlocks, workflowEdges } from '@sim/db/schema'
import { templates, workflow, workflowDeploymentVersion } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { generateRequestId } from '@/lib/utils'
import { regenerateWorkflowStateIds } from '@/lib/workflows/db-helpers'
const logger = createLogger('TemplateUseAPI')
export const dynamic = 'force-dynamic'
export const revalidate = 0
// Type for template details
interface TemplateDetails {
tagline?: string
about?: string
}
// POST /api/templates/[id]/use - Use a template (increment views and create workflow)
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId()
@@ -24,9 +32,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get workspace ID from request body
// Get workspace ID and connectToTemplate flag from request body
const body = await request.json()
const { workspaceId } = body
const { workspaceId, connectToTemplate = false } = body
if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId in request body`)
@@ -34,17 +42,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
logger.debug(
`[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}`
`[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}, connect: ${connectToTemplate}`
)
// Get the template with its data
// Get the template
const template = await db
.select({
id: templates.id,
name: templates.name,
description: templates.description,
details: templates.details,
state: templates.state,
color: templates.color,
workflowId: templates.workflowId,
})
.from(templates)
.where(eq(templates.id, id))
@@ -59,119 +67,106 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Create a new workflow ID
const newWorkflowId = uuidv4()
const now = new Date()
// Use a transaction to ensure consistency
// Extract variables from the template state and remap to the new workflow
const templateVariables = (templateData.state as any)?.variables as
| Record<string, any>
| undefined
const remappedVariables: Record<string, any> = (() => {
if (!templateVariables || typeof templateVariables !== 'object') return {}
const mapped: Record<string, any> = {}
for (const [, variable] of Object.entries(templateVariables)) {
const newVarId = uuidv4()
mapped[newVarId] = { ...variable, id: newVarId, workflowId: newWorkflowId }
}
return mapped
})()
// Step 1: Create the workflow record (like imports do)
await db.insert(workflow).values({
id: newWorkflowId,
workspaceId: workspaceId,
name:
connectToTemplate && !templateData.workflowId
? templateData.name
: `${templateData.name} (copy)`,
description: (templateData.details as TemplateDetails | null)?.tagline || null,
userId: session.user.id,
variables: remappedVariables, // Remap variable IDs and workflowId for the new workflow
createdAt: now,
updatedAt: now,
lastSynced: now,
isDeployed: connectToTemplate && !templateData.workflowId,
deployedAt: connectToTemplate && !templateData.workflowId ? now : null,
})
// Step 2: Regenerate IDs when creating a copy (not when connecting/editing template)
// When connecting to template (edit mode), keep original IDs
// When using template (copy mode), regenerate all IDs to avoid conflicts
const workflowState = connectToTemplate
? templateData.state
: regenerateWorkflowStateIds(templateData.state)
// Step 3: Save the workflow state using the existing state endpoint (like imports do)
// Ensure variables in state are remapped for the new workflow as well
const workflowStateWithVariables = { ...workflowState, variables: remappedVariables }
const stateResponse = await fetch(`${getBaseUrl()}/api/workflows/${newWorkflowId}/state`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
// Forward the session cookie for authentication
cookie: request.headers.get('cookie') || '',
},
body: JSON.stringify(workflowStateWithVariables),
})
if (!stateResponse.ok) {
logger.error(`[${requestId}] Failed to save workflow state for template use`)
// Clean up the workflow we created
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
return NextResponse.json(
{ error: 'Failed to create workflow from template' },
{ status: 500 }
)
}
// Use a transaction for template updates and deployment version
const result = await db.transaction(async (tx) => {
// Increment the template views
await tx
.update(templates)
.set({
views: sql`${templates.views} + 1`,
updatedAt: new Date(),
})
.where(eq(templates.id, id))
// Prepare template update data
const updateData: any = {
views: sql`${templates.views} + 1`,
updatedAt: now,
}
const now = new Date()
// If connecting to template for editing, also update the workflowId
// Also create a new deployment version for this workflow with the same state
if (connectToTemplate && !templateData.workflowId) {
updateData.workflowId = newWorkflowId
// Create a new workflow from the template
const newWorkflow = await tx
.insert(workflow)
.values({
id: newWorkflowId,
workspaceId: workspaceId,
name: `${templateData.name} (copy)`,
description: templateData.description,
color: templateData.color,
userId: session.user.id,
createdAt: now,
updatedAt: now,
lastSynced: now,
})
.returning({ id: workflow.id })
// Create workflow_blocks entries from the template state
const templateState = templateData.state as any
if (templateState?.blocks) {
// Create a mapping from old block IDs to new block IDs for reference updates
const blockIdMap = new Map<string, string>()
const blockEntries = Object.values(templateState.blocks).map((block: any) => {
const newBlockId = uuidv4()
blockIdMap.set(block.id, newBlockId)
return {
id: newBlockId,
// Create a deployment version for the new workflow
if (templateData.state) {
const newDeploymentVersionId = uuidv4()
await tx.insert(workflowDeploymentVersion).values({
id: newDeploymentVersionId,
workflowId: newWorkflowId,
type: block.type,
name: block.name,
positionX: block.position?.x?.toString() || '0',
positionY: block.position?.y?.toString() || '0',
enabled: block.enabled !== false,
horizontalHandles: block.horizontalHandles !== false,
isWide: block.isWide || false,
advancedMode: block.advancedMode || false,
height: block.height?.toString() || '0',
subBlocks: block.subBlocks || {},
outputs: block.outputs || {},
data: block.data || {},
parentId: block.parentId ? blockIdMap.get(block.parentId) || null : null,
extent: block.extent || null,
version: 1,
state: templateData.state,
isActive: true,
createdAt: now,
updatedAt: now,
}
})
// Create edge entries with new IDs
const edgeEntries = (templateState.edges || []).map((edge: any) => ({
id: uuidv4(),
workflowId: newWorkflowId,
sourceBlockId: blockIdMap.get(edge.source) || edge.source,
targetBlockId: blockIdMap.get(edge.target) || edge.target,
sourceHandle: edge.sourceHandle || null,
targetHandle: edge.targetHandle || null,
createdAt: now,
}))
// Update the workflow state with new block IDs
const updatedState = { ...templateState }
if (updatedState.blocks) {
const newBlocks: any = {}
Object.entries(updatedState.blocks).forEach(([oldId, blockData]: [string, any]) => {
const newId = blockIdMap.get(oldId)
if (newId) {
newBlocks[newId] = {
...blockData,
id: newId,
}
}
createdBy: session.user.id,
})
updatedState.blocks = newBlocks
}
// Update edges to use new block IDs
if (updatedState.edges) {
updatedState.edges = updatedState.edges.map((edge: any) => ({
...edge,
id: uuidv4(),
source: blockIdMap.get(edge.source) || edge.source,
target: blockIdMap.get(edge.target) || edge.target,
}))
}
// Insert blocks and edges
if (blockEntries.length > 0) {
await tx.insert(workflowBlocks).values(blockEntries)
}
if (edgeEntries.length > 0) {
await tx.insert(workflowEdges).values(edgeEntries)
}
}
return newWorkflow[0]
// Update template with view count and potentially new workflow connection
await tx.update(templates).set(updateData).where(eq(templates.id, id))
return { id: newWorkflowId }
})
logger.info(
`[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}, database returned: ${result.id}`
`[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}`
)
// Track template usage
@@ -191,18 +186,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Silently fail
}
// Verify the workflow was actually created
const verifyWorkflow = await db
.select({ id: workflow.id })
.from(workflow)
.where(eq(workflow.id, newWorkflowId))
.limit(1)
if (verifyWorkflow.length === 0) {
logger.error(`[${requestId}] Workflow was not created properly: ${newWorkflowId}`)
return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 })
}
return NextResponse.json(
{
message: 'Template used successfully',

View File

@@ -1,5 +1,13 @@
import { db } from '@sim/db'
import { templateStars, templates, workflow } from '@sim/db/schema'
import {
member,
templateCreators,
templateStars,
templates,
user,
workflow,
workflowDeploymentVersion,
} from '@sim/db/schema'
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
@@ -7,78 +15,43 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import {
extractRequiredCredentials,
sanitizeCredentials,
} from '@/lib/workflows/credential-extractor'
const logger = createLogger('TemplatesAPI')
export const revalidate = 0
// Function to sanitize sensitive data from workflow state
// Now uses the more comprehensive sanitizeCredentials from credential-extractor
function sanitizeWorkflowState(state: any): any {
const sanitizedState = JSON.parse(JSON.stringify(state)) // Deep clone
if (sanitizedState.blocks) {
Object.values(sanitizedState.blocks).forEach((block: any) => {
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
// Clear OAuth credentials and API keys using regex patterns
if (
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) ||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(
subBlock.type || ''
) ||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(
subBlock.value || ''
)
) {
subBlock.value = ''
}
})
}
// Also clear from data field if present
if (block.data) {
Object.entries(block.data).forEach(([key, value]: [string, any]) => {
if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) {
block.data[key] = ''
}
})
}
})
}
return sanitizedState
return sanitizeCredentials(state)
}
// Schema for creating a template
const CreateTemplateSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'),
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
description: z
.string()
.min(1, 'Description is required')
.max(500, 'Description must be less than 500 characters'),
author: z
.string()
.min(1, 'Author is required')
.max(100, 'Author must be less than 100 characters'),
category: z.string().min(1, 'Category is required'),
icon: z.string().min(1, 'Icon is required'),
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
state: z.object({
blocks: z.record(z.any()),
edges: z.array(z.any()),
loops: z.record(z.any()),
parallels: z.record(z.any()),
}),
details: z
.object({
tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(),
about: z.string().optional(), // Markdown long description
})
.optional(),
creatorId: z.string().optional(), // Creator profile ID
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
})
// Schema for query parameters
const QueryParamsSchema = z.object({
category: z.string().optional(),
limit: z.coerce.number().optional().default(50),
offset: z.coerce.number().optional().default(0),
search: z.string().optional(),
workflowId: z.string().optional(),
status: z.enum(['pending', 'approved', 'rejected']).optional(),
includeAllStatuses: z.coerce.boolean().optional().default(false), // For super users
})
// GET /api/templates - Retrieve templates
@@ -97,27 +70,41 @@ export async function GET(request: NextRequest) {
logger.debug(`[${requestId}] Fetching templates with params:`, params)
// Check if user is a super user
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
// Build query conditions
const conditions = []
// Apply category filter if provided
if (params.category) {
conditions.push(eq(templates.category, params.category))
// Apply workflow filter if provided (for getting template by workflow)
// When fetching by workflowId, we want to get the template regardless of status
// This is used by the deploy modal to check if a template exists
if (params.workflowId) {
conditions.push(eq(templates.workflowId, params.workflowId))
// Don't apply status filter when fetching by workflowId - we want to show
// the template to its owner even if it's pending
} else {
// Apply status filter - only approved templates for non-super users
if (params.status) {
conditions.push(eq(templates.status, params.status))
} else if (!isSuperUser || !params.includeAllStatuses) {
// Non-super users and super users without includeAllStatuses flag see only approved templates
conditions.push(eq(templates.status, 'approved'))
}
}
// Apply search filter if provided
if (params.search) {
const searchTerm = `%${params.search}%`
conditions.push(
or(ilike(templates.name, searchTerm), ilike(templates.description, searchTerm))
or(
ilike(templates.name, searchTerm),
sql`${templates.details}->>'tagline' ILIKE ${searchTerm}`
)
)
}
// Apply workflow filter if provided (for getting template by workflow)
if (params.workflowId) {
conditions.push(eq(templates.workflowId, params.workflowId))
}
// Combine conditions
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
@@ -126,25 +113,27 @@ export async function GET(request: NextRequest) {
.select({
id: templates.id,
workflowId: templates.workflowId,
userId: templates.userId,
name: templates.name,
description: templates.description,
author: templates.author,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
color: templates.color,
icon: templates.icon,
category: templates.category,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
isSuperUser: sql<boolean>`${isSuperUser}`, // Include super user status in response
})
.from(templates)
.leftJoin(
templateStars,
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(whereCondition)
.orderBy(desc(templates.views), desc(templates.createdAt))
.limit(params.limit)
@@ -200,7 +189,6 @@ export async function POST(request: NextRequest) {
logger.debug(`[${requestId}] Creating template:`, {
name: data.name,
category: data.category,
workflowId: data.workflowId,
})
@@ -216,26 +204,116 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
}
// Validate creator profile if provided
if (data.creatorId) {
// Verify the creator profile exists and user has access
const creatorProfile = await db
.select()
.from(templateCreators)
.where(eq(templateCreators.id, data.creatorId))
.limit(1)
if (creatorProfile.length === 0) {
logger.warn(`[${requestId}] Creator profile not found: ${data.creatorId}`)
return NextResponse.json({ error: 'Creator profile not found' }, { status: 404 })
}
const creator = creatorProfile[0]
// Verify user has permission to use this creator profile
if (creator.referenceType === 'user') {
if (creator.referenceId !== session.user.id) {
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
return NextResponse.json(
{ error: 'You do not have permission to use this creator profile' },
{ status: 403 }
)
}
} else if (creator.referenceType === 'organization') {
// Verify user is a member of the organization
const membership = await db
.select()
.from(member)
.where(
and(eq(member.userId, session.user.id), eq(member.organizationId, creator.referenceId))
)
.limit(1)
if (membership.length === 0) {
logger.warn(
`[${requestId}] User not a member of organization for creator: ${data.creatorId}`
)
return NextResponse.json(
{ error: 'You must be a member of the organization to use its creator profile' },
{ status: 403 }
)
}
}
}
// Create the template
const templateId = uuidv4()
const now = new Date()
// Sanitize the workflow state to remove sensitive credentials
const sanitizedState = sanitizeWorkflowState(data.state)
// Get the active deployment version for the workflow to copy its state
const activeVersion = await db
.select({
id: workflowDeploymentVersion.id,
state: workflowDeploymentVersion.state,
})
.from(workflowDeploymentVersion)
.where(
and(
eq(workflowDeploymentVersion.workflowId, data.workflowId),
eq(workflowDeploymentVersion.isActive, true)
)
)
.limit(1)
if (activeVersion.length === 0) {
logger.warn(
`[${requestId}] No active deployment version found for workflow: ${data.workflowId}`
)
return NextResponse.json(
{ error: 'Workflow must be deployed before creating a template' },
{ status: 400 }
)
}
// Ensure the state includes workflow variables (if not already included)
let stateWithVariables = activeVersion[0].state as any
if (stateWithVariables && !stateWithVariables.variables) {
// Fetch workflow variables if not in deployment version
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, data.workflowId))
.limit(1)
stateWithVariables = {
...stateWithVariables,
variables: workflowRecord?.variables || undefined,
}
}
// Extract credential requirements before sanitizing
const requiredCredentials = extractRequiredCredentials(stateWithVariables)
// Sanitize the workflow state to remove all credential values
const sanitizedState = sanitizeWorkflowState(stateWithVariables)
const newTemplate = {
id: templateId,
workflowId: data.workflowId,
userId: session.user.id,
name: data.name,
description: data.description || null,
author: data.author,
details: data.details || null,
creatorId: data.creatorId || null,
views: 0,
stars: 0,
color: data.color,
icon: data.icon,
category: data.category,
state: sanitizedState,
status: 'pending' as const, // All new templates start as pending
tags: data.tags || [],
requiredCredentials: requiredCredentials, // Store the extracted credential requirements
state: sanitizedState, // Store the sanitized state without credential values
createdAt: now,
updatedAt: now,
}
@@ -247,7 +325,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json(
{
id: templateId,
message: 'Template created successfully',
message: 'Template submitted for approval successfully',
},
{ status: 201 }
)

View File

@@ -0,0 +1,42 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
const logger = createLogger('SuperUserAPI')
export const revalidate = 0
// GET /api/user/super-user - Check if current user is a super user (database status)
export async function GET(request: NextRequest) {
const requestId = generateRequestId()
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized super user status check attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
if (currentUser.length === 0) {
logger.warn(`[${requestId}] User not found: ${session.user.id}`)
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({
isSuperUser: currentUser[0].isSuperUser,
})
} catch (error) {
logger.error(`[${requestId}] Error checking super user status`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -28,6 +28,7 @@ const SettingsSchema = z.object({
billingUsageNotificationsEnabled: z.boolean().optional(),
showFloatingControls: z.boolean().optional(),
showTrainingControls: z.boolean().optional(),
superUserModeEnabled: z.boolean().optional(),
})
// Default settings values
@@ -42,6 +43,7 @@ const defaultSettings = {
billingUsageNotificationsEnabled: true,
showFloatingControls: true,
showTrainingControls: false,
superUserModeEnabled: false,
}
export async function GET() {
@@ -78,6 +80,7 @@ export async function GET() {
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
showFloatingControls: userSettings.showFloatingControls ?? true,
showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
},
},
{ status: 200 }

View File

@@ -142,6 +142,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
isDeployed: workflowData.isDeployed || false,
deployedAt: workflowData.deployedAt,
},
// Include workflow variables
variables: workflowData.variables || {},
}
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
@@ -218,7 +220,13 @@ export async function DELETE(
if (checkTemplates) {
// Return template information for frontend to handle
const publishedTemplates = await db
.select()
.select({
id: templates.id,
name: templates.name,
views: templates.views,
stars: templates.stars,
status: templates.status,
})
.from(templates)
.where(eq(templates.workflowId, workflowId))

View File

@@ -102,6 +102,7 @@ const WorkflowStateSchema = z.object({
lastSaved: z.number().optional(),
isDeployed: z.boolean().optional(),
deployedAt: z.coerce.date().optional(),
variables: z.any().optional(), // Workflow variables
})
/**
@@ -227,14 +228,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId })
}
// Update workflow's lastSynced timestamp
await db
.update(workflow)
.set({
lastSynced: new Date(),
updatedAt: new Date(),
})
.where(eq(workflow.id, workflowId))
// Update workflow's lastSynced timestamp and variables if provided
const updateData: any = {
lastSynced: new Date(),
updatedAt: new Date(),
}
// If variables are provided in the state, update them in the workflow record
if (state.variables !== undefined) {
updateData.variables = state.variables
}
await db.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`)

View File

@@ -0,0 +1,5 @@
import TemplateDetails from './template'
export default function TemplatePage() {
return <TemplateDetails />
}

View File

@@ -0,0 +1,936 @@
'use client'
import { useEffect, useState } from 'react'
import { formatDistanceToNow } from 'date-fns'
import {
ArrowLeft,
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
ChevronDown,
Clock,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Eye,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Linkedin,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
Twitter,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
import type { Template } from '@/app/templates/templates'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
const logger = createLogger('TemplateDetails')
// Icon mapping
const iconMap = {
FileText,
NotebookPen,
BookOpen,
BarChart3,
LineChart,
TrendingUp,
Target,
Database,
Server,
Cloud,
Folder,
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
HeadphonesIcon,
Users,
Settings,
Wrench,
Bot,
Brain,
Cpu,
Code,
Zap,
Workflow,
Search,
Play,
Layers,
Lightbulb,
Globe,
Award,
}
export default function TemplateDetails() {
const router = useRouter()
const searchParams = useSearchParams()
const params = useParams()
const templateId = params?.id as string
const [template, setTemplate] = useState<Template | null>(null)
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
const [currentUserOrgs, setCurrentUserOrgs] = useState<string[]>([])
const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
Array<{ organizationId: string; role: string }>
>([])
const [isSuperUser, setIsSuperUser] = useState(false)
const [loading, setLoading] = useState(true)
const [isStarred, setIsStarred] = useState(false)
const [starCount, setStarCount] = useState(0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [isApproving, setIsApproving] = useState(false)
const [isRejecting, setIsRejecting] = useState(false)
const [hasWorkspaceAccess, setHasWorkspaceAccess] = useState<boolean | null>(null)
const [workspaces, setWorkspaces] = useState<
Array<{ id: string; name: string; permissions: string }>
>([])
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
const [showWorkspaceSelectorForEdit, setShowWorkspaceSelectorForEdit] = useState(false)
const [showWorkspaceSelectorForUse, setShowWorkspaceSelectorForUse] = useState(false)
// Fetch template data on client side
useEffect(() => {
if (!templateId) {
setLoading(false)
return
}
const fetchTemplate = async () => {
try {
const response = await fetch(`/api/templates/${templateId}`)
if (response.ok) {
const data = await response.json()
setTemplate(data.data)
setIsStarred(data.data.isStarred || false)
setStarCount(data.data.stars || 0)
}
} catch (error) {
console.error('Error fetching template:', error)
} finally {
setLoading(false)
}
}
const fetchCurrentUser = async () => {
try {
const response = await fetch('/api/auth/get-session')
if (response.ok) {
const data = await response.json()
setCurrentUserId(data?.user?.id || null)
} else {
setCurrentUserId(null)
}
} catch (error) {
console.error('Error fetching session:', error)
setCurrentUserId(null)
}
}
const fetchUserOrganizations = async () => {
try {
const response = await fetch('/api/organizations')
if (response.ok) {
const data = await response.json()
const orgs = data.organizations || []
const orgIds = orgs.map((org: any) => org.id)
const orgRoles = orgs.map((org: any) => ({
organizationId: org.id,
role: org.role,
}))
setCurrentUserOrgs(orgIds)
setCurrentUserOrgRoles(orgRoles)
}
} catch (error) {
console.error('Error fetching organizations:', error)
}
}
const fetchSuperUserStatus = async () => {
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser || false)
}
} catch (error) {
console.error('Error fetching super user status:', error)
}
}
fetchTemplate()
fetchCurrentUser()
fetchSuperUserStatus()
fetchUserOrganizations()
}, [templateId])
// Fetch workspaces when user is logged in
useEffect(() => {
if (!currentUserId) return
const fetchWorkspaces = async () => {
try {
setIsLoadingWorkspaces(true)
const response = await fetch('/api/workspaces')
if (response.ok) {
const data = await response.json()
// Filter workspaces where user has write/admin permissions
const availableWorkspaces = data.workspaces
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
.map((ws: any) => ({
id: ws.id,
name: ws.name,
permissions: ws.permissions,
}))
setWorkspaces(availableWorkspaces)
}
} catch (error) {
console.error('Error fetching workspaces:', error)
} finally {
setIsLoadingWorkspaces(false)
}
}
fetchWorkspaces()
}, [currentUserId])
// Clean up URL when returning from login
useEffect(() => {
if (template && searchParams?.get('use') === 'true' && currentUserId) {
router.replace(`/templates/${template.id}`)
}
}, [searchParams, currentUserId, template, router])
// Check if user can edit template
const canEditTemplate = (() => {
if (!currentUserId || !template?.creator) return false
// For user creator profiles: must be the user themselves
if (template.creator.referenceType === 'user') {
return template.creator.referenceId === currentUserId
}
// For organization creator profiles:
if (template.creator.referenceType === 'organization' && template.creator.referenceId) {
const isOrgMember = currentUserOrgs.includes(template.creator.referenceId)
// If template has a connected workflow, any org member with workspace access can edit
if (template.workflowId) {
return isOrgMember
}
// If template is orphaned, only admin/owner can edit
// We need to check the user's role in the organization
const orgMembership = currentUserOrgRoles.find(
(org) => org.organizationId === template.creator?.referenceId
)
const isAdminOrOwner = orgMembership?.role === 'admin' || orgMembership?.role === 'owner'
return isOrgMember && isAdminOrOwner
}
return false
})()
// Check workspace access for connected workflow
useEffect(() => {
const checkWorkspaceAccess = async () => {
if (!template?.workflowId || !currentUserId || !canEditTemplate) {
setHasWorkspaceAccess(null)
return
}
try {
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.status === 403) {
setHasWorkspaceAccess(false)
} else if (checkResponse.ok) {
setHasWorkspaceAccess(true)
} else {
// Workflow doesn't exist
setHasWorkspaceAccess(null)
}
} catch (error) {
logger.error('Error checking workspace access:', error)
setHasWorkspaceAccess(null)
}
}
checkWorkspaceAccess()
}, [template?.workflowId, currentUserId, canEditTemplate])
if (loading) {
return (
<div className='flex h-screen items-center justify-center'>
<div className='text-center'>
<p className='text-muted-foreground'>Loading template...</p>
</div>
</div>
)
}
if (!template) {
return (
<div className='flex h-screen items-center justify-center'>
<div className='text-center'>
<h1 className='mb-4 font-bold text-2xl'>Template Not Found</h1>
<p className='text-muted-foreground'>The template you're looking for doesn't exist.</p>
</div>
</div>
)
}
const renderWorkflowPreview = () => {
if (!template.state) {
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
<div className='mb-2 font-medium text-lg'> No Workflow Data</div>
<div className='text-sm'>This template doesn't contain workflow state data.</div>
</div>
</div>
)
}
try {
return (
<WorkflowPreview
workflowState={template.state}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={1}
/>
)
} catch (error) {
console.error('Error rendering workflow preview:', error)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
<div className='mb-2 font-medium text-lg'>⚠️ Preview Error</div>
<div className='text-sm'>Unable to render workflow preview</div>
</div>
</div>
)
}
}
const handleBack = () => {
router.push('/templates')
}
const handleStarToggle = async () => {
if (isStarring || !currentUserId) return
setIsStarring(true)
try {
const method = isStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${template.id}/star`, { method })
if (response.ok) {
setIsStarred(!isStarred)
setStarCount((prev) => (isStarred ? prev - 1 : prev + 1))
}
} catch (error) {
logger.error('Error toggling star:', error)
} finally {
setIsStarring(false)
}
}
const handleUseTemplate = () => {
if (!currentUserId) {
const callbackUrl = encodeURIComponent(`/templates/${template.id}`)
router.push(`/login?callbackUrl=${callbackUrl}`)
return
}
setShowWorkspaceSelectorForUse(true)
}
const handleEditTemplate = async () => {
if (!currentUserId || !template) return
// Check if workflow exists and user has access
if (template.workflowId) {
setIsEditing(true)
try {
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.status === 403) {
// User doesn't have access to the workspace
// This shouldn't happen if button is properly disabled, but handle it gracefully
alert("You don't have access to the workspace containing this template")
return
}
if (checkResponse.ok) {
// Workflow exists and user has access, get its workspace and navigate to it
const result = await checkResponse.json()
const workspaceId = result.data?.workspaceId
if (workspaceId) {
// Use window.location to ensure a full page load with fresh data
// This avoids race conditions with client-side navigation
window.location.href = `/workspace/${workspaceId}/w/${template.workflowId}`
return
}
}
} catch (error) {
logger.error('Error checking workflow:', error)
} finally {
setIsEditing(false)
}
}
// Workflow doesn't exist or was deleted - show workspace selector
setShowWorkspaceSelectorForEdit(true)
}
const handleWorkspaceSelectForUse = async (workspaceId: string) => {
if (isUsing || !template) return
setIsUsing(true)
setShowWorkspaceSelectorForUse(false)
try {
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
throw new Error('Failed to use template')
}
const { workflowId } = await response.json()
// Navigate to the new workflow with full page load
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
} catch (error) {
logger.error('Error using template:', error)
} finally {
setIsUsing(false)
}
}
const handleWorkspaceSelectForEdit = async (workspaceId: string) => {
if (isUsing || !template) return
setIsUsing(true)
setShowWorkspaceSelectorForEdit(false)
try {
// Import template as a new workflow and connect it to the template
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId, connectToTemplate: true }),
})
if (!response.ok) {
throw new Error('Failed to import template for editing')
}
const { workflowId } = await response.json()
// Navigate to the new workflow with full page load
window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
} catch (error) {
logger.error('Error importing template for editing:', error)
} finally {
setIsUsing(false)
}
}
const handleApprove = async () => {
if (isApproving || !template) return
setIsApproving(true)
try {
const response = await fetch(`/api/templates/${template.id}/approve`, {
method: 'POST',
})
if (response.ok) {
// Update template status optimistically
setTemplate({ ...template, status: 'approved' })
// Redirect back to templates page after approval
router.push('/templates')
}
} catch (error) {
logger.error('Error approving template:', error)
} finally {
setIsApproving(false)
}
}
const handleReject = async () => {
if (isRejecting || !template) return
setIsRejecting(true)
try {
const response = await fetch(`/api/templates/${template.id}/reject`, {
method: 'POST',
})
if (response.ok) {
// Update template status optimistically
setTemplate({ ...template, status: 'rejected' })
// Redirect back to templates page after rejection
router.push('/templates')
}
} catch (error) {
logger.error('Error rejecting template:', error)
} finally {
setIsRejecting(false)
}
}
return (
<TooltipProvider delayDuration={100} skipDelayDuration={0}>
<div className='flex min-h-screen flex-col'>
{/* Header */}
<div className='border-b bg-background p-6'>
<div className='mx-auto max-w-7xl'>
{/* Back button */}
<button
onClick={handleBack}
className='mb-6 flex items-center gap-2 text-muted-foreground transition-colors hover:text-foreground'
>
<ArrowLeft className='h-4 w-4' />
<span className='text-sm'>Back to templates</span>
</button>
{/* Template header */}
<div className='flex items-start justify-between'>
<div className='flex items-start gap-4'>
{/* Icon */}
{/* Title and description */}
<div>
<h1 className='font-bold text-3xl text-foreground'>{template.name}</h1>
{template.details?.tagline && (
<p className='mt-2 max-w-3xl text-lg text-muted-foreground'>
{template.details.tagline}
</p>
)}
{/* Tags */}
{template.tags && template.tags.length > 0 && (
<div className='mt-3 flex flex-wrap gap-2'>
{template.tags.map((tag, index) => (
<Badge
key={index}
variant='secondary'
className='border-0 bg-muted/60 px-2.5 py-0.5 text-sm hover:bg-muted/80'
>
{tag}
</Badge>
))}
</div>
)}
</div>
</div>
{/* Action buttons */}
<div className='flex items-center gap-3'>
{/* Super user approve/reject buttons for pending templates */}
{isSuperUser && template.status === 'pending' && (
<>
<Button
onClick={handleApprove}
disabled={isApproving}
className='bg-green-600 text-white hover:bg-green-700'
>
{isApproving ? 'Approving...' : 'Approve'}
</Button>
<Button
onClick={handleReject}
disabled={isRejecting}
variant='outline'
className='border-red-600 text-red-600 hover:bg-red-50'
>
{isRejecting ? 'Rejecting...' : 'Reject'}
</Button>
</>
)}
{/* Star button - only for logged-in non-owners and non-pending templates */}
{currentUserId && !canEditTemplate && template.status !== 'pending' && (
<Button
variant='outline'
size='sm'
onClick={handleStarToggle}
disabled={isStarring}
className={cn(
'transition-colors',
isStarred &&
'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100'
)}
>
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} />
{starCount}
</Button>
)}
{/* Edit button - for template owners (approved or pending) */}
{canEditTemplate && currentUserId && (
<>
{template.workflowId && !showWorkspaceSelectorForEdit ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
onClick={handleEditTemplate}
disabled={isEditing || hasWorkspaceAccess === false}
className={
hasWorkspaceAccess === false
? 'cursor-not-allowed opacity-50'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
>
{isEditing ? 'Opening...' : 'Edit Template'}
</Button>
</span>
</TooltipTrigger>
{hasWorkspaceAccess === false && (
<TooltipContent>
<p>Don't have access to workspace to edit template</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : (
<DropdownMenu
open={showWorkspaceSelectorForEdit}
onOpenChange={setShowWorkspaceSelectorForEdit}
>
<DropdownMenuTrigger asChild>
<Button
onClick={() =>
!template.workflowId && setShowWorkspaceSelectorForEdit(true)
}
disabled={isUsing || isLoadingWorkspaces}
className='bg-blue-600 text-white hover:bg-blue-700'
>
{isUsing
? 'Importing...'
: isLoadingWorkspaces
? 'Loading...'
: 'Edit Template'}
<ChevronDown className='ml-2 h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{workspaces.length === 0 ? (
<DropdownMenuItem disabled className='text-muted-foreground text-sm'>
No workspaces with write access
</DropdownMenuItem>
) : (
workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceSelectForEdit(workspace.id)}
className='flex cursor-pointer items-center justify-between'
>
<div className='flex flex-col'>
<span className='font-medium text-sm'>{workspace.name}</span>
<span className='text-muted-foreground text-xs capitalize'>
{workspace.permissions} access
</span>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
{/* Use template button - only for approved templates and non-owners */}
{template.status === 'approved' && !canEditTemplate && (
<>
{!currentUserId ? (
<Button
onClick={() => {
const callbackUrl = encodeURIComponent(`/templates/${template.id}`)
router.push(`/login?callbackUrl=${callbackUrl}`)
}}
className='bg-purple-600 text-white hover:bg-purple-700'
>
Sign in to use
</Button>
) : (
<DropdownMenu
open={showWorkspaceSelectorForUse}
onOpenChange={setShowWorkspaceSelectorForUse}
>
<DropdownMenuTrigger asChild>
<Button
onClick={() => setShowWorkspaceSelectorForUse(true)}
disabled={isUsing || isLoadingWorkspaces}
className='bg-purple-600 text-white hover:bg-purple-700'
>
{isUsing
? 'Creating...'
: isLoadingWorkspaces
? 'Loading...'
: 'Use this template'}
<ChevronDown className='ml-2 h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
{workspaces.length === 0 ? (
<DropdownMenuItem disabled className='text-muted-foreground text-sm'>
No workspaces with write access
</DropdownMenuItem>
) : (
workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceSelectForUse(workspace.id)}
className='flex cursor-pointer items-center justify-between'
>
<div className='flex flex-col'>
<span className='font-medium text-sm'>{workspace.name}</span>
<span className='text-muted-foreground text-xs capitalize'>
{workspace.permissions} access
</span>
</div>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
</div>
</div>
{/* Tags */}
<div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'>
{/* Views */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Eye className='h-3 w-3' />
<span>{template.views} views</span>
</div>
{/* Stars */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Star className='h-3 w-3' />
<span>{starCount} stars</span>
</div>
{/* Author */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<User className='h-3 w-3' />
<span>by {template.creator?.name || 'Unknown'}</span>
</div>
{/* Author Type - show if organization */}
{template.creator?.referenceType === 'organization' && (
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Users className='h-3 w-3' />
<span>Organization</span>
</div>
)}
{/* Last Updated */}
{template.updatedAt && (
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Clock className='h-3 w-3' />
<span>
Last updated{' '}
{formatDistanceToNow(new Date(template.updatedAt), {
addSuffix: true,
})}
</span>
</div>
)}
</div>
</div>
</div>
{/* Workflow preview */}
<div className='flex-1 p-6'>
<div className='mx-auto max-w-7xl'>
<h2 className='mb-4 font-semibold text-xl'>Workflow Preview</h2>
<div className='h-[600px] w-full'>{renderWorkflowPreview()}</div>
{Array.isArray(template.requiredCredentials) &&
template.requiredCredentials.length > 0 && (
<div className='mt-8'>
<h3 className='mb-3 font-semibold text-lg'>Credentials Needed</h3>
<ul className='list-disc space-y-1 pl-6 text-muted-foreground text-sm'>
{template.requiredCredentials.map(
(cred: CredentialRequirement, idx: number) => {
// Get block name from registry or format blockType
const blockName =
getBlock(cred.blockType)?.name ||
cred.blockType.charAt(0).toUpperCase() + cred.blockType.slice(1)
const alreadyHasBlock = cred.label
.toLowerCase()
.includes(` for ${blockName.toLowerCase()}`)
const text = alreadyHasBlock ? cred.label : `${cred.label} for ${blockName}`
return <li key={idx}>{text}</li>
}
)}
</ul>
</div>
)}
{/* About this Workflow */}
{template.details?.about && (
<div className='mt-8'>
<h3 className='mb-3 font-semibold text-lg'>About this Workflow</h3>
<div className='prose prose-sm max-w-none dark:prose-invert'>
<ReactMarkdown>{template.details.about}</ReactMarkdown>
</div>
</div>
)}
{/* Creator Profile */}
{template.creator && (
<div className='mt-8'>
<h3 className='mb-4 font-semibold text-lg'>About the Creator</h3>
<div className='rounded-lg border bg-card p-6'>
<div className='flex items-start gap-4'>
{/* Profile Picture */}
<div className='flex-shrink-0'>
{template.creator.profileImageUrl ? (
<div className='relative h-20 w-20 overflow-hidden rounded-full'>
<img
src={template.creator.profileImageUrl}
alt={template.creator.name}
className='h-full w-full object-cover'
/>
</div>
) : (
<div className='flex h-20 w-20 items-center justify-center rounded-full bg-[#802FFF]'>
<User className='h-10 w-10 text-white' />
</div>
)}
</div>
{/* Creator Info */}
<div className='flex-1'>
<h4 className='font-semibold text-lg'>{template.creator.name}</h4>
{template.creator.details?.about && (
<p className='mt-2 text-muted-foreground text-sm leading-relaxed'>
{template.creator.details.about}
</p>
)}
{/* Social Links */}
{(template.creator.details?.xUrl ||
template.creator.details?.linkedinUrl ||
template.creator.details?.websiteUrl ||
template.creator.details?.contactEmail) && (
<div className='mt-4 flex flex-wrap gap-3'>
{template.creator.details.xUrl && (
<a
href={template.creator.details.xUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground'
>
<Twitter className='h-4 w-4' />
<span>X</span>
</a>
)}
{template.creator.details.linkedinUrl && (
<a
href={template.creator.details.linkedinUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground'
>
<Linkedin className='h-4 w-4' />
<span>LinkedIn</span>
</a>
)}
{template.creator.details.websiteUrl && (
<a
href={template.creator.details.websiteUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground'
>
<Globe className='h-4 w-4' />
<span>Website</span>
</a>
)}
{template.creator.details.contactEmail && (
<a
href={`mailto:${template.creator.details.contactEmail}`}
className='inline-flex items-center gap-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground'
>
<Mail className='h-4 w-4' />
<span>Contact</span>
</a>
)}
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,33 @@
import { cn } from '@/lib/utils'
interface NavigationTab {
id: string
label: string
count?: number
}
interface NavigationTabsProps {
tabs: NavigationTab[]
activeTab?: string
onTabClick?: (tabId: string) => void
className?: string
}
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
{tabs.map((tab, index) => (
<button
key={tab.id}
onClick={() => onTabClick?.(tab.id)}
className={cn(
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
)}
>
<span>{tab.label}</span>
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,463 @@
import { useState } from 'react'
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { getBlock } from '@/blocks/registry'
const logger = createLogger('TemplateCard')
// Icon mapping for template icons
const iconMap = {
// Content & Documentation
FileText,
NotebookPen,
BookOpen,
Edit,
// Analytics & Charts
BarChart3,
LineChart,
TrendingUp,
Target,
// Database & Storage
Database,
Server,
Cloud,
Folder,
// Marketing & Communication
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
// Sales & Finance
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
// Support & Service
HeadphonesIcon,
User,
Users,
Settings,
Wrench,
// AI & Technology
Bot,
Brain,
Cpu,
Code,
Zap,
// Workflow & Process
Workflow,
Search,
Play,
Layers,
// General
Lightbulb,
Star,
Globe,
Award,
}
interface TemplateCardProps {
id: string
title: string
description: string
author: string
usageCount: string
stars?: number
blocks?: string[]
tags?: string[]
className?: string
state?: {
blocks?: Record<string, { type: string; name?: string }>
}
isStarred?: boolean
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
isAuthenticated?: boolean
}
// Skeleton component for loading states
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
{/* Left side - Info skeleton */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section skeleton */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon skeleton */}
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
{/* Title skeleton */}
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
</div>
{/* Star and Use button skeleton */}
<div className='flex flex-shrink-0 items-center gap-3'>
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
</div>
</div>
{/* Description skeleton */}
<div className='space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
</div>
</div>
{/* Bottom section skeleton */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
{/* Stars section - hidden on smaller screens */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
</div>
</div>
</div>
{/* Right side - Block Icons skeleton */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='animate-pulse rounded bg-gray-200'
style={{ width: '30px', height: '30px' }}
/>
))}
</div>
</div>
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): string[] => {
if (!state?.blocks) return []
// Get unique block types from the state, excluding starter blocks
// Sort the keys to ensure consistent ordering between server and client
const blockTypes = Object.keys(state.blocks)
.sort() // Sort keys to ensure consistent order
.map((key) => state.blocks![key].type)
.filter((type) => type !== 'starter')
return [...new Set(blockTypes)]
}
// Utility function to get block display name
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
export function TemplateCard({
id,
title,
description,
author,
usageCount,
stars = 0,
blocks = [],
tags = [],
className,
state,
isStarred = false,
onStarChange,
isAuthenticated = true,
}: TemplateCardProps) {
const router = useRouter()
// Local state for optimistic updates
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
const blockTypes = state
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort()
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
e.stopPropagation()
// Prevent multiple clicks while loading
if (isStarLoading) return
setIsStarLoading(true)
// Optimistic update - update UI immediately
const newIsStarred = !localIsStarred
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
setLocalIsStarred(newIsStarred)
setLocalStarCount(newStarCount)
// Notify parent component immediately for optimistic update
if (onStarChange) {
onStarChange(id, newIsStarred, newStarCount)
}
try {
const method = localIsStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${id}/star`, { method })
if (!response.ok) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Failed to toggle star:', response.statusText)
}
} catch (error) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Error toggling star:', error)
} finally {
setIsStarLoading(false)
}
}
// Handle use click - just navigate to detail page
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
router.push(`/templates/${id}`)
}
const handleCardClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
router.push(`/templates/${id}`)
}
return (
<div
onClick={handleCardClick}
className={cn(
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
>
{/* Left side - Info */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Template name */}
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
{title}
</h3>
</div>
{/* Actions */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Star button - only for authenticated users */}
{isAuthenticated && (
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
isStarLoading && 'opacity-50'
)}
/>
)}
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</div>
</div>
{/* Description */}
<p className='line-clamp-2 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
{description}
</p>
{/* Tags */}
{tags && tags.length > 0 && (
<div className='mt-1 flex flex-wrap gap-1'>
{tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
variant='secondary'
className='h-5 border-0 bg-muted/60 px-1.5 text-[10px] hover:bg-muted/80'
>
{tag}
</Badge>
))}
{tags.length > 3 && (
<Badge
variant='secondary'
className='h-5 border-0 bg-muted/60 px-1.5 text-[10px] hover:bg-muted/80'
>
+{tags.length - 3}
</Badge>
)}
</div>
)}
</div>
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
<span className='flex-shrink-0'>by</span>
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'></span>
<User className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<span></span>
<Star className='h-3 w-3' />
<span>{localStarCount}</span>
</div>
</div>
</div>
{/* Right side - Block Icons */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{blockTypes.length > 3 ? (
<>
{/* Show first 2 blocks when there are more than 3 */}
{blockTypes.slice(0, 2).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})}
{/* Show +n block for remaining blocks */}
<div className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
style={{ width: '30px', height: '30px' }}
>
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
</div>
</div>
</>
) : (
/* Show all blocks when 3 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,5 @@
import { GeistSans } from 'geist/font/sans'
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
return <div className={GeistSans.className}>{children}</div>
}

View File

@@ -0,0 +1,33 @@
import { cn } from '@/lib/utils'
interface NavigationTab {
id: string
label: string
count?: number
}
interface NavigationTabsProps {
tabs: NavigationTab[]
activeTab?: string
onTabClick?: (tabId: string) => void
className?: string
}
export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) {
return (
<div className={cn('flex items-center gap-2', className)}>
{tabs.map((tab, index) => (
<button
key={tab.id}
onClick={() => onTabClick?.(tab.id)}
className={cn(
'flex h-[38px] items-center gap-1 rounded-[14px] px-3 font-[440] font-sans text-muted-foreground text-sm transition-all duration-200',
activeTab === tab.id ? 'bg-secondary' : 'bg-transparent hover:bg-secondary/50'
)}
>
<span>{tab.label}</span>
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,99 @@
import { db } from '@sim/db'
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import type { Template } from '@/app/templates/templates'
import Templates from '@/app/templates/templates'
export default async function TemplatesPage() {
const session = await getSession()
// Check if user is a super user and if super user mode is enabled
let effectiveSuperUser = false
if (session?.user?.id) {
const currentUser = await db
.select({ isSuperUser: user.isSuperUser })
.from(user)
.where(eq(user.id, session.user.id))
.limit(1)
const userSettings = await db
.select({ superUserModeEnabled: settings.superUserModeEnabled })
.from(settings)
.where(eq(settings.userId, session.user.id))
.limit(1)
const isSuperUser = currentUser[0]?.isSuperUser || false
const superUserModeEnabled = userSettings[0]?.superUserModeEnabled ?? true
// Effective super user = database status AND UI mode enabled
effectiveSuperUser = isSuperUser && superUserModeEnabled
}
// Fetch templates based on user status
let templatesData
if (session?.user?.id) {
// Build where condition based on super user status
const whereCondition = effectiveSuperUser ? undefined : eq(templates.status, 'approved')
// Logged-in users: include star status
templatesData = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
name: templates.name,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
})
.from(templates)
.leftJoin(
templateStars,
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(whereCondition)
.orderBy(desc(templates.views), desc(templates.createdAt))
} else {
// Non-logged-in users: only approved templates, no star status
templatesData = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
name: templates.name,
details: templates.details,
creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views,
stars: templates.stars,
status: templates.status,
tags: templates.tags,
requiredCredentials: templates.requiredCredentials,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.status, 'approved'))
.orderBy(desc(templates.views), desc(templates.createdAt))
.then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
}
return (
<Templates
initialTemplates={templatesData as unknown as Template[]}
currentUserId={session?.user?.id || null}
isSuperUser={effectiveSuperUser}
/>
)
}

View File

@@ -0,0 +1,564 @@
import { useState } from 'react'
import {
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
FileText,
Folder,
Globe,
HeadphonesIcon,
Layers,
Lightbulb,
LineChart,
Mail,
Megaphone,
MessageSquare,
NotebookPen,
Phone,
Play,
Search,
Server,
Settings,
ShoppingCart,
Star,
Target,
TrendingUp,
User,
Users,
Workflow,
Wrench,
Zap,
} from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { getBlock } from '@/blocks/registry'
const logger = createLogger('TemplateCard')
// Icon mapping for template icons
const iconMap = {
// Content & Documentation
FileText,
NotebookPen,
BookOpen,
Edit,
// Analytics & Charts
BarChart3,
LineChart,
TrendingUp,
Target,
// Database & Storage
Database,
Server,
Cloud,
Folder,
// Marketing & Communication
Megaphone,
Mail,
MessageSquare,
Phone,
Bell,
// Sales & Finance
DollarSign,
CreditCard,
Calculator,
ShoppingCart,
Briefcase,
// Support & Service
HeadphonesIcon,
User,
Users,
Settings,
Wrench,
// AI & Technology
Bot,
Brain,
Cpu,
Code,
Zap,
// Workflow & Process
Workflow,
Search,
Play,
Layers,
// General
Lightbulb,
Star,
Globe,
Award,
}
interface TemplateCardProps {
id: string
title: string
description: string
author: string
usageCount: string
stars?: number
blocks?: string[]
onClick?: () => void
className?: string
// Add state prop to extract block types
state?: {
blocks?: Record<string, { type: string; name?: string }>
}
isStarred?: boolean
// Optional callback when template is successfully used (for closing modals, etc.)
onTemplateUsed?: () => void
// Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// Super user props for approval
status?: 'pending' | 'approved' | 'rejected'
isSuperUser?: boolean
onApprove?: (templateId: string) => void
onReject?: (templateId: string) => void
}
// Skeleton component for loading states
export function TemplateCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-[8px] border bg-card shadow-xs', 'flex h-[142px]', className)}>
{/* Left side - Info skeleton */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section skeleton */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Icon skeleton */}
<div className='h-5 w-5 flex-shrink-0 animate-pulse rounded-md bg-gray-200' />
{/* Title skeleton */}
<div className='h-4 w-32 animate-pulse rounded bg-gray-200' />
</div>
{/* Star and Use button skeleton */}
<div className='flex flex-shrink-0 items-center gap-3'>
<div className='h-4 w-4 animate-pulse rounded bg-gray-200' />
<div className='h-6 w-10 animate-pulse rounded-md bg-gray-200' />
</div>
</div>
{/* Description skeleton */}
<div className='space-y-1.5'>
<div className='h-3 w-full animate-pulse rounded bg-gray-200' />
<div className='h-3 w-4/5 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3/5 animate-pulse rounded bg-gray-200' />
</div>
</div>
{/* Bottom section skeleton */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5'>
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-16 animate-pulse rounded bg-gray-200' />
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-8 animate-pulse rounded bg-gray-200' />
{/* Stars section - hidden on smaller screens */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<div className='h-2 w-1 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-3 animate-pulse rounded bg-gray-200' />
<div className='h-3 w-6 animate-pulse rounded bg-gray-200' />
</div>
</div>
</div>
{/* Right side - Block Icons skeleton */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className='animate-pulse rounded bg-gray-200'
style={{ width: '30px', height: '30px' }}
/>
))}
</div>
</div>
)
}
// Utility function to extract block types from workflow state
const extractBlockTypesFromState = (state?: {
blocks?: Record<string, { type: string; name?: string }>
}): string[] => {
if (!state?.blocks) return []
// Get unique block types from the state, excluding starter blocks
// Sort the keys to ensure consistent ordering between server and client
const blockTypes = Object.keys(state.blocks)
.sort() // Sort keys to ensure consistent order
.map((key) => state.blocks![key].type)
.filter((type) => type !== 'starter')
return [...new Set(blockTypes)]
}
// Utility function to get block display name
const getBlockDisplayName = (blockType: string): string => {
const block = getBlock(blockType)
return block?.name || blockType
}
// Utility function to get the full block config for colored icon display
const getBlockConfig = (blockType: string) => {
const block = getBlock(blockType)
return block
}
export function TemplateCard({
id,
title,
description,
author,
usageCount,
stars = 0,
blocks = [],
onClick,
className,
state,
isStarred = false,
onTemplateUsed,
onStarChange,
status,
isSuperUser,
onApprove,
onReject,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
const [isApproving, setIsApproving] = useState(false)
const [isRejecting, setIsRejecting] = useState(false)
// Local state for optimistic updates
const [localIsStarred, setLocalIsStarred] = useState(isStarred)
const [localStarCount, setLocalStarCount] = useState(stars)
const [isStarLoading, setIsStarLoading] = useState(false)
// Extract block types from state if provided, otherwise use the blocks prop
// Filter out starter blocks in both cases and sort for consistent rendering
const blockTypes = state
? extractBlockTypesFromState(state)
: blocks.filter((blockType) => blockType !== 'starter').sort()
// Handle star toggle with optimistic updates
const handleStarClick = async (e: React.MouseEvent) => {
e.stopPropagation()
// Prevent multiple clicks while loading
if (isStarLoading) return
setIsStarLoading(true)
// Optimistic update - update UI immediately
const newIsStarred = !localIsStarred
const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1
setLocalIsStarred(newIsStarred)
setLocalStarCount(newStarCount)
// Notify parent component immediately for optimistic update
if (onStarChange) {
onStarChange(id, newIsStarred, newStarCount)
}
try {
const method = localIsStarred ? 'DELETE' : 'POST'
const response = await fetch(`/api/templates/${id}/star`, { method })
if (!response.ok) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Failed to toggle star:', response.statusText)
}
} catch (error) {
// Rollback on error
setLocalIsStarred(localIsStarred)
setLocalStarCount(localStarCount)
// Rollback parent state too
if (onStarChange) {
onStarChange(id, localIsStarred, localStarCount)
}
logger.error('Error toggling star:', error)
} finally {
setIsStarLoading(false)
}
}
// Handle use template
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
const response = await fetch(`/api/templates/${id}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: params.workspaceId,
}),
})
if (response.ok) {
const data = await response.json()
logger.info('Template use API response:', data)
if (!data.workflowId) {
logger.error('No workflowId returned from API:', data)
return
}
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
logger.info('Template used successfully, navigating to:', workflowUrl)
// Call the callback if provided (for closing modals, etc.)
if (onTemplateUsed) {
onTemplateUsed()
}
// Use window.location.href for more reliable navigation
window.location.href = workflowUrl
} else {
const errorText = await response.text()
logger.error('Failed to use template:', response.statusText, errorText)
}
} catch (error) {
logger.error('Error using template:', error)
}
}
const handleCardClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
const workspaceId = params?.workspaceId as string
if (workspaceId) {
router.push(`/workspace/${workspaceId}/templates/${id}`)
}
}
const handleApprove = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isApproving || !onApprove) return
setIsApproving(true)
try {
const response = await fetch(`/api/templates/${id}/approve`, {
method: 'POST',
})
if (response.ok) {
onApprove(id)
}
} catch (error) {
logger.error('Error approving template:', error)
} finally {
setIsApproving(false)
}
}
const handleReject = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isRejecting || !onReject) return
setIsRejecting(true)
try {
const response = await fetch(`/api/templates/${id}/reject`, {
method: 'POST',
})
if (response.ok) {
onReject(id)
}
} catch (error) {
logger.error('Error rejecting template:', error)
} finally {
setIsRejecting(false)
}
}
return (
<div
onClick={handleCardClick}
className={cn(
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
>
{/* Left side - Info */}
<div className='flex min-w-0 flex-1 flex-col justify-between p-4'>
{/* Top section */}
<div className='space-y-2'>
<div className='flex min-w-0 items-center justify-between gap-2.5'>
<div className='flex min-w-0 items-center gap-2.5'>
{/* Template name */}
<h3 className='truncate font-medium font-sans text-card-foreground text-sm leading-tight'>
{title}
</h3>
</div>
{/* Actions */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Approve/Reject buttons for pending templates (super users only) */}
{isSuperUser && status === 'pending' ? (
<>
<button
onClick={handleApprove}
disabled={isApproving}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-green-600 hover:bg-green-700 disabled:opacity-50'
)}
>
{isApproving ? '...' : 'Approve'}
</button>
<button
onClick={handleReject}
disabled={isRejecting}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-colors duration-200',
'bg-red-600 hover:bg-red-700 disabled:opacity-50'
)}
>
{isRejecting ? '...' : 'Reject'}
</button>
</>
) : (
<>
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
isStarLoading && 'opacity-50'
)}
/>
<button
onClick={handleUseClick}
className={cn(
'rounded-[8px] px-3 py-1 font-medium font-sans text-white text-xs transition-[background-color,box-shadow] duration-200',
'bg-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hover-hex)]',
'shadow-[0_0_0_0_var(--brand-primary-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
)}
>
Use
</button>
</>
)}
</div>
</div>
{/* Description */}
<p className='line-clamp-3 break-words font-sans text-muted-foreground text-xs leading-relaxed'>
{description}
</p>
</div>
{/* Bottom section */}
<div className='flex min-w-0 items-center gap-1.5 pt-1.5 font-sans text-muted-foreground text-xs'>
<span className='flex-shrink-0'>by</span>
<span className='min-w-0 truncate'>{author}</span>
<span className='flex-shrink-0'></span>
<User className='h-3 w-3 flex-shrink-0' />
<span className='flex-shrink-0'>{usageCount}</span>
{/* Stars section - hidden on smaller screens when space is constrained */}
<div className='hidden flex-shrink-0 items-center gap-1.5 sm:flex'>
<span></span>
<Star className='h-3 w-3' />
<span>{localStarCount}</span>
</div>
</div>
</div>
{/* Right side - Block Icons */}
<div className='flex w-16 flex-col items-center justify-center gap-2 rounded-r-[8px] border-border border-l bg-secondary p-2'>
{blockTypes.length > 3 ? (
<>
{/* Show first 2 blocks when there are more than 3 */}
{blockTypes.slice(0, 2).map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})}
{/* Show +n block for remaining blocks */}
<div className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px] bg-muted-foreground'
style={{ width: '30px', height: '30px' }}
>
<span className='font-medium text-white text-xs'>+{blockTypes.length - 2}</span>
</div>
</div>
</>
) : (
/* Show all blocks when 3 or fewer */
blockTypes.map((blockType, index) => {
const blockConfig = getBlockConfig(blockType)
if (!blockConfig) return null
return (
<div key={index} className='flex items-center justify-center'>
<div
className='flex flex-shrink-0 items-center justify-center rounded-[8px]'
style={{
backgroundColor: blockConfig.bgColor || 'gray',
width: '30px',
height: '30px',
}}
>
<blockConfig.icon className='h-4 w-4 text-white' />
</div>
</div>
)
})
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,312 @@
'use client'
import { useState } from 'react'
import { ArrowLeft, Search } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
import { NavigationTabs } from '@/app/templates/components/navigation-tabs'
import { TemplateCard, TemplateCardSkeleton } from '@/app/templates/components/template-card'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { CreatorProfileDetails } from '@/types/creator-profile'
const logger = createLogger('TemplatesPage')
export interface Template {
id: string
workflowId: string | null
name: string
details?: {
tagline?: string
about?: string
} | null
creatorId: string | null
creator?: {
id: string
name: string
profileImageUrl?: string | null
details?: CreatorProfileDetails | null
referenceType: 'user' | 'organization'
referenceId: string
} | null
views: number
stars: number
status: 'pending' | 'approved' | 'rejected'
tags: string[]
requiredCredentials: CredentialRequirement[]
state: WorkflowState
createdAt: Date | string
updatedAt: Date | string
isStarred: boolean
isSuperUser?: boolean
}
interface TemplatesProps {
initialTemplates: Template[]
currentUserId: string | null
isSuperUser: boolean
}
export default function Templates({
initialTemplates,
currentUserId,
isSuperUser,
}: TemplatesProps) {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('gallery')
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
const handleTabClick = (tabId: string) => {
setActiveTab(tabId)
}
// Handle star change callback from template card
const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => {
setTemplates((prevTemplates) =>
prevTemplates.map((template) =>
template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template
)
)
}
const matchesSearch = (template: Template) => {
if (!searchQuery) return true
const query = searchQuery.toLowerCase()
return (
template.name.toLowerCase().includes(query) ||
template.details?.tagline?.toLowerCase().includes(query) ||
template.creator?.name?.toLowerCase().includes(query)
)
}
const ownedTemplates = currentUserId
? templates.filter(
(template) =>
template.creator?.referenceType === 'user' &&
template.creator?.referenceId === currentUserId
)
: []
const starredTemplates = currentUserId
? templates.filter(
(template) =>
template.isStarred &&
!(
template.creator?.referenceType === 'user' &&
template.creator?.referenceId === currentUserId
)
)
: []
const filteredOwnedTemplates = ownedTemplates.filter(matchesSearch)
const filteredStarredTemplates = starredTemplates.filter(matchesSearch)
const galleryTemplates = templates
.filter((template) => template.status === 'approved')
.filter(matchesSearch)
const pendingTemplates = templates
.filter((template) => template.status === 'pending')
.filter(matchesSearch)
// Helper function to render template cards
const renderTemplateCard = (template: Template) => (
<TemplateCard
key={template.id}
id={template.id}
title={template.name}
description={template.details?.tagline || ''}
author={template.creator?.name || 'Unknown'}
usageCount={template.views.toString()}
stars={template.stars}
tags={template.tags}
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={!!currentUserId}
/>
)
// Render skeleton cards for loading state
const renderSkeletonCards = () => {
return Array.from({ length: 8 }).map((_, index) => (
<TemplateCardSkeleton key={`skeleton-${index}`} />
))
}
// Calculate counts for tabs
const yourTemplatesCount = ownedTemplates.length + starredTemplates.length
const galleryCount = templates.filter((template) => template.status === 'approved').length
const pendingCount = templates.filter((template) => template.status === 'pending').length
// Build tabs based on user status
const navigationTabs = [
{
id: 'gallery',
label: 'Gallery',
count: galleryCount,
},
...(currentUserId
? [
{
id: 'your',
label: 'Your Templates',
count: yourTemplatesCount,
},
]
: []),
...(isSuperUser
? [
{
id: 'pending',
label: 'Pending',
count: pendingCount,
},
]
: []),
]
// Show tabs if there's more than one tab
const showTabs = navigationTabs.length > 1
const handleBackToWorkspace = async () => {
try {
const response = await fetch('/api/workspaces')
if (response.ok) {
const data = await response.json()
const defaultWorkspace = data.workspaces?.[0]
if (defaultWorkspace) {
router.push(`/workspace/${defaultWorkspace.id}`)
}
}
} catch (error) {
logger.error('Error navigating to workspace:', error)
}
}
return (
<div className='flex h-[100vh] flex-col'>
<div className='flex flex-1 overflow-hidden'>
<div className='flex flex-1 flex-col overflow-auto p-6'>
{/* Header with Back Button */}
<div className='mb-6'>
{currentUserId && (
<Button
variant='ghost'
size='sm'
onClick={handleBackToWorkspace}
className='-ml-2 mb-4 text-muted-foreground hover:text-foreground'
>
<ArrowLeft className='mr-2 h-4 w-4' />
Back to Workspace
</Button>
)}
<h1 className='mb-2 font-sans font-semibold text-3xl text-foreground tracking-[0.01em]'>
Templates
</h1>
<p className='font-[350] font-sans text-muted-foreground text-sm leading-[1.5] tracking-[0.01em]'>
Grab a template and start building, or make
<br />
one from scratch.
</p>
</div>
{/* Search */}
<div className='mb-6 flex items-center justify-between'>
<div className='flex h-9 w-[460px] items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search templates...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
/>
</div>
</div>
{/* Navigation - only show if multiple tabs */}
{showTabs && (
<div className='mb-6'>
<NavigationTabs
tabs={navigationTabs}
activeTab={activeTab}
onTabClick={handleTabClick}
/>
</div>
)}
{loading ? (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{renderSkeletonCards()}
</div>
) : activeTab === 'your' ? (
filteredOwnedTemplates.length === 0 && filteredStarredTemplates.length === 0 ? (
<div className='flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-muted-foreground text-sm'>
{searchQuery ? 'No templates found' : 'No templates yet'}
</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>
{searchQuery
? 'Try a different search term'
: 'Create or star templates to see them here'}
</p>
</div>
</div>
) : (
<div className='space-y-8'>
{filteredOwnedTemplates.length > 0 && (
<section>
<h2 className='mb-3 font-semibold text-lg'>Your Templates</h2>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{filteredOwnedTemplates.map((template) => renderTemplateCard(template))}
</div>
</section>
)}
{filteredStarredTemplates.length > 0 && (
<section>
<h2 className='mb-3 font-semibold text-lg'>Starred Templates</h2>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{filteredStarredTemplates.map((template) => renderTemplateCard(template))}
</div>
</section>
)}
</div>
)
) : (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-muted-foreground text-sm'>
{searchQuery
? 'No templates found'
: activeTab === 'pending'
? 'No pending templates'
: 'No templates available'}
</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>
{searchQuery
? 'Try a different search term'
: activeTab === 'pending'
? 'New submissions will appear here'
: 'Templates will appear once approved'}
</p>
</div>
</div>
) : (
(activeTab === 'gallery' ? galleryTemplates : pendingTemplates).map((template) =>
renderTemplateCard(template)
)
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,11 +1,10 @@
import { db } from '@sim/db'
import { templateStars, templates } from '@sim/db/schema'
import { templateCreators, templateStars, templates } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { notFound } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'
import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template'
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
const logger = createLogger('TemplatePage')
@@ -20,36 +19,19 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId, id } = await params
try {
// Validate the template ID format (basic UUID validation)
if (!id || typeof id !== 'string' || id.length !== 36) {
notFound()
}
const session = await getSession()
if (!session?.user?.id) {
return <div>Please log in to view this template</div>
}
// Fetch template data first without star status to avoid query issues
const templateData = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
userId: templates.userId,
name: templates.name,
description: templates.description,
author: templates.author,
views: templates.views,
stars: templates.stars,
color: templates.color,
icon: templates.icon,
category: templates.category,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
template: templates,
creator: templateCreators,
})
.from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id))
.limit(1)
@@ -57,61 +39,52 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
notFound()
}
const template = templateData[0]
const { template, creator } = templateData[0]
// Validate that required fields are present
if (!template.id || !template.name || !template.author) {
if (!session?.user?.id && template.status !== 'approved') {
notFound()
}
if (!template.id || !template.name) {
logger.error('Template missing required fields:', {
id: template.id,
name: template.name,
author: template.author,
})
notFound()
}
// Check if user has starred this template
let isStarred = false
try {
const starData = await db
.select({ id: templateStars.id })
.from(templateStars)
.where(
and(eq(templateStars.templateId, template.id), eq(templateStars.userId, session.user.id))
)
.limit(1)
isStarred = starData.length > 0
} catch {
// Continue with isStarred = false
if (session?.user?.id) {
try {
const starData = await db
.select({ id: templateStars.id })
.from(templateStars)
.where(
and(
eq(templateStars.templateId, template.id),
eq(templateStars.userId, session.user.id)
)
)
.limit(1)
isStarred = starData.length > 0
} catch {
isStarred = false
}
}
// Ensure proper serialization of the template data with null checks
const serializedTemplate: Template = {
id: template.id,
workflowId: template.workflowId,
userId: template.userId,
name: template.name,
description: template.description,
author: template.author,
views: template.views,
stars: template.stars,
color: template.color || '#3972F6', // Default color if missing
icon: template.icon || 'FileText', // Default icon if missing
category: template.category as any,
state: template.state as any,
createdAt: template.createdAt ? template.createdAt.toISOString() : new Date().toISOString(),
updatedAt: template.updatedAt ? template.updatedAt.toISOString() : new Date().toISOString(),
const serializedTemplate = {
...template,
creator: creator || null,
createdAt: template.createdAt.toISOString(),
updatedAt: template.updatedAt.toISOString(),
isStarred,
}
logger.info('Template from DB:', template)
logger.info('Serialized template:', serializedTemplate)
logger.info('Template state from DB:', template.state)
return (
<TemplateDetails
template={serializedTemplate}
template={JSON.parse(JSON.stringify(serializedTemplate))}
workspaceId={workspaceId}
currentUserId={session.user.id}
currentUserId={session?.user?.id || null}
/>
)
} catch (error) {
@@ -122,6 +95,9 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
<h1 className='mb-4 font-bold text-2xl'>Error Loading Template</h1>
<p className='text-muted-foreground'>There was an error loading this template.</p>
<p className='mt-2 text-muted-foreground text-sm'>Template ID: {id}</p>
<p className='mt-2 text-red-500 text-xs'>
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import {
ArrowLeft,
Award,
@@ -45,12 +45,11 @@ import {
Wrench,
Zap,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
import { categories } from '@/app/workspace/[workspaceId]/templates/templates'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -59,7 +58,7 @@ const logger = createLogger('TemplateDetails')
interface TemplateDetailsProps {
template: Template
workspaceId: string
currentUserId: string
currentUserId: string | null
}
// Icon mapping - reuse from template-card
@@ -110,35 +109,56 @@ const getIconComponent = (icon: string): React.ReactNode => {
return IconComponent ? <IconComponent className='h-6 w-6' /> : <FileText className='h-6 w-6' />
}
// Get category display name
const getCategoryDisplayName = (categoryValue: string): string => {
const category = categories.find((c) => c.value === categoryValue)
return category?.label || categoryValue
}
export default function TemplateDetails({
template,
workspaceId,
currentUserId,
}: TemplateDetailsProps) {
const router = useRouter()
const [isStarred, setIsStarred] = useState(template?.isStarred || false)
const [starCount, setStarCount] = useState(template?.stars || 0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
const searchParams = useSearchParams()
// Defensive check for template after hooks are initialized
// Defensive check for template BEFORE initializing state hooks
if (!template) {
logger.error('Template prop is undefined or null in TemplateDetails component', {
template,
workspaceId,
currentUserId,
})
return (
<div className='flex h-screen items-center justify-center'>
<div className='text-center'>
<h1 className='mb-4 font-bold text-2xl'>Template Not Found</h1>
<p className='text-muted-foreground'>The template you're looking for doesn't exist.</p>
<p className='mt-2 text-muted-foreground text-xs'>Template data failed to load</p>
</div>
</div>
)
}
logger.info('Template loaded in TemplateDetails', {
id: template.id,
name: template.name,
hasState: !!template.state,
})
const [isStarred, setIsStarred] = useState(template.isStarred || false)
const [starCount, setStarCount] = useState(template.stars || 0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const isOwner = currentUserId && template.userId === currentUserId
// Auto-use template after login if use=true query param is present
useEffect(() => {
const shouldAutoUse = searchParams?.get('use') === 'true'
if (shouldAutoUse && currentUserId && !isUsing) {
handleUseTemplate()
// Clean up URL
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
}
}, [searchParams, currentUserId])
// Render workflow preview exactly like deploy-modal.tsx
const renderWorkflowPreview = () => {
// Follow the same pattern as deployed-workflow-card.tsx
@@ -189,7 +209,7 @@ export default function TemplateDetails({
}
const handleStarToggle = async () => {
if (isStarring) return
if (isStarring || !currentUserId) return
setIsStarring(true)
try {
@@ -210,42 +230,78 @@ export default function TemplateDetails({
const handleUseTemplate = async () => {
if (isUsing) return
// Check if user is logged in
if (!currentUserId) {
// Redirect to login with callback URL to use template after login
const callbackUrl = encodeURIComponent(
`/workspace/${workspaceId}/templates/${template.id}?use=true`
)
router.push(`/login?callbackUrl=${callbackUrl}`)
return
}
setIsUsing(true)
try {
// TODO: Implement proper template usage logic
// This should create a new workflow from the template state
// For now, we'll create a basic workflow and navigate to it
logger.info('Using template:', template.id)
// Create a new workflow
const response = await fetch('/api/workflows', {
const response = await fetch(`/api/templates/${template.id}/use`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: `${template.name} (Copy)`,
description: `Created from template: ${template.name}`,
color: template.color,
workspaceId,
folderId: null,
}),
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
throw new Error('Failed to create workflow from template')
throw new Error('Failed to use template')
}
const newWorkflow = await response.json()
const { workflowId } = await response.json()
// Navigate to the new workflow
router.push(`/workspace/${workspaceId}/w/${newWorkflow.id}`)
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
} catch (error) {
logger.error('Error using template:', error)
// Show error to user (could implement toast notification)
} finally {
setIsUsing(false)
}
}
const handleEditTemplate = async () => {
if (isEditing || !currentUserId) return
setIsEditing(true)
try {
// If template already has a connected workflowId, check if it exists in user's workspace
if (template.workflowId) {
// Try to fetch the workflow to see if it still exists
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.ok) {
// Workflow exists, redirect to it
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
return
}
}
// No connected workflow or it was deleted - create a new one
const response = await fetch(`/api/templates/${template.id}/edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspaceId }),
})
if (!response.ok) {
throw new Error('Failed to edit template')
}
const { workflowId } = await response.json()
// Navigate to the workflow
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
} catch (error) {
logger.error('Error editing template:', error)
} finally {
setIsEditing(false)
}
}
return (
<div className='flex min-h-screen flex-col'>
{/* Header */}
@@ -282,20 +338,36 @@ export default function TemplateDetails({
{/* Action buttons */}
<div className='flex items-center gap-3'>
{/* Star button */}
<Button
variant='outline'
size='sm'
onClick={handleStarToggle}
disabled={isStarring}
className={cn(
'transition-colors',
isStarred && 'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100'
)}
>
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} />
{starCount}
</Button>
{/* Star button - only for logged-in users */}
{currentUserId && (
<Button
variant='outline'
size='sm'
onClick={handleStarToggle}
disabled={isStarring}
className={cn(
'transition-colors',
isStarred &&
'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100'
)}
>
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} />
{starCount}
</Button>
)}
{/* Edit button - only for template owner when logged in */}
{isOwner && currentUserId && (
<Button
variant='outline'
onClick={handleEditTemplate}
disabled={isEditing}
className='border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
>
<Edit className='mr-2 h-4 w-4' />
{isEditing ? 'Opening...' : 'Edit'}
</Button>
)}
{/* Use template button */}
<Button
@@ -303,28 +375,23 @@ export default function TemplateDetails({
disabled={isUsing}
className='bg-purple-600 text-white hover:bg-purple-700'
>
Use this template
{isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
</Button>
</div>
</div>
{/* Tags */}
<div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'>
{/* Category */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<span>{getCategoryDisplayName(template.category)}</span>
</div>
{/* Views */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Eye className='h-3 w-3' />
<span>{template.views}</span>
<span>{template.views} views</span>
</div>
{/* Stars */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Star className='h-3 w-3' />
<span>{starCount}</span>
<span>{starCount} stars</span>
</div>
{/* Author */}
@@ -332,6 +399,14 @@ export default function TemplateDetails({
<User className='h-3 w-3' />
<span>by {template.author}</span>
</div>
{/* Author Type - show if organization */}
{template.authorType === 'organization' && (
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Users className='h-3 w-3' />
<span>Organization</span>
</div>
)}
</div>
</div>
</div>

View File

@@ -130,6 +130,8 @@ interface TemplateCardProps {
onTemplateUsed?: () => void
// Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// User authentication status
isAuthenticated?: boolean
}
// Skeleton component for loading states
@@ -249,6 +251,7 @@ export function TemplateCard({
isStarred = false,
onTemplateUsed,
onStarChange,
isAuthenticated = true,
}: TemplateCardProps) {
const router = useRouter()
const params = useParams()
@@ -320,52 +323,27 @@ export function TemplateCard({
}
}
// Handle use template
// Handle use click - just navigate to detail page
const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
const response = await fetch(`/api/templates/${id}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: params.workspaceId,
}),
})
router.push(`/templates/${id}`)
}
if (response.ok) {
const data = await response.json()
logger.info('Template use API response:', data)
if (!data.workflowId) {
logger.error('No workflowId returned from API:', data)
return
}
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
logger.info('Template used successfully, navigating to:', workflowUrl)
// Call the callback if provided (for closing modals, etc.)
if (onTemplateUsed) {
onTemplateUsed()
}
// Use window.location.href for more reliable navigation
window.location.href = workflowUrl
} else {
const errorText = await response.text()
logger.error('Failed to use template:', response.statusText, errorText)
}
} catch (error) {
logger.error('Error using template:', error)
const handleCardClick = (e: React.MouseEvent) => {
// Don't navigate if clicking on action buttons
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
return
}
router.push(`/templates/${id}`)
}
return (
<div
onClick={handleCardClick}
className={cn(
'group rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
'flex h-[142px]',
className
)}
@@ -396,18 +374,21 @@ export function TemplateCard({
</h3>
</div>
{/* Star and Use button */}
<div className='flex flex-shrink-0 items-center gap-3'>
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
isStarLoading && 'opacity-50'
)}
/>
{/* Actions */}
<div className='flex flex-shrink-0 items-center gap-2'>
{/* Star button - only for authenticated users */}
{isAuthenticated && (
<Star
onClick={handleStarClick}
className={cn(
'h-4 w-4 cursor-pointer transition-colors duration-50',
localIsStarred
? 'fill-yellow-400 text-yellow-400'
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
isStarLoading && 'opacity-50'
)}
/>
)}
<button
onClick={handleUseClick}
className={cn(

View File

@@ -1,47 +1,6 @@
import { db } from '@sim/db'
import { templateStars, templates } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
import { redirect } from 'next/navigation'
export default async function TemplatesPage() {
const session = await getSession()
if (!session?.user?.id) {
return <div>Please log in to view templates</div>
}
// Fetch templates server-side with all necessary data
const templatesData = await db
.select({
id: templates.id,
workflowId: templates.workflowId,
userId: templates.userId,
name: templates.name,
description: templates.description,
author: templates.author,
views: templates.views,
stars: templates.stars,
color: templates.color,
icon: templates.icon,
category: templates.category,
state: templates.state,
createdAt: templates.createdAt,
updatedAt: templates.updatedAt,
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
})
.from(templates)
.leftJoin(
templateStars,
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
)
.orderBy(desc(templates.views), desc(templates.createdAt))
return (
<Templates
initialTemplates={templatesData as unknown as Template[]}
currentUserId={session.user.id}
/>
)
// Redirect all users to the root templates page
redirect('/templates')
}

View File

@@ -1,8 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronRight, Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useState } from 'react'
import { Search } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger'
import { NavigationTabs } from '@/app/workspace/[workspaceId]/templates/components/navigation-tabs'
@@ -14,18 +13,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplatesPage')
// Shared categories definition
export const categories = [
{ value: 'marketing', label: 'Marketing' },
{ value: 'sales', label: 'Sales' },
{ value: 'finance', label: 'Finance' },
{ value: 'support', label: 'Support' },
{ value: 'artificial-intelligence', label: 'Artificial Intelligence' },
{ value: 'other', label: 'Other' },
] as const
export type CategoryValue = (typeof categories)[number]['value']
// Template data structure
export interface Template {
id: string
@@ -34,68 +21,38 @@ export interface Template {
name: string
description: string | null
author: string
authorType: 'user' | 'organization'
organizationId: string | null
views: number
stars: number
color: string
icon: string
category: CategoryValue
status: 'pending' | 'approved' | 'rejected'
state: WorkflowState
createdAt: Date | string
updatedAt: Date | string
isStarred: boolean
isSuperUser?: boolean
}
interface TemplatesProps {
initialTemplates: Template[]
currentUserId: string
isSuperUser: boolean
}
export default function Templates({ initialTemplates, currentUserId }: TemplatesProps) {
const router = useRouter()
const params = useParams()
export default function Templates({
initialTemplates,
currentUserId,
isSuperUser,
}: TemplatesProps) {
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('your')
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false)
// Refs for scrolling to sections
const sectionRefs = {
your: useRef<HTMLDivElement>(null),
recent: useRef<HTMLDivElement>(null),
marketing: useRef<HTMLDivElement>(null),
sales: useRef<HTMLDivElement>(null),
finance: useRef<HTMLDivElement>(null),
support: useRef<HTMLDivElement>(null),
'artificial-intelligence': useRef<HTMLDivElement>(null),
other: useRef<HTMLDivElement>(null),
}
// Get your templates count (created by user OR starred by user)
const yourTemplatesCount = templates.filter(
(template) => template.userId === currentUserId || template.isStarred === true
).length
// Handle case where active tab is "your" but user has no templates
useEffect(() => {
if (!loading && activeTab === 'your' && yourTemplatesCount === 0) {
setActiveTab('recent') // Switch to recent tab
}
}, [loading, activeTab, yourTemplatesCount])
const handleTabClick = (tabId: string) => {
setActiveTab(tabId)
const sectionRef = sectionRefs[tabId as keyof typeof sectionRefs]
if (sectionRef.current) {
sectionRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
}
const handleCreateNew = () => {
// TODO: Open create template modal or navigate to create page
logger.info('Create new template')
}
// Handle star change callback from template card
@@ -107,34 +64,39 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
)
}
const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => {
let filteredByCategory = templates
// Get templates for the active tab with search filtering
const getActiveTabTemplates = () => {
let filtered = templates
if (category === 'your') {
// For "your" templates, show templates created by you OR starred by you
filteredByCategory = templates.filter(
// Filter by active tab
if (activeTab === 'your') {
filtered = filtered.filter(
(template) => template.userId === currentUserId || template.isStarred === true
)
} else if (category === 'recent') {
// For "recent" templates, show the 8 most recent templates
filteredByCategory = templates
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 8)
} else {
filteredByCategory = templates.filter((template) => template.category === category)
} else if (activeTab === 'gallery') {
// Show all approved templates
filtered = filtered.filter((template) => template.status === 'approved')
} else if (activeTab === 'pending') {
// Show pending templates for super users
filtered = filtered.filter((template) => template.status === 'pending')
}
if (!searchQuery) return filteredByCategory
// Apply search filter
if (searchQuery) {
filtered = filtered.filter(
(template) =>
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
)
}
return filteredByCategory.filter(
(template) =>
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
)
return filtered
}
// Helper function to render template cards with proper type handling
const activeTemplates = getActiveTabTemplates()
// Helper function to render template cards
const renderTemplateCard = (template: Template) => (
<TemplateCard
key={template.id}
@@ -149,14 +111,10 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
isStarred={template.isStarred}
onStarChange={handleStarChange}
isAuthenticated={true}
/>
)
// Group templates by category for display
const getTemplatesByCategory = (category: CategoryValue | 'your' | 'recent') => {
return filteredTemplates(category)
}
// Render skeleton cards for loading state
const renderSkeletonCards = () => {
return Array.from({ length: 8 }).map((_, index) => (
@@ -164,45 +122,33 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
))
}
// Calculate navigation tabs with real counts or skeleton counts
// Calculate counts for tabs
const yourTemplatesCount = templates.filter(
(template) => template.userId === currentUserId || template.isStarred === true
).length
const galleryCount = templates.filter((template) => template.status === 'approved').length
const pendingCount = templates.filter((template) => template.status === 'pending').length
const navigationTabs = [
// Only include "Your templates" tab if user has created or starred templates
...(yourTemplatesCount > 0 || loading
{
id: 'gallery',
label: 'Gallery',
count: galleryCount,
},
{
id: 'your',
label: 'Your Templates',
count: yourTemplatesCount,
},
...(isSuperUser
? [
{
id: 'your',
label: 'Your templates',
count: loading ? 8 : getTemplatesByCategory('your').length,
id: 'pending',
label: 'Pending',
count: pendingCount,
},
]
: []),
{
id: 'recent',
label: 'Recent',
count: loading ? 8 : getTemplatesByCategory('recent').length,
},
{
id: 'marketing',
label: 'Marketing',
count: loading ? 8 : getTemplatesByCategory('marketing').length,
},
{ id: 'sales', label: 'Sales', count: loading ? 8 : getTemplatesByCategory('sales').length },
{
id: 'finance',
label: 'Finance',
count: loading ? 8 : getTemplatesByCategory('finance').length,
},
{
id: 'support',
label: 'Support',
count: loading ? 8 : getTemplatesByCategory('support').length,
},
{
id: 'artificial-intelligence',
label: 'Artificial Intelligence',
count: loading ? 8 : getTemplatesByCategory('artificial-intelligence').length,
},
{ id: 'other', label: 'Other', count: loading ? 8 : getTemplatesByCategory('other').length },
]
return (
@@ -250,124 +196,36 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
/>
</div>
{/* Your Templates Section */}
{yourTemplatesCount > 0 || loading ? (
<div ref={sectionRefs.your} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Your templates</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
{/* Templates Grid - Based on Active Tab */}
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading ? (
renderSkeletonCards()
) : activeTemplates.length === 0 ? (
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
<div className='text-center'>
<p className='font-medium text-muted-foreground text-sm'>
{searchQuery
? 'No templates found'
: activeTab === 'pending'
? 'No pending templates'
: activeTab === 'your'
? 'No templates yet'
: 'No templates available'}
</p>
<p className='mt-1 text-muted-foreground/70 text-xs'>
{searchQuery
? 'Try a different search term'
: activeTab === 'pending'
? 'New submissions will appear here'
: activeTab === 'your'
? 'Create or star templates to see them here'
: 'Templates will appear once approved'}
</p>
</div>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('your').map((template) => renderTemplateCard(template))}
</div>
</div>
) : null}
{/* Recent Templates Section */}
<div ref={sectionRefs.recent} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Recent</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('recent').map((template) => renderTemplateCard(template))}
</div>
</div>
{/* Marketing Section */}
<div ref={sectionRefs.marketing} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Marketing</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('marketing').map((template) =>
renderTemplateCard(template)
)}
</div>
</div>
{/* Sales Section */}
<div ref={sectionRefs.sales} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Sales</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('sales').map((template) => renderTemplateCard(template))}
</div>
</div>
{/* Finance Section */}
<div ref={sectionRefs.finance} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Finance</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('finance').map((template) => renderTemplateCard(template))}
</div>
</div>
{/* Support Section */}
<div ref={sectionRefs.support} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Support</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('support').map((template) => renderTemplateCard(template))}
</div>
</div>
{/* Artificial Intelligence Section */}
<div ref={sectionRefs['artificial-intelligence']} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>
Artificial Intelligence
</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('artificial-intelligence').map((template) =>
renderTemplateCard(template)
)}
</div>
</div>
{/* Other Section */}
<div ref={sectionRefs.other} className='mb-8'>
<div className='mb-4 flex items-center gap-2'>
<h2 className='font-medium font-sans text-foreground text-lg'>Other</h2>
<ChevronRight className='h-4 w-4 text-muted-foreground' />
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{loading
? renderSkeletonCards()
: getTemplatesByCategory('other').map((template) => renderTemplateCard(template))}
</div>
) : (
activeTemplates.map((template) => renderTemplateCard(template))
)}
</div>
</div>
</div>

View File

@@ -1,3 +1,4 @@
export { ChatDeploy } from './chat-deploy/chat-deploy'
export { DeploymentInfo } from './deployment-info/deployment-info'
export { ImageSelector } from './image-selector/image-selector'
export { TemplateDeploy } from './template-deploy/template-deploy'

View File

@@ -0,0 +1,509 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { CheckCircle2, Loader2, Plus, Trash2 } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@/components/ui'
import { TagInput } from '@/components/ui/tag-input'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDeploy')
const templateSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Max 100 characters'),
tagline: z.string().max(500, 'Max 500 characters').optional(),
about: z.string().optional(), // Markdown long description
creatorId: z.string().optional(), // Creator profile ID
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
})
type TemplateFormData = z.infer<typeof templateSchema>
interface CreatorOption {
id: string
name: string
referenceType: 'user' | 'organization'
referenceId: string
}
interface TemplateDeployProps {
workflowId: string
onDeploymentComplete?: () => void
}
export function TemplateDeploy({ workflowId, onDeploymentComplete }: TemplateDeployProps) {
const { data: session } = useSession()
const [isSubmitting, setIsSubmitting] = useState(false)
const [existingTemplate, setExistingTemplate] = useState<any>(null)
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [creatorOptions, setCreatorOptions] = useState<CreatorOption[]>([])
const [loadingCreators, setLoadingCreators] = useState(false)
const [showPreviewDialog, setShowPreviewDialog] = useState(false)
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
defaultValues: {
name: '',
tagline: '',
about: '',
creatorId: undefined,
tags: [],
},
})
// Fetch creator profiles
useEffect(() => {
const fetchCreatorOptions = async () => {
if (!session?.user?.id) return
setLoadingCreators(true)
try {
const response = await fetch('/api/creator-profiles')
if (response.ok) {
const data = await response.json()
const profiles = (data.profiles || []).map((profile: any) => ({
id: profile.id,
name: profile.name,
referenceType: profile.referenceType,
referenceId: profile.referenceId,
}))
setCreatorOptions(profiles)
}
} catch (error) {
logger.error('Error fetching creator profiles:', error)
} finally {
setLoadingCreators(false)
}
}
fetchCreatorOptions()
}, [session?.user?.id])
// Check for existing template
useEffect(() => {
const checkExistingTemplate = async () => {
setIsLoadingTemplate(true)
try {
const response = await fetch(`/api/templates?workflowId=${workflowId}&limit=1`)
if (response.ok) {
const result = await response.json()
const template = result.data?.[0] || null
setExistingTemplate(template)
if (template) {
// Map old template format to new format if needed
const tagline = (template.details as any)?.tagline || template.description || ''
const about = (template.details as any)?.about || ''
form.reset({
name: template.name,
tagline: tagline,
about: about,
creatorId: template.creatorId || undefined,
tags: template.tags || [],
})
}
}
} catch (error) {
logger.error('Error checking existing template:', error)
setExistingTemplate(null)
} finally {
setIsLoadingTemplate(false)
}
}
checkExistingTemplate()
}, [workflowId, session?.user?.id])
const onSubmit = async (data: TemplateFormData) => {
if (!session?.user) {
logger.error('User not authenticated')
return
}
setIsSubmitting(true)
try {
// Build template data with new schema
const templateData: any = {
name: data.name,
details: {
tagline: data.tagline || '',
about: data.about || '',
},
creatorId: data.creatorId || null,
tags: data.tags || [],
}
let response
if (existingTemplate) {
// Update template metadata AND state from current workflow
response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...templateData,
updateState: true, // Update state from current workflow
}),
})
} else {
// Create new template with workflowId
response = await fetch('/api/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...templateData, workflowId }),
})
}
if (!response.ok) {
const errorData = await response.json()
throw new Error(
errorData.error || `Failed to ${existingTemplate ? 'update' : 'create'} template`
)
}
const result = await response.json()
logger.info(`Template ${existingTemplate ? 'updated' : 'created'} successfully:`, result)
// Update existing template state
setExistingTemplate(result.data || result)
onDeploymentComplete?.()
} catch (error) {
logger.error('Failed to save template:', error)
} finally {
setIsSubmitting(false)
}
}
const handleDelete = async () => {
if (!existingTemplate) return
setIsDeleting(true)
try {
const response = await fetch(`/api/templates/${existingTemplate.id}`, {
method: 'DELETE',
})
if (response.ok) {
setExistingTemplate(null)
setShowDeleteDialog(false)
form.reset({
name: '',
tagline: '',
about: '',
creatorId: undefined,
tags: [],
})
}
} catch (error) {
logger.error('Error deleting template:', error)
} finally {
setIsDeleting(false)
}
}
if (isLoadingTemplate) {
return (
<div className='flex h-64 items-center justify-center'>
<Loader2 className='h-6 w-6 animate-spin text-muted-foreground' />
</div>
)
}
return (
<div className='space-y-4'>
{existingTemplate && (
<div className='flex items-center justify-between rounded-lg border border-border/50 bg-muted/30 px-4 py-3'>
<div className='flex items-center gap-3'>
<CheckCircle2 className='h-4 w-4 text-green-600 dark:text-green-400' />
<div className='flex items-center gap-2'>
<span className='font-medium text-sm'>Template Connected</span>
{existingTemplate.status === 'pending' && (
<span className='rounded-md bg-yellow-100 px-2 py-0.5 font-medium text-xs text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'>
Under Review
</span>
)}
{existingTemplate.status === 'approved' && existingTemplate.views > 0 && (
<span className='text-muted-foreground text-xs'>
{existingTemplate.views} views
{existingTemplate.stars > 0 && `${existingTemplate.stars} stars`}
</span>
)}
</div>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setShowDeleteDialog(true)}
className='h-8 px-2 text-muted-foreground hover:text-red-600 dark:hover:text-red-400'
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Template Name</FormLabel>
<FormControl>
<Input placeholder='My Awesome Template' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tagline'
render={({ field }) => (
<FormItem>
<FormLabel>Tagline</FormLabel>
<FormControl>
<Input placeholder='Brief description of what this template does' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='about'
render={({ field }) => (
<FormItem>
<FormLabel>About (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder='Detailed description (supports Markdown)'
className='min-h-[150px] resize-none'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='creatorId'
render={({ field }) => (
<FormItem>
<FormLabel>Creator Profile</FormLabel>
{creatorOptions.length === 0 && !loadingCreators ? (
<div className='flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
className='gap-2'
onClick={() => {
// Open settings modal to creator profile tab
const settingsButton = document.querySelector(
'[data-settings-button]'
) as HTMLButtonElement
if (settingsButton) {
settingsButton.click()
setTimeout(() => {
const creatorProfileTab = document.querySelector(
'[data-section="creator-profile"]'
) as HTMLButtonElement
if (creatorProfileTab) {
creatorProfileTab.click()
}
}, 100)
}
}}
>
<Plus className='h-4 w-4 text-muted-foreground' />
<span className='text-muted-foreground'>Create a Creator Profile</span>
</Button>
</div>
) : (
<Select
onValueChange={field.onChange}
value={field.value}
disabled={loadingCreators}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={loadingCreators ? 'Loading...' : 'Select creator profile'}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{creatorOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='tags'
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<TagInput
value={field.value || []}
onChange={field.onChange}
placeholder='Type and press Enter to add tags'
maxTags={10}
disabled={isSubmitting}
/>
</FormControl>
<p className='text-muted-foreground text-xs'>
Add up to 10 tags to help users discover your template
</p>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end gap-2 border-t pt-4'>
{existingTemplate && (
<Button
type='button'
variant='outline'
onClick={() => setShowPreviewDialog(true)}
disabled={!existingTemplate?.state}
>
View Current
</Button>
)}
<Button
type='submit'
disabled={isSubmitting || !form.formState.isValid}
className='bg-purple-600 hover:bg-purple-700'
>
{isSubmitting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{existingTemplate ? 'Updating...' : 'Publishing...'}
</>
) : existingTemplate ? (
'Update Template'
) : (
'Publish Template'
)}
</Button>
</div>
</form>
</Form>
{showDeleteDialog && (
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
<div className='w-full max-w-md rounded-lg bg-background p-6 shadow-lg'>
<h3 className='mb-4 font-semibold text-lg'>Delete Template?</h3>
<p className='mb-6 text-muted-foreground text-sm'>
This will permanently delete your template. This action cannot be undone.
</p>
<div className='flex justify-end gap-2'>
<Button variant='outline' onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
onClick={handleDelete}
disabled={isDeleting}
className='bg-red-600 hover:bg-red-700'
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
</div>
)}
{/* Template State Preview Dialog */}
{showPreviewDialog && (
<Dialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog}>
<DialogContent className='max-h-[80vh] max-w-5xl overflow-auto'>
<DialogHeader>
<DialogTitle>Template State Preview</DialogTitle>
</DialogHeader>
<div className='mt-4'>
{(() => {
if (!existingTemplate?.state || !existingTemplate.state.blocks) {
return (
<div className='flex flex-col items-center gap-4 py-8'>
<div className='text-center text-muted-foreground'>
<p className='mb-2'>No template state available yet.</p>
<p className='text-sm'>
Click "Update Template" to capture the current workflow state.
</p>
</div>
</div>
)
}
// Ensure the state has the right structure
const workflowState: WorkflowState = {
blocks: existingTemplate.state.blocks || {},
edges: existingTemplate.state.edges || [],
loops: existingTemplate.state.loops || {},
parallels: existingTemplate.state.parallels || {},
lastSaved: existingTemplate.state.lastSaved || Date.now(),
}
return (
<div className='h-[500px] w-full'>
<WorkflowPreview
key={`template-preview-${existingTemplate.id}-${Date.now()}`}
workflowState={workflowState}
showSubBlocks={true}
height='100%'
width='100%'
/>
</div>
)
})()}
</div>
</DialogContent>
</Dialog>
)}
</div>
)
}

View File

@@ -18,7 +18,10 @@ import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers'
import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
import {
DeploymentInfo,
TemplateDeploy,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
import { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -47,7 +50,7 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean
}
type TabView = 'api' | 'versions' | 'chat'
type TabView = 'api' | 'versions' | 'chat' | 'template'
export function DeployModal({
open,
@@ -369,6 +372,9 @@ export function DeployModal({
setVersionToActivate(null)
setApiDeployError(null)
// Templates connected to this workflow are automatically updated with the new state
// The deployWorkflow function handles updating template states in db-helpers.ts
} catch (error: unknown) {
logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
@@ -687,6 +693,16 @@ export function DeployModal({
>
Versions
</button>
<button
onClick={() => setActiveTab('template')}
className={`rounded-md px-3 py-1 text-sm transition-colors ${
activeTab === 'template'
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`}
>
Template
</button>
</div>
</div>
@@ -937,6 +953,10 @@ export function DeployModal({
onVersionActivated={() => setVersionToActivate(null)}
/>
)}
{activeTab === 'template' && workflowId && (
<TemplateDeploy workflowId={workflowId} onDeploymentComplete={handleCloseModal} />
)}
</div>
</div>
</div>

View File

@@ -85,7 +85,6 @@ import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
import { categories } from '@/app/workspace/[workspaceId]/templates/templates'
const logger = createLogger('TemplateModal')
@@ -99,13 +98,19 @@ const templateSchema = z.object({
.string()
.min(1, 'Author is required')
.max(100, 'Author must be less than 100 characters'),
category: z.string().min(1, 'Category is required'),
authorType: z.enum(['user', 'organization']).default('user'),
organizationId: z.string().optional(),
icon: z.string().min(1, 'Icon is required'),
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
})
type TemplateFormData = z.infer<typeof templateSchema>
interface Organization {
id: string
name: string
}
interface TemplateModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -180,6 +185,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [organizations, setOrganizations] = useState<Organization[]>([])
const [loadingOrgs, setLoadingOrgs] = useState(false)
const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema),
@@ -187,7 +194,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
category: '',
authorType: 'user',
organizationId: undefined,
icon: 'FileText',
color: '#3972F6',
},
@@ -195,12 +203,37 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
// Watch form state to determine if all required fields are valid
const formValues = form.watch()
const authorType = form.watch('authorType')
const isFormValid =
form.formState.isValid &&
formValues.name?.trim() &&
formValues.description?.trim() &&
formValues.author?.trim() &&
formValues.category
formValues.author?.trim()
// Fetch user's organizations when modal opens
useEffect(() => {
const fetchOrganizations = async () => {
if (!open || !session?.user?.id) return
setLoadingOrgs(true)
try {
const response = await fetch('/api/organizations')
if (response.ok) {
const data = await response.json()
setOrganizations(data.organizations || [])
}
} catch (error) {
logger.error('Error fetching organizations:', error)
setOrganizations([])
} finally {
setLoadingOrgs(false)
}
}
if (open) {
fetchOrganizations()
}
}, [open, session?.user?.id])
// Check for existing template when modal opens
useEffect(() => {
@@ -224,7 +257,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
name: template.name,
description: template.description,
author: template.author,
category: template.category,
authorType: template.authorType || 'user',
organizationId: template.organizationId || undefined,
icon: template.icon,
color: template.color,
})
@@ -236,7 +270,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
name: '',
description: '',
author: session?.user?.name || session?.user?.email || '',
category: '',
authorType: 'user',
organizationId: undefined,
icon: 'FileText',
color: '#3972F6',
})
@@ -267,7 +302,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
name: data.name,
description: data.description || '',
author: data.author,
category: data.category,
authorType: data.authorType,
organizationId: data.organizationId,
icon: data.icon,
color: data.color,
state: templateState,
@@ -400,14 +436,14 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
{/* Author and Category row */}
{/* Author and Author Type row */}
<div className='grid grid-cols-2 gap-4'>
<div>
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */}
</div>
<div>
<Skeleton className='mb-2 h-4 w-16' /> {/* Label */}
<Skeleton className='mb-2 h-4 w-24' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Select */}
</div>
</div>
@@ -535,24 +571,30 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
<FormField
control={form.control}
name='category'
name='authorType'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Category
Author Type
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select
onValueChange={(value) => {
field.onChange(value)
// Reset org selection when switching to user
if (value === 'user') {
form.setValue('organizationId', undefined)
}
}}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className='h-10 rounded-[8px]'>
<SelectValue placeholder='Select a category' />
<SelectValue placeholder='Select author type' />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
<SelectItem value='user'>User</SelectItem>
<SelectItem value='organization'>Organization</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -561,6 +603,46 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
/>
</div>
{/* Organization selector - only show when authorType is 'organization' */}
{authorType === 'organization' && (
<FormField
control={form.control}
name='organizationId'
render={({ field }) => (
<FormItem>
<FormLabel className='!text-foreground font-medium text-sm'>
Organization
</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className='h-10 rounded-[8px]'>
<SelectValue placeholder='Select an organization' />
</SelectTrigger>
</FormControl>
<SelectContent>
{loadingOrgs ? (
<SelectItem value='loading' disabled>
Loading organizations...
</SelectItem>
) : organizations.length === 0 ? (
<SelectItem value='none' disabled>
No organizations available
</SelectItem>
) : (
organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='description'

View File

@@ -10,7 +10,6 @@ import {
RefreshCw,
SkipForward,
StepForward,
Store,
Trash2,
Webhook,
WifiOff,
@@ -39,7 +38,6 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import {
DeploymentControls,
ExportControls,
TemplateModal,
WebhookSettings,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
@@ -111,7 +109,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
const [mounted, setMounted] = useState(false)
const [, forceUpdate] = useState({})
const [isExpanded, setIsExpanded] = useState(false)
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
const [isWebhookSettingsOpen, setIsWebhookSettingsOpen] = useState(false)
const [isAutoLayouting, setIsAutoLayouting] = useState(false)
@@ -646,24 +643,28 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{deleteState.showTemplateChoice ? 'Published Templates Found' : 'Delete workflow?'}
{deleteState.showTemplateChoice ? 'Template Connected' : 'Delete workflow?'}
</AlertDialogTitle>
{deleteState.showTemplateChoice ? (
<div className='space-y-3'>
<AlertDialogDescription>
This workflow has {deleteState.publishedTemplates.length} published template
{deleteState.publishedTemplates.length > 1 ? 's' : ''}:
</AlertDialogDescription>
{deleteState.publishedTemplates.length > 0 && (
<ul className='list-disc space-y-1 pl-6'>
{deleteState.publishedTemplates.map((template) => (
<li key={template.id}>{template.name}</li>
))}
</ul>
)}
<AlertDialogDescription>
What would you like to do with the published template
{deleteState.publishedTemplates.length > 1 ? 's' : ''}?
<AlertDialogDescription asChild>
<div className='space-y-2'>
<div>
This workflow is connected to a template:{' '}
<strong>{deleteState.publishedTemplates[0]?.name}</strong>
</div>
<div className='mt-3'>What would you like to do with it?</div>
<div className='mt-2 space-y-1 text-xs'>
<div className='text-muted-foreground'>
<strong>Keep template:</strong> Template remains in the marketplace. You can
reconnect it later by clicking "Edit" on the template.
</div>
<div className='text-muted-foreground'>
<strong>Delete template:</strong> Permanently remove template from the
marketplace.
</div>
</div>
</div>
</AlertDialogDescription>
</div>
) : (
@@ -685,14 +686,14 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
disabled={deleteState.isDeleting}
className='h-9 flex-1 rounded-[8px]'
>
Keep templates
Keep template
</Button>
<Button
onClick={() => handleTemplateAction('delete')}
disabled={deleteState.isDeleting}
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
>
{deleteState.isDeleting ? 'Deleting...' : 'Delete templates'}
{deleteState.isDeleting ? 'Deleting...' : 'Delete template'}
</Button>
</div>
) : (
@@ -983,43 +984,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
)
}
/**
* Render publish template button
*/
const renderPublishButton = () => {
const canEdit = userPermissions.canEdit
const isDisabled = isExecuting || isDebugging || !canEdit
const getTooltipText = () => {
if (!canEdit) return 'Admin permission required to publish templates'
if (isDebugging) return 'Cannot publish template while debugging'
if (isExecuting) return 'Cannot publish template while workflow is running'
return 'Publish as template'
}
return (
<Tooltip>
<TooltipTrigger asChild>
{isDisabled ? (
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
<Store className='h-4 w-4' />
</div>
) : (
<Button
variant='outline'
onClick={() => setIsTemplateModalOpen(true)}
className='h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs hover:bg-secondary'
>
<Store className='h-5 w-5' />
<span className='sr-only'>Publish Template</span>
</Button>
)}
</TooltipTrigger>
<TooltipContent>{getTooltipText()}</TooltipContent>
</Tooltip>
)
}
/**
* Render debug mode toggle button
*/
@@ -1273,22 +1237,12 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{isExpanded && renderWebhookButton()}
{isExpanded && <ExportControls />}
{isExpanded && renderAutoLayoutButton()}
{isExpanded && renderPublishButton()}
{renderDeleteButton()}
{renderDuplicateButton()}
{!isDebugging && renderDebugModeToggle()}
{renderDeployButton()}
{isDebugging ? renderDebugControlsBar() : renderRunButton()}
{/* Template Modal */}
{activeWorkflowId && (
<TemplateModal
open={isTemplateModalOpen}
onOpenChange={setIsTemplateModalOpen}
workflowId={activeWorkflowId}
/>
)}
{/* Webhook Settings */}
{activeWorkflowId && (
<WebhookSettings

View File

@@ -26,6 +26,7 @@ export const NavigationItem = ({ item }: NavigationItemProps) => {
<Button
variant='outline'
onClick={item.onClick}
data-settings-button={item.id === 'settings' ? '' : undefined}
className={cn(
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
isGrayHover && 'hover:bg-secondary',

View File

@@ -0,0 +1,526 @@
'use client'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { Camera, Check, Globe, Linkedin, Mail, Save, Twitter, User, Users } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { AgentIcon } from '@/components/icons'
import {
Button,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { CreatorProfileDetails } from '@/types/creator-profile'
import { useProfilePictureUpload } from '../account/hooks/use-profile-picture-upload'
const logger = createLogger('CreatorProfile')
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
const creatorProfileSchema = z.object({
referenceType: z.enum(['user', 'organization']),
referenceId: z.string().min(1, 'Reference is required'),
name: z.string().min(1, 'Display Name is required').max(100, 'Max 100 characters'),
profileImageUrl: z.string().min(1, 'Profile Picture is required'),
about: z.string().max(2000, 'Max 2000 characters').optional(),
xUrl: z.string().url().optional().or(z.literal('')),
linkedinUrl: z.string().url().optional().or(z.literal('')),
websiteUrl: z.string().url().optional().or(z.literal('')),
contactEmail: z.string().email().optional().or(z.literal('')),
})
type CreatorProfileFormData = z.infer<typeof creatorProfileSchema>
interface Organization {
id: string
name: string
role: string
}
export function CreatorProfile() {
const { data: session } = useSession()
const [loading, setLoading] = useState(false)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const [organizations, setOrganizations] = useState<Organization[]>([])
const [existingProfile, setExistingProfile] = useState<any>(null)
const [uploadError, setUploadError] = useState<string | null>(null)
const form = useForm<CreatorProfileFormData>({
resolver: zodResolver(creatorProfileSchema),
defaultValues: {
referenceType: 'user',
referenceId: session?.user?.id || '',
name: session?.user?.name || session?.user?.email || '',
profileImageUrl: '',
about: '',
xUrl: '',
linkedinUrl: '',
websiteUrl: '',
contactEmail: '',
},
})
const profileImageUrl = form.watch('profileImageUrl')
const {
previewUrl: profilePictureUrl,
fileInputRef: profilePictureInputRef,
handleThumbnailClick: handleProfilePictureClick,
handleFileChange: handleProfilePictureChange,
isUploading: isUploadingProfilePicture,
} = useProfilePictureUpload({
currentImage: profileImageUrl,
onUpload: async (url) => {
form.setValue('profileImageUrl', url || '')
setUploadError(null)
},
onError: (error) => {
setUploadError(error)
setTimeout(() => setUploadError(null), 5000)
},
})
const referenceType = form.watch('referenceType')
// Fetch organizations
useEffect(() => {
const fetchOrganizations = async () => {
if (!session?.user?.id) return
try {
const response = await fetch('/api/organizations')
if (response.ok) {
const data = await response.json()
const orgs = (data.organizations || []).filter(
(org: any) => org.role === 'owner' || org.role === 'admin'
)
setOrganizations(orgs)
}
} catch (error) {
logger.error('Error fetching organizations:', error)
}
}
fetchOrganizations()
}, [session?.user?.id])
// Load existing profile
useEffect(() => {
const loadProfile = async () => {
if (!session?.user?.id) return
setLoading(true)
try {
const response = await fetch(`/api/creator-profiles?userId=${session.user.id}`)
if (response.ok) {
const data = await response.json()
if (data.profiles && data.profiles.length > 0) {
const profile = data.profiles[0]
const details = profile.details as CreatorProfileDetails | null
setExistingProfile(profile)
form.reset({
referenceType: profile.referenceType,
referenceId: profile.referenceId,
name: profile.name || '',
profileImageUrl: profile.profileImageUrl || '',
about: details?.about || '',
xUrl: details?.xUrl || '',
linkedinUrl: details?.linkedinUrl || '',
websiteUrl: details?.websiteUrl || '',
contactEmail: details?.contactEmail || '',
})
}
}
} catch (error) {
logger.error('Error loading profile:', error)
} finally {
setLoading(false)
}
}
loadProfile()
}, [session?.user?.id, form])
const onSubmit = async (data: CreatorProfileFormData) => {
if (!session?.user?.id) return
setSaveStatus('saving')
try {
const details: CreatorProfileDetails = {}
if (data.about) details.about = data.about
if (data.xUrl) details.xUrl = data.xUrl
if (data.linkedinUrl) details.linkedinUrl = data.linkedinUrl
if (data.websiteUrl) details.websiteUrl = data.websiteUrl
if (data.contactEmail) details.contactEmail = data.contactEmail
const payload = {
referenceType: data.referenceType,
referenceId: data.referenceId,
name: data.name,
profileImageUrl: data.profileImageUrl,
details: Object.keys(details).length > 0 ? details : undefined,
}
const url = existingProfile
? `/api/creator-profiles/${existingProfile.id}`
: '/api/creator-profiles'
const method = existingProfile ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (response.ok) {
const result = await response.json()
setExistingProfile(result.data)
logger.info('Creator profile saved successfully')
setSaveStatus('saved')
// Reset to idle after 2 seconds
setTimeout(() => {
setSaveStatus('idle')
}, 2000)
} else {
logger.error('Failed to save creator profile')
setSaveStatus('error')
setTimeout(() => {
setSaveStatus('idle')
}, 3000)
}
} catch (error) {
logger.error('Error saving creator profile:', error)
setSaveStatus('error')
setTimeout(() => {
setSaveStatus('idle')
}, 3000)
}
}
if (loading) {
return (
<div className='flex h-full items-center justify-center'>
<p className='text-muted-foreground'>Loading...</p>
</div>
)
}
return (
<div className='h-full overflow-y-auto p-6'>
<div className='mx-auto max-w-2xl space-y-6'>
<div>
<p className='text-muted-foreground text-sm'>
Set up your creator profile for publishing templates
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-6'>
{/* Profile Type - only show if user has organizations */}
{organizations.length > 0 && (
<FormField
control={form.control}
name='referenceType'
render={({ field }) => (
<FormItem className='space-y-3'>
<FormLabel>Profile Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='flex flex-col space-y-1'
>
<div className='flex items-center space-x-3'>
<RadioGroupItem value='user' id='user' />
<label
htmlFor='user'
className='flex cursor-pointer items-center gap-2 font-normal text-sm'
>
<User className='h-4 w-4' />
Personal Profile
</label>
</div>
<div className='flex items-center space-x-3'>
<RadioGroupItem value='organization' id='organization' />
<label
htmlFor='organization'
className='flex cursor-pointer items-center gap-2 font-normal text-sm'
>
<Users className='h-4 w-4' />
Organization Profile
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Reference Selection */}
{referenceType === 'organization' && organizations.length > 0 && (
<FormField
control={form.control}
name='referenceId'
render={({ field }) => (
<FormItem>
<FormLabel>Organization</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select organization' />
</SelectTrigger>
</FormControl>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Profile Name */}
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>
Display Name <span className='text-destructive'>*</span>
</FormLabel>
<FormControl>
<Input placeholder='How your name appears on templates' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Profile Picture Upload */}
<FormField
control={form.control}
name='profileImageUrl'
render={() => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Camera className='h-4 w-4' />
Profile Picture <span className='text-destructive'>*</span>
</div>
</FormLabel>
<FormControl>
<div className='space-y-2'>
<div className='relative inline-block'>
<div
className='group relative flex h-24 w-24 cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[#802FFF] transition-all hover:opacity-80'
onClick={handleProfilePictureClick}
>
{profilePictureUrl ? (
<Image
src={profilePictureUrl}
alt='Profile picture'
width={96}
height={96}
className={`h-full w-full object-cover transition-opacity duration-300 ${
isUploadingProfilePicture ? 'opacity-50' : 'opacity-100'
}`}
/>
) : (
<AgentIcon className='h-12 w-12 text-white' />
)}
{/* Upload overlay */}
<div
className={`absolute inset-0 flex items-center justify-center rounded-full bg-black/50 transition-opacity ${
isUploadingProfilePicture
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
{isUploadingProfilePicture ? (
<div className='h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent' />
) : (
<Camera className='h-6 w-6 text-white' />
)}
</div>
</div>
{/* Hidden file input */}
<Input
type='file'
accept='image/png,image/jpeg,image/jpg'
className='hidden'
ref={profilePictureInputRef}
onChange={handleProfilePictureChange}
disabled={isUploadingProfilePicture}
/>
</div>
{uploadError && <p className='text-destructive text-sm'>{uploadError}</p>}
<p className='text-muted-foreground text-xs'>PNG or JPEG (max 5MB)</p>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* About */}
<FormField
control={form.control}
name='about'
render={({ field }) => (
<FormItem>
<FormLabel>About</FormLabel>
<FormControl>
<Textarea
placeholder='Tell people about yourself or your organization'
className='min-h-[120px] resize-none'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Social Links */}
<div className='space-y-4'>
<h3 className='font-medium text-sm'>Social Links</h3>
<FormField
control={form.control}
name='xUrl'
render={({ field }) => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Twitter className='h-4 w-4' />X (Twitter)
</div>
</FormLabel>
<FormControl>
<Input placeholder='https://x.com/username' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='linkedinUrl'
render={({ field }) => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Linkedin className='h-4 w-4' />
LinkedIn
</div>
</FormLabel>
<FormControl>
<Input placeholder='https://linkedin.com/in/username' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='websiteUrl'
render={({ field }) => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Globe className='h-4 w-4' />
Website
</div>
</FormLabel>
<FormControl>
<Input placeholder='https://yourwebsite.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='contactEmail'
render={({ field }) => (
<FormItem>
<FormLabel>
<div className='flex items-center gap-2'>
<Mail className='h-4 w-4' />
Contact Email
</div>
</FormLabel>
<FormControl>
<Input placeholder='contact@example.com' type='email' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type='submit'
disabled={saveStatus === 'saving'}
className={cn(
'w-full transition-all duration-200',
saveStatus === 'saved' && 'bg-green-600 hover:bg-green-700',
saveStatus === 'error' && 'bg-red-600 hover:bg-red-700'
)}
>
{saveStatus === 'saving' && (
<>
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
Saving...
</>
)}
{saveStatus === 'saved' && (
<>
<Check className='mr-2 h-4 w-4' />
Saved
</>
)}
{saveStatus === 'error' && <>Error Saving</>}
{saveStatus === 'idle' && (
<>
<Save className='mr-2 h-4 w-4' />
{existingProfile ? 'Update Profile' : 'Create Profile'}
</>
)}
</Button>
</form>
</Form>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Info } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -12,6 +12,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { getEnv, isTruthy } from '@/lib/env'
import { useGeneralStore } from '@/stores/settings/general/store'
@@ -24,9 +25,15 @@ const TOOLTIPS = {
'Show floating controls for zoom, undo, and redo at the bottom of the workflow canvas.',
trainingControls:
'Show training controls for recording workflow edits to build copilot training datasets.',
superUserMode:
'Toggle super user mode UI. When enabled, you can see and approve pending templates. Your super user status in the database remains unchanged.',
}
export function General() {
const { data: session } = useSession()
const [isSuperUser, setIsSuperUser] = useState(false)
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
const isLoading = useGeneralStore((state) => state.isLoading)
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
const theme = useGeneralStore((state) => state.theme)
@@ -36,6 +43,7 @@ export function General() {
const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault)
const showFloatingControls = useGeneralStore((state) => state.showFloatingControls)
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
const superUserModeEnabled = useGeneralStore((state) => state.superUserModeEnabled)
// Loading states
const isAutoConnectLoading = useGeneralStore((state) => state.isAutoConnectLoading)
@@ -47,6 +55,7 @@ export function General() {
const isThemeLoading = useGeneralStore((state) => state.isThemeLoading)
const isFloatingControlsLoading = useGeneralStore((state) => state.isFloatingControlsLoading)
const isTrainingControlsLoading = useGeneralStore((state) => state.isTrainingControlsLoading)
const isSuperUserModeLoading = useGeneralStore((state) => state.isSuperUserModeLoading)
const setTheme = useGeneralStore((state) => state.setTheme)
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
@@ -57,6 +66,34 @@ export function General() {
)
const toggleFloatingControls = useGeneralStore((state) => state.toggleFloatingControls)
const toggleTrainingControls = useGeneralStore((state) => state.toggleTrainingControls)
const toggleSuperUserMode = useGeneralStore((state) => state.toggleSuperUserMode)
// Fetch super user status from database
useEffect(() => {
const fetchSuperUserStatus = async () => {
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
const data = await response.json()
setIsSuperUser(data.isSuperUser)
}
} catch (error) {
console.error('Failed to fetch super user status:', error)
} finally {
setLoadingSuperUser(false)
}
}
if (session?.user?.id) {
fetchSuperUserStatus()
}
}, [session?.user?.id])
const handleSuperUserModeToggle = async (checked: boolean) => {
if (checked !== superUserModeEnabled && !isSuperUserModeLoading) {
await toggleSuperUserMode()
}
}
// Sync theme from store to next-themes when theme changes
useEffect(() => {
@@ -327,6 +364,39 @@ export function General() {
/>
</div>
)}
{/* Super User Mode Toggle - Only visible to super users */}
{!loadingSuperUser && isSuperUser && (
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<Label htmlFor='super-user-mode' className='font-normal'>
Super User Mode
</Label>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-7 p-1 text-gray-500'
aria-label='Learn more about super user mode'
disabled={isLoading}
>
<Info className='h-5 w-5' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-[300px] p-3'>
<p className='text-sm'>{TOOLTIPS.superUserMode}</p>
</TooltipContent>
</Tooltip>
</div>
<Switch
id='super-user-mode'
checked={superUserModeEnabled}
onCheckedChange={handleSuperUserModeToggle}
disabled={isLoading || isSuperUserModeLoading}
/>
</div>
)}
</>
)}
</div>

View File

@@ -1,6 +1,7 @@
export { Account } from './account/account'
export { ApiKeys } from './api-keys/api-keys'
export { Copilot } from './copilot/copilot'
export { CreatorProfile } from './creator-profile/creator-profile'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools'
export { EnvironmentVariables } from './environment/environment'

View File

@@ -7,6 +7,7 @@ import {
Home,
Key,
LogIn,
Palette,
Server,
Settings,
Shield,
@@ -32,6 +33,7 @@ interface SettingsNavigationProps {
| 'general'
| 'environment'
| 'account'
| 'creator-profile'
| 'credentials'
| 'apikeys'
| 'files'
@@ -51,6 +53,7 @@ type NavigationItem = {
| 'general'
| 'environment'
| 'account'
| 'creator-profile'
| 'credentials'
| 'apikeys'
| 'files'
@@ -100,6 +103,11 @@ const allNavigationItems: NavigationItem[] = [
label: 'Account',
icon: User,
},
{
id: 'creator-profile',
label: 'Creator Profile',
icon: Palette,
},
{
id: 'apikeys',
label: 'API Keys',
@@ -231,6 +239,7 @@ export function SettingsNavigation({
}
}}
onClick={() => onSectionChange(item.id)}
data-section={item.id}
className={cn(
'group flex h-9 w-full cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors',
activeSection === item.id ? 'bg-muted' : 'hover:bg-muted'

View File

@@ -8,6 +8,7 @@ import {
Account,
ApiKeys,
Copilot,
CreatorProfile,
Credentials,
CustomTools,
EnvironmentVariables,
@@ -36,6 +37,7 @@ type SettingsSection =
| 'general'
| 'environment'
| 'account'
| 'creator-profile'
| 'credentials'
| 'apikeys'
| 'files'
@@ -154,6 +156,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
<Account onOpenChange={onOpenChange} />
</div>
)}
{activeSection === 'creator-profile' && (
<div className='h-full'>
<CreatorProfile />
</div>
)}
{activeSection === 'credentials' && (
<div className='h-full'>
<Credentials

View File

@@ -132,6 +132,7 @@ export {
} from './table'
export { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs'
export { checkTagTrigger, TagDropdown } from './tag-dropdown'
export { TagInput } from './tag-input'
export { Textarea } from './textarea'
export { Toggle, toggleVariants } from './toggle'
export { ToolCallCompletion, ToolCallExecution } from './tool-call'

View File

@@ -0,0 +1,103 @@
'use client'
import { type KeyboardEvent, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
interface TagInputProps {
value: string[]
onChange: (tags: string[]) => void
placeholder?: string
maxTags?: number
disabled?: boolean
className?: string
}
export function TagInput({
value = [],
onChange,
placeholder = 'Type and press Enter',
maxTags = 10,
disabled = false,
className,
}: TagInputProps) {
const [inputValue, setInputValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const addTag = (tag: string) => {
const trimmedTag = tag.trim()
if (trimmedTag && !value.includes(trimmedTag) && value.length < maxTags) {
onChange([...value, trimmedTag])
setInputValue('')
}
}
const removeTag = (tagToRemove: string) => {
onChange(value.filter((tag) => tag !== tagToRemove))
}
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim()) {
e.preventDefault()
addTag(inputValue)
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
// Remove last tag when backspace is pressed with empty input
removeTag(value[value.length - 1])
}
}
const handleBlur = () => {
// Add tag on blur if there's input
if (inputValue.trim()) {
addTag(inputValue)
}
}
return (
<div
className={cn(
'flex min-h-[2.5rem] flex-wrap gap-1.5 rounded-md border border-input bg-background p-2',
disabled && 'cursor-not-allowed opacity-50',
className
)}
onClick={() => !disabled && inputRef.current?.focus()}
>
{value.map((tag) => (
<Badge
key={tag}
variant='secondary'
className='h-7 gap-1.5 border-0 bg-muted/60 pr-1.5 pl-2.5 hover:bg-muted/80'
>
<span className='text-xs'>{tag}</span>
{!disabled && (
<button
type='button'
onClick={(e) => {
e.stopPropagation()
removeTag(tag)
}}
className='ml-auto rounded-full p-0.5 hover:bg-muted-foreground/20'
>
<X className='h-3 w-3' />
</button>
)}
</Badge>
))}
{!disabled && value.length < maxTags && (
<Input
ref={inputRef}
type='text'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={value.length === 0 ? placeholder : ''}
disabled={disabled}
className='h-7 min-w-[120px] flex-1 border-0 p-0 px-1 text-sm shadow-none placeholder:text-muted-foreground/60 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
)}
</div>
)
}

View File

@@ -57,6 +57,13 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
const refreshTools = useCallback(
async (forceRefresh = false) => {
// Skip if no workspaceId (e.g., on template preview pages)
if (!workspaceId) {
setMcpTools([])
setIsLoading(false)
return
}
setIsLoading(true)
setError(null)

View File

@@ -427,9 +427,7 @@ async function processTemplateFromDb(
.select({
id: templates.id,
name: templates.name,
description: templates.description,
category: templates.category,
author: templates.author,
details: templates.details,
stars: templates.stars,
state: templates.state,
})
@@ -438,14 +436,11 @@ async function processTemplateFromDb(
.limit(1)
const t = rows?.[0]
if (!t) return null
const workflowState = (t as any).state || {}
// Match get-user-workflow format: just the workflow state JSON
const workflowState = t.state || {}
const summary = {
id: t.id,
name: t.name,
description: t.description || '',
category: t.category,
author: t.author,
description: (t.details as any)?.tagline || '',
stars: t.stars || 0,
workflow: workflowState,
}

View File

@@ -0,0 +1,269 @@
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { AuthMode } from '@/blocks/types'
// Credential types based on actual patterns in the codebase
export enum CredentialType {
OAUTH = 'oauth',
SECRET = 'secret', // password: true (covers API keys, bot tokens, passwords, etc.)
}
// Type for credential requirement
export interface CredentialRequirement {
type: CredentialType
provider?: string // For OAuth (e.g., 'google-drive', 'slack')
label: string // Human-readable label
blockType: string // The block type that requires this
subBlockId: string // The subblock ID for reference
required: boolean
}
// Workspace-specific subblock types that should be cleared
const WORKSPACE_SPECIFIC_TYPES = new Set([
'knowledge-base-selector',
'knowledge-tag-filters',
'document-selector',
'document-tag-entry',
'file-selector', // Workspace files
'file-upload', // Uploaded files in workspace
'project-selector', // Workspace-specific projects
'channel-selector', // Workspace-specific channels
'folder-selector', // User-specific folders
'mcp-server-selector', // User-specific MCP servers
])
// Field IDs that are workspace-specific
const WORKSPACE_SPECIFIC_FIELDS = new Set([
'knowledgeBaseId',
'tagFilters',
'documentTags',
'documentId',
'fileId',
'projectId',
'channelId',
'folderId',
])
/**
* Extract required credentials from a workflow state
* This analyzes all blocks and their subblocks to identify credential requirements
*/
export function extractRequiredCredentials(state: any): CredentialRequirement[] {
const credentials: CredentialRequirement[] = []
const seen = new Set<string>()
if (!state?.blocks) {
return credentials
}
// Process each block
Object.values(state.blocks).forEach((block: any) => {
if (!block?.type) return
const blockConfig = getBlock(block.type)
if (!blockConfig) return
// Add OAuth credential if block has OAuth auth mode
if (blockConfig.authMode === AuthMode.OAuth) {
const blockName = blockConfig.name || block.type
const key = `oauth-${block.type}`
if (!seen.has(key)) {
seen.add(key)
credentials.push({
type: CredentialType.OAUTH,
provider: block.type,
label: `Credential for ${blockName}`,
blockType: block.type,
subBlockId: 'oauth',
required: true,
})
}
}
// Process password fields (API keys, tokens, etc)
blockConfig.subBlocks?.forEach((subBlockConfig: SubBlockConfig) => {
if (!isSubBlockVisible(block, subBlockConfig)) return
if (!subBlockConfig.password) return
const blockName = blockConfig.name || block.type
const suffix = block?.triggerMode ? ' Trigger' : ''
const fieldLabel = subBlockConfig.title || formatFieldName(subBlockConfig.id)
const key = `secret-${block.type}-${subBlockConfig.id}-${block?.triggerMode ? 'trigger' : 'default'}`
if (!seen.has(key)) {
seen.add(key)
credentials.push({
type: CredentialType.SECRET,
label: `${fieldLabel} for ${blockName}${suffix}`,
blockType: block.type,
subBlockId: subBlockConfig.id,
required: subBlockConfig.required !== false,
})
}
})
})
// Helper to check visibility, respecting mode and conditions
function isSubBlockVisible(block: any, subBlockConfig: SubBlockConfig): boolean {
const mode = subBlockConfig.mode ?? 'both'
if (mode === 'trigger' && !block?.triggerMode) return false
if (mode === 'basic' && block?.advancedMode) return false
if (mode === 'advanced' && !block?.advancedMode) return false
if (!subBlockConfig.condition) return true
const condition =
typeof subBlockConfig.condition === 'function'
? subBlockConfig.condition()
: subBlockConfig.condition
const evaluate = (cond: any): boolean => {
const currentValue = block?.subBlocks?.[cond.field]?.value
const expected = cond.value
let match =
expected === undefined
? true
: Array.isArray(expected)
? expected.includes(currentValue)
: currentValue === expected
if (cond.not) match = !match
if (cond.and) match = match && evaluate(cond.and)
return match
}
return evaluate(condition)
}
// Sort: OAuth first, then secrets, alphabetically within each type
credentials.sort((a, b) => {
if (a.type !== b.type) {
return a.type === CredentialType.OAUTH ? -1 : 1
}
return a.label.localeCompare(b.label)
})
return credentials
}
/**
* Format field name to be human-readable
*/
function formatFieldName(fieldName: string): string {
return fieldName
.replace(/[_-]/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
/**
* Sanitize workflow state by removing all credentials and workspace-specific data
* This is used for both template creation and workflow export to ensure consistency
*
* @param state - The workflow state to sanitize
* @param options - Options for sanitization behavior
*/
export function sanitizeWorkflowForSharing(
state: any,
options: {
preserveEnvVars?: boolean // Keep {{VAR}} references for export
} = {}
): any {
const sanitized = JSON.parse(JSON.stringify(state)) // Deep clone
if (!sanitized?.blocks) {
return sanitized
}
Object.values(sanitized.blocks).forEach((block: any) => {
if (!block?.type) return
const blockConfig = getBlock(block.type)
// Process subBlocks with config
if (blockConfig) {
blockConfig.subBlocks?.forEach((subBlockConfig: SubBlockConfig) => {
if (block.subBlocks?.[subBlockConfig.id]) {
const subBlock = block.subBlocks[subBlockConfig.id]
// Clear OAuth credentials (type: 'oauth-input')
if (subBlockConfig.type === 'oauth-input') {
block.subBlocks[subBlockConfig.id].value = ''
}
// Clear secret fields (password: true)
else if (subBlockConfig.password === true) {
// Preserve environment variable references if requested
if (
options.preserveEnvVars &&
typeof subBlock.value === 'string' &&
subBlock.value.startsWith('{{') &&
subBlock.value.endsWith('}}')
) {
// Keep the env var reference
} else {
block.subBlocks[subBlockConfig.id].value = ''
}
}
// Clear workspace-specific selectors
else if (WORKSPACE_SPECIFIC_TYPES.has(subBlockConfig.type)) {
block.subBlocks[subBlockConfig.id].value = ''
}
// Clear workspace-specific fields by ID
else if (WORKSPACE_SPECIFIC_FIELDS.has(subBlockConfig.id)) {
block.subBlocks[subBlockConfig.id].value = ''
}
}
})
}
// Process subBlocks without config (fallback)
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
// Clear workspace-specific fields by key name
if (WORKSPACE_SPECIFIC_FIELDS.has(key)) {
subBlock.value = ''
}
})
}
// Clear data field (for backward compatibility)
if (block.data) {
Object.entries(block.data).forEach(([key, value]: [string, any]) => {
// Clear anything that looks like credentials
if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) {
block.data[key] = ''
}
// Clear workspace-specific data
if (WORKSPACE_SPECIFIC_FIELDS.has(key)) {
block.data[key] = ''
}
})
}
})
return sanitized
}
/**
* Sanitize workflow state for templates (removes credentials and workspace data)
* Wrapper for backward compatibility
*/
export function sanitizeCredentials(state: any): any {
return sanitizeWorkflowForSharing(state, { preserveEnvVars: false })
}
/**
* Sanitize workflow state for export (preserves env vars)
* Convenience wrapper for workflow export
*/
export function sanitizeForExport(state: any): any {
return sanitizeWorkflowForSharing(state, { preserveEnvVars: true })
}

View File

@@ -1,3 +1,4 @@
import crypto from 'crypto'
import {
db,
workflow,
@@ -403,11 +404,19 @@ export async function deployWorkflow(params: {
return { success: false, error: 'Failed to load workflow state' }
}
// Also fetch workflow variables
const [workflowRecord] = await db
.select({ variables: workflow.variables })
.from(workflow)
.where(eq(workflow.id, workflowId))
.limit(1)
const currentState = {
blocks: normalizedData.blocks,
edges: normalizedData.edges,
loops: normalizedData.loops,
parallels: normalizedData.parallels,
variables: workflowRecord?.variables || undefined,
lastSaved: Date.now(),
}
@@ -447,6 +456,9 @@ export async function deployWorkflow(params: {
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
// Note: Templates are NOT automatically updated on deployment
// Template updates must be done explicitly through the "Update Template" button
return nextVersion
})
@@ -492,3 +504,131 @@ export async function deployWorkflow(params: {
}
}
}
/**
* Regenerates all IDs in a workflow state to avoid conflicts when duplicating or using templates
* Returns a new state with all IDs regenerated and references updated
*/
export function regenerateWorkflowStateIds(state: any): any {
// Create ID mappings
const blockIdMapping = new Map<string, string>()
const edgeIdMapping = new Map<string, string>()
const loopIdMapping = new Map<string, string>()
const parallelIdMapping = new Map<string, string>()
// First pass: Create all ID mappings
// Map block IDs
Object.keys(state.blocks || {}).forEach((oldId) => {
blockIdMapping.set(oldId, crypto.randomUUID())
})
// Map edge IDs
;(state.edges || []).forEach((edge: any) => {
edgeIdMapping.set(edge.id, crypto.randomUUID())
})
// Map loop IDs
Object.keys(state.loops || {}).forEach((oldId) => {
loopIdMapping.set(oldId, crypto.randomUUID())
})
// Map parallel IDs
Object.keys(state.parallels || {}).forEach((oldId) => {
parallelIdMapping.set(oldId, crypto.randomUUID())
})
// Second pass: Create new state with regenerated IDs and updated references
const newBlocks: Record<string, any> = {}
const newEdges: any[] = []
const newLoops: Record<string, any> = {}
const newParallels: Record<string, any> = {}
// Regenerate blocks with updated references
Object.entries(state.blocks || {}).forEach(([oldId, block]: [string, any]) => {
const newId = blockIdMapping.get(oldId)!
const newBlock = { ...block, id: newId }
// Update parentId reference if it exists
if (newBlock.data?.parentId) {
const newParentId = blockIdMapping.get(newBlock.data.parentId)
if (newParentId) {
newBlock.data.parentId = newParentId
}
}
// Update any block references in subBlocks
if (newBlock.subBlocks) {
const updatedSubBlocks: Record<string, any> = {}
Object.entries(newBlock.subBlocks).forEach(([subId, subBlock]: [string, any]) => {
const updatedSubBlock = { ...subBlock }
// If subblock value contains block references, update them
if (
typeof updatedSubBlock.value === 'string' &&
blockIdMapping.has(updatedSubBlock.value)
) {
updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value)
}
updatedSubBlocks[subId] = updatedSubBlock
})
newBlock.subBlocks = updatedSubBlocks
}
newBlocks[newId] = newBlock
})
// Regenerate edges with updated source/target references
;(state.edges || []).forEach((edge: any) => {
const newId = edgeIdMapping.get(edge.id)!
const newSource = blockIdMapping.get(edge.source) || edge.source
const newTarget = blockIdMapping.get(edge.target) || edge.target
newEdges.push({
...edge,
id: newId,
source: newSource,
target: newTarget,
})
})
// Regenerate loops with updated node references
Object.entries(state.loops || {}).forEach(([oldId, loop]: [string, any]) => {
const newId = loopIdMapping.get(oldId)!
const newLoop = { ...loop, id: newId }
// Update nodes array with new block IDs
if (newLoop.nodes) {
newLoop.nodes = newLoop.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
}
newLoops[newId] = newLoop
})
// Regenerate parallels with updated node references
Object.entries(state.parallels || {}).forEach(([oldId, parallel]: [string, any]) => {
const newId = parallelIdMapping.get(oldId)!
const newParallel = { ...parallel, id: newId }
// Update nodes array with new block IDs
if (newParallel.nodes) {
newParallel.nodes = newParallel.nodes.map(
(nodeId: string) => blockIdMapping.get(nodeId) || nodeId
)
}
newParallels[newId] = newParallel
})
return {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
lastSaved: state.lastSaved || Date.now(),
...(state.variables && { variables: state.variables }),
...(state.metadata && { metadata: state.metadata }),
}
}

View File

@@ -1,4 +1,5 @@
import type { Edge } from 'reactflow'
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credential-extractor'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
/**
@@ -380,44 +381,24 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
* Users need positions to restore the visual layout when importing
*/
export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
const clonedState = JSON.parse(
JSON.stringify({
blocks: state.blocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
metadata: state.metadata,
variables: state.variables,
})
)
// Preserve edges, loops, parallels, metadata, and variables
const fullState = {
blocks: state.blocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
metadata: state.metadata,
variables: state.variables,
}
Object.values(clonedState.blocks).forEach((block: any) => {
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
if (
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) ||
subBlock.type === 'oauth-input'
) {
subBlock.value = ''
}
if (key === 'tagFilters' || key === 'documentTags') {
subBlock.value = ''
}
})
}
if (block.data) {
Object.entries(block.data).forEach(([key, value]: [string, any]) => {
if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) {
block.data[key] = ''
}
})
}
// Use unified sanitization with env var preservation for export
const sanitizedState = sanitizeWorkflowForSharing(fullState, {
preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows
})
return {
version: '1.0',
exportedAt: new Date().toISOString(),
state: clonedState,
state: sanitizedState,
}
}

View File

@@ -151,6 +151,11 @@ export async function middleware(request: NextRequest) {
return NextResponse.next()
}
// Allow public access to template pages for SEO
if (url.pathname.startsWith('/templates')) {
return NextResponse.next()
}
if (url.pathname.startsWith('/workspace')) {
if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url))

View File

@@ -23,6 +23,7 @@ export const useGeneralStore = create<GeneralStore>()(
isConsoleExpandedByDefault: true,
showFloatingControls: true,
showTrainingControls: false,
superUserModeEnabled: true,
theme: 'system' as const, // Keep for compatibility but not used
telemetryEnabled: true,
isLoading: false,
@@ -37,6 +38,7 @@ export const useGeneralStore = create<GeneralStore>()(
isBillingUsageNotificationsEnabled: true,
isFloatingControlsLoading: false,
isTrainingControlsLoading: false,
isSuperUserModeLoading: false,
}
// Optimistic update helper
@@ -122,6 +124,17 @@ export const useGeneralStore = create<GeneralStore>()(
)
},
toggleSuperUserMode: async () => {
if (get().isSuperUserModeLoading) return
const newValue = !get().superUserModeEnabled
await updateSettingOptimistic(
'superUserModeEnabled',
newValue,
'isSuperUserModeLoading',
'superUserModeEnabled'
)
},
setTheme: async (theme) => {
if (get().isThemeLoading) return
@@ -219,6 +232,7 @@ export const useGeneralStore = create<GeneralStore>()(
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
showFloatingControls: data.showFloatingControls ?? true,
showTrainingControls: data.showTrainingControls ?? false,
superUserModeEnabled: data.superUserModeEnabled ?? true,
theme: data.theme || 'system',
telemetryEnabled: data.telemetryEnabled,
isBillingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,

View File

@@ -4,6 +4,7 @@ export interface General {
isConsoleExpandedByDefault: boolean
showFloatingControls: boolean
showTrainingControls: boolean
superUserModeEnabled: boolean
theme: 'system' | 'light' | 'dark'
telemetryEnabled: boolean
isLoading: boolean
@@ -17,6 +18,7 @@ export interface General {
isBillingUsageNotificationsEnabled: boolean
isFloatingControlsLoading: boolean
isTrainingControlsLoading: boolean
isSuperUserModeLoading: boolean
}
export interface GeneralActions {
@@ -25,6 +27,7 @@ export interface GeneralActions {
toggleConsoleExpandedByDefault: () => Promise<void>
toggleFloatingControls: () => Promise<void>
toggleTrainingControls: () => Promise<void>
toggleSuperUserMode: () => Promise<void>
setTheme: (theme: 'system' | 'light' | 'dark') => Promise<void>
setTelemetryEnabled: (enabled: boolean) => Promise<void>
setBillingUsageNotificationsEnabled: (enabled: boolean) => Promise<void>
@@ -41,6 +44,7 @@ export type UserSettings = {
consoleExpandedByDefault: boolean
showFloatingControls: boolean
showTrainingControls: boolean
superUserModeEnabled: boolean
telemetryEnabled: boolean
isBillingUsageNotificationsEnabled: boolean
}

View File

@@ -475,6 +475,18 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
useWorkflowStore.setState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(id, (workflowState as any).blocks || {})
// Load workflow variables if they exist
if (workflowData?.variables && typeof workflowData.variables === 'object') {
useVariablesStore.setState((state) => {
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(([, v]: any) => v.workflowId !== id)
)
return {
variables: { ...withoutWorkflow, ...workflowData.variables },
}
})
}
window.dispatchEvent(
new CustomEvent('active-workflow-changed', {
detail: { workflowId: id },

View File

@@ -0,0 +1,7 @@
export interface CreatorProfileDetails {
about?: string
xUrl?: string
linkedinUrl?: string
websiteUrl?: string
contactEmail?: string
}

View File

@@ -0,0 +1,47 @@
CREATE TYPE "public"."template_creator_type" AS ENUM('user', 'organization');--> statement-breakpoint
CREATE TYPE "public"."template_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint
CREATE TABLE "template_creators" (
"id" text PRIMARY KEY NOT NULL,
"reference_type" "template_creator_type" NOT NULL,
"reference_id" text NOT NULL,
"name" text NOT NULL,
"profile_image_url" text,
"details" jsonb,
"created_by" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "templates" DROP CONSTRAINT "templates_user_id_user_id_fk";
--> statement-breakpoint
ALTER TABLE "templates" DROP CONSTRAINT "templates_workflow_id_workflow_id_fk";
--> statement-breakpoint
DROP INDEX "templates_workflow_id_idx";--> statement-breakpoint
DROP INDEX "templates_user_id_idx";--> statement-breakpoint
DROP INDEX "templates_category_idx";--> statement-breakpoint
DROP INDEX "templates_category_views_idx";--> statement-breakpoint
DROP INDEX "templates_category_stars_idx";--> statement-breakpoint
DROP INDEX "templates_user_category_idx";--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "super_user_mode_enabled" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "templates" ADD COLUMN "details" jsonb;--> statement-breakpoint
ALTER TABLE "templates" ADD COLUMN "creator_id" text;--> statement-breakpoint
ALTER TABLE "templates" ADD COLUMN "status" "template_status" DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE "templates" ADD COLUMN "tags" text[] DEFAULT '{}'::text[] NOT NULL;--> statement-breakpoint
ALTER TABLE "templates" ADD COLUMN "required_credentials" jsonb DEFAULT '[]' NOT NULL;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "is_super_user" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "template_creators" ADD CONSTRAINT "template_creators_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "template_creators_reference_idx" ON "template_creators" USING btree ("reference_type","reference_id");--> statement-breakpoint
CREATE INDEX "template_creators_reference_id_idx" ON "template_creators" USING btree ("reference_id");--> statement-breakpoint
CREATE INDEX "template_creators_created_by_idx" ON "template_creators" USING btree ("created_by");--> statement-breakpoint
ALTER TABLE "templates" ADD CONSTRAINT "templates_creator_id_template_creators_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."template_creators"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "templates" ADD CONSTRAINT "templates_workflow_id_workflow_id_fk" FOREIGN KEY ("workflow_id") REFERENCES "public"."workflow"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "templates_status_idx" ON "templates" USING btree ("status");--> statement-breakpoint
CREATE INDEX "templates_creator_id_idx" ON "templates" USING btree ("creator_id");--> statement-breakpoint
CREATE INDEX "templates_status_views_idx" ON "templates" USING btree ("status","views");--> statement-breakpoint
CREATE INDEX "templates_status_stars_idx" ON "templates" USING btree ("status","stars");--> statement-breakpoint
ALTER TABLE "templates" DROP COLUMN "user_id";--> statement-breakpoint
ALTER TABLE "templates" DROP COLUMN "description";--> statement-breakpoint
ALTER TABLE "templates" DROP COLUMN "author";--> statement-breakpoint
ALTER TABLE "templates" DROP COLUMN "color";--> statement-breakpoint
ALTER TABLE "templates" DROP COLUMN "icon";--> statement-breakpoint
ALTER TABLE "templates" DROP COLUMN "category";

File diff suppressed because it is too large Load Diff

View File

@@ -743,6 +743,13 @@
"when": 1762371130884,
"tag": "0106_bitter_captain_midlands",
"breakpoints": true
},
{
"idx": 107,
"version": "7",
"when": 1762565365042,
"tag": "0107_silky_agent_brand",
"breakpoints": true
}
]
}

View File

@@ -37,6 +37,7 @@ export const user = pgTable('user', {
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
stripeCustomerId: text('stripe_customer_id'),
isSuperUser: boolean('is_super_user').notNull().default(false),
})
export const session = pgTable(
@@ -423,6 +424,7 @@ export const settings = pgTable('settings', {
// UI preferences
showFloatingControls: boolean('show_floating_controls').notNull().default(true),
showTrainingControls: boolean('show_training_controls').notNull().default(false),
superUserModeEnabled: boolean('super_user_mode_enabled').notNull().default(true),
// Copilot preferences - maps model_id to enabled/disabled boolean
copilotEnabledModels: jsonb('copilot_enabled_models').notNull().default('{}'),
@@ -1305,40 +1307,61 @@ export const workflowCheckpoints = pgTable(
})
)
export const templateStatusEnum = pgEnum('template_status', ['pending', 'approved', 'rejected'])
export const templateCreatorTypeEnum = pgEnum('template_creator_type', ['user', 'organization'])
export const templateCreators = pgTable(
'template_creators',
{
id: text('id').primaryKey(),
referenceType: templateCreatorTypeEnum('reference_type').notNull(),
referenceId: text('reference_id').notNull(),
name: text('name').notNull(),
profileImageUrl: text('profile_image_url'),
details: jsonb('details'),
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
referenceUniqueIdx: uniqueIndex('template_creators_reference_idx').on(
table.referenceType,
table.referenceId
),
referenceIdIdx: index('template_creators_reference_id_idx').on(table.referenceId),
createdByIdx: index('template_creators_created_by_idx').on(table.createdBy),
})
)
export const templates = pgTable(
'templates',
{
id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
name: text('name').notNull(),
description: text('description'),
author: text('author').notNull(),
details: jsonb('details'),
creatorId: text('creator_id').references(() => templateCreators.id, { onDelete: 'set null' }),
views: integer('views').notNull().default(0),
stars: integer('stars').notNull().default(0),
color: text('color').notNull().default('#3972F6'),
icon: text('icon').notNull().default('FileText'), // Lucide icon name as string
category: text('category').notNull(),
state: jsonb('state').notNull(), // Using jsonb for better performance
status: templateStatusEnum('status').notNull().default('pending'),
tags: text('tags').array().notNull().default(sql`'{}'::text[]`), // Array of tags
requiredCredentials: jsonb('required_credentials').notNull().default('[]'), // Array of credential requirements
state: jsonb('state').notNull(), // Store the workflow state directly
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
// Primary access patterns
workflowIdIdx: index('templates_workflow_id_idx').on(table.workflowId),
userIdIdx: index('templates_user_id_idx').on(table.userId),
categoryIdx: index('templates_category_idx').on(table.category),
statusIdx: index('templates_status_idx').on(table.status),
creatorIdIdx: index('templates_creator_id_idx').on(table.creatorId),
// Sorting indexes for popular/trending templates
viewsIdx: index('templates_views_idx').on(table.views),
starsIdx: index('templates_stars_idx').on(table.stars),
// Composite indexes for common queries
categoryViewsIdx: index('templates_category_views_idx').on(table.category, table.views),
categoryStarsIdx: index('templates_category_stars_idx').on(table.category, table.stars),
userCategoryIdx: index('templates_user_category_idx').on(table.userId, table.category),
statusViewsIdx: index('templates_status_views_idx').on(table.status, table.views),
statusStarsIdx: index('templates_status_stars_idx').on(table.status, table.stars),
// Temporal indexes
createdAtIdx: index('templates_created_at_idx').on(table.createdAt),