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 // Infer context from key if not explicitly provided
const inferredContext = context || inferContextFromKey(cloudKey) 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) // 1. Workspace files: Check database first (most reliable for both local and cloud)
if (inferredContext === 'workspace') { if (inferredContext === 'workspace') {
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal) return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal)

View File

@@ -31,6 +31,25 @@ export async function GET(
logger.info('File serve request:', { path }) 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 }) const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) { if (!authResult.success || !authResult.userId) {
@@ -42,14 +61,6 @@ export async function GET(
} }
const userId = authResult.userId 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) { if (isUsingCloudStorage() || isCloudPath) {
return await handleCloudProxy(cloudKey, userId, contextParam, legacyBucketType) return await handleCloudProxy(cloudKey, userId, contextParam, legacyBucketType)
@@ -174,3 +185,64 @@ async function handleCloudProxy(
throw error 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 { db } from '@sim/db'
import { templates, workflow } from '@sim/db/schema' import { member, templateCreators, templates, workflow } from '@sim/db/schema'
import { eq, sql } from 'drizzle-orm' import { and, eq, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod' import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { hasAdminPermission } from '@/lib/permissions/utils'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
import {
extractRequiredCredentials,
sanitizeCredentials,
} from '@/lib/workflows/credential-extractor'
const logger = createLogger('TemplateByIdAPI') const logger = createLogger('TemplateByIdAPI')
@@ -19,45 +22,76 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
try { try {
const session = await getSession() 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}`) logger.debug(`[${requestId}] Fetching template: ${id}`)
// Fetch the template by ID // Fetch the template by ID with creator info
const result = await db.select().from(templates).where(eq(templates.id, id)).limit(1) 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) { if (result.length === 0) {
logger.warn(`[${requestId}] Template not found: ${id}`) logger.warn(`[${requestId}] Template not found: ${id}`)
return NextResponse.json({ error: 'Template not found' }, { status: 404 }) 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 // Only show approved templates to non-authenticated users
try { if (!session?.user?.id && template.status !== 'approved') {
await db return NextResponse.json({ error: 'Template not found' }, { status: 404 })
.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}`) // Check if user has starred (only if authenticated)
} catch (viewError) { let isStarred = false
// Log the error but don't fail the request if (session?.user?.id) {
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError) 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}`) logger.info(`[${requestId}] Successfully retrieved template: ${id}`)
return NextResponse.json({ return NextResponse.json({
data: { data: {
...template, ...templateWithCreator,
views: template.views + 1, // Return the incremented view count views: template.views + (shouldIncrementView ? 1 : 0),
isStarred,
}, },
}) })
} catch (error: any) { } catch (error: any) {
@@ -67,13 +101,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
} }
const updateTemplateSchema = z.object({ const updateTemplateSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1).max(100).optional(),
description: z.string().min(1).max(500), details: z
author: z.string().min(1).max(100), .object({
category: z.string().min(1), tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(),
icon: z.string().min(1), about: z.string().optional(), // Markdown long description
color: z.string().regex(/^#[0-9A-F]{6}$/i), })
state: z.any().optional(), // Workflow state .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 // 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 // Check if template exists
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1) 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 }) return NextResponse.json({ error: 'Template not found' }, { status: 404 })
} }
// Permission: template owner OR admin of the workflow's workspace (if any) // No permission check needed - template updates only happen from within the workspace
let canUpdate = existingTemplate[0].userId === session.user.id // where the user is already editing the connected workflow
if (!canUpdate && existingTemplate[0].workflowId) { // Prepare update data - only include fields that were provided
const wfRows = await db const updateData: any = {
.select({ workspaceId: workflow.workspaceId }) updatedAt: new Date(),
.from(workflow) }
.where(eq(workflow.id, existingTemplate[0].workflowId))
.limit(1)
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined // Only update fields that were provided
if (workspaceId) { if (name !== undefined) updateData.name = name
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId) if (details !== undefined) updateData.details = details
if (hasAdmin) canUpdate = true 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 const updatedTemplate = await db
.update(templates) .update(templates)
.set({ .set(updateData)
name,
description,
author,
category,
icon,
color,
...(state && { state }),
updatedAt: new Date(),
})
.where(eq(templates.id, id)) .where(eq(templates.id, id))
.returning() .returning()
@@ -183,27 +242,41 @@ export async function DELETE(
const template = existing[0] const template = existing[0]
// Permission: owner or admin of the workflow's workspace (if any) // Permission: Only admin/owner of creator profile can delete
let canDelete = template.userId === session.user.id if (template.creatorId) {
const creatorProfile = await db
if (!canDelete && template.workflowId) { .select()
// Look up workflow to get workspaceId .from(templateCreators)
const wfRows = await db .where(eq(templateCreators.id, template.creatorId))
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, template.workflowId))
.limit(1) .limit(1)
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined if (creatorProfile.length > 0) {
if (workspaceId) { const creator = creatorProfile[0]
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId) let hasPermission = false
if (hasAdmin) canDelete = true
}
}
if (!canDelete) { if (creator.referenceType === 'user') {
logger.warn(`[${requestId}] User denied permission to delete template ${id}`) hasPermission = creator.referenceId === session.user.id
return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } 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)) await db.delete(templates).where(eq(templates.id, id))

View File

@@ -1,17 +1,25 @@
import { db } from '@sim/db' 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 { eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { getBaseUrl } from '@/lib/urls/utils'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
import { regenerateWorkflowStateIds } from '@/lib/workflows/db-helpers'
const logger = createLogger('TemplateUseAPI') const logger = createLogger('TemplateUseAPI')
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export const revalidate = 0 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) // POST /api/templates/[id]/use - Use a template (increment views and create workflow)
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = generateRequestId() const requestId = generateRequestId()
@@ -24,9 +32,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 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 body = await request.json()
const { workspaceId } = body const { workspaceId, connectToTemplate = false } = body
if (!workspaceId) { if (!workspaceId) {
logger.warn(`[${requestId}] Missing workspaceId in request body`) logger.warn(`[${requestId}] Missing workspaceId in request body`)
@@ -34,17 +42,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
} }
logger.debug( 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 const template = await db
.select({ .select({
id: templates.id, id: templates.id,
name: templates.name, name: templates.name,
description: templates.description, details: templates.details,
state: templates.state, state: templates.state,
color: templates.color, workflowId: templates.workflowId,
}) })
.from(templates) .from(templates)
.where(eq(templates.id, id)) .where(eq(templates.id, id))
@@ -59,119 +67,106 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Create a new workflow ID // Create a new workflow ID
const newWorkflowId = uuidv4() 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) => { const result = await db.transaction(async (tx) => {
// Increment the template views // Prepare template update data
await tx const updateData: any = {
.update(templates) views: sql`${templates.views} + 1`,
.set({ updatedAt: now,
views: sql`${templates.views} + 1`, }
updatedAt: new Date(),
})
.where(eq(templates.id, id))
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 // Create a deployment version for the new workflow
const newWorkflow = await tx if (templateData.state) {
.insert(workflow) const newDeploymentVersionId = uuidv4()
.values({ await tx.insert(workflowDeploymentVersion).values({
id: newWorkflowId, id: newDeploymentVersionId,
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,
workflowId: newWorkflowId, workflowId: newWorkflowId,
type: block.type, version: 1,
name: block.name, state: templateData.state,
positionX: block.position?.x?.toString() || '0', isActive: true,
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,
createdAt: now, createdAt: now,
updatedAt: now, createdBy: session.user.id,
}
})
// 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,
}
}
}) })
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( 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 // Track template usage
@@ -191,18 +186,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Silently fail // 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( return NextResponse.json(
{ {
message: 'Template used successfully', message: 'Template used successfully',

View File

@@ -1,5 +1,13 @@
import { db } from '@sim/db' 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 { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server' import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
@@ -7,78 +15,43 @@ import { z } from 'zod'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils' import { generateRequestId } from '@/lib/utils'
import {
extractRequiredCredentials,
sanitizeCredentials,
} from '@/lib/workflows/credential-extractor'
const logger = createLogger('TemplatesAPI') const logger = createLogger('TemplatesAPI')
export const revalidate = 0 export const revalidate = 0
// Function to sanitize sensitive data from workflow state // Function to sanitize sensitive data from workflow state
// Now uses the more comprehensive sanitizeCredentials from credential-extractor
function sanitizeWorkflowState(state: any): any { function sanitizeWorkflowState(state: any): any {
const sanitizedState = JSON.parse(JSON.stringify(state)) // Deep clone return sanitizeCredentials(state)
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
} }
// Schema for creating a template // Schema for creating a template
const CreateTemplateSchema = z.object({ const CreateTemplateSchema = z.object({
workflowId: z.string().min(1, 'Workflow ID is required'), 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'), name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
description: z details: z
.string() .object({
.min(1, 'Description is required') tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(),
.max(500, 'Description must be less than 500 characters'), about: z.string().optional(), // Markdown long description
author: z })
.string() .optional(),
.min(1, 'Author is required') creatorId: z.string().optional(), // Creator profile ID
.max(100, 'Author must be less than 100 characters'), tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
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()),
}),
}) })
// Schema for query parameters // Schema for query parameters
const QueryParamsSchema = z.object({ const QueryParamsSchema = z.object({
category: z.string().optional(),
limit: z.coerce.number().optional().default(50), limit: z.coerce.number().optional().default(50),
offset: z.coerce.number().optional().default(0), offset: z.coerce.number().optional().default(0),
search: z.string().optional(), search: z.string().optional(),
workflowId: 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 // GET /api/templates - Retrieve templates
@@ -97,27 +70,41 @@ export async function GET(request: NextRequest) {
logger.debug(`[${requestId}] Fetching templates with params:`, params) 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 // Build query conditions
const conditions = [] const conditions = []
// Apply category filter if provided // Apply workflow filter if provided (for getting template by workflow)
if (params.category) { // When fetching by workflowId, we want to get the template regardless of status
conditions.push(eq(templates.category, params.category)) // 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 // Apply search filter if provided
if (params.search) { if (params.search) {
const searchTerm = `%${params.search}%` const searchTerm = `%${params.search}%`
conditions.push( 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 // Combine conditions
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
@@ -126,25 +113,27 @@ export async function GET(request: NextRequest) {
.select({ .select({
id: templates.id, id: templates.id,
workflowId: templates.workflowId, workflowId: templates.workflowId,
userId: templates.userId,
name: templates.name, name: templates.name,
description: templates.description, details: templates.details,
author: templates.author, creatorId: templates.creatorId,
creator: templateCreators,
views: templates.views, views: templates.views,
stars: templates.stars, stars: templates.stars,
color: templates.color, status: templates.status,
icon: templates.icon, tags: templates.tags,
category: templates.category, requiredCredentials: templates.requiredCredentials,
state: templates.state, state: templates.state,
createdAt: templates.createdAt, createdAt: templates.createdAt,
updatedAt: templates.updatedAt, updatedAt: templates.updatedAt,
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`, 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) .from(templates)
.leftJoin( .leftJoin(
templateStars, templateStars,
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id)) and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
) )
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(whereCondition) .where(whereCondition)
.orderBy(desc(templates.views), desc(templates.createdAt)) .orderBy(desc(templates.views), desc(templates.createdAt))
.limit(params.limit) .limit(params.limit)
@@ -200,7 +189,6 @@ export async function POST(request: NextRequest) {
logger.debug(`[${requestId}] Creating template:`, { logger.debug(`[${requestId}] Creating template:`, {
name: data.name, name: data.name,
category: data.category,
workflowId: data.workflowId, workflowId: data.workflowId,
}) })
@@ -216,26 +204,116 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) 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 // Create the template
const templateId = uuidv4() const templateId = uuidv4()
const now = new Date() const now = new Date()
// Sanitize the workflow state to remove sensitive credentials // Get the active deployment version for the workflow to copy its state
const sanitizedState = sanitizeWorkflowState(data.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 = { const newTemplate = {
id: templateId, id: templateId,
workflowId: data.workflowId, workflowId: data.workflowId,
userId: session.user.id,
name: data.name, name: data.name,
description: data.description || null, details: data.details || null,
author: data.author, creatorId: data.creatorId || null,
views: 0, views: 0,
stars: 0, stars: 0,
color: data.color, status: 'pending' as const, // All new templates start as pending
icon: data.icon, tags: data.tags || [],
category: data.category, requiredCredentials: requiredCredentials, // Store the extracted credential requirements
state: sanitizedState, state: sanitizedState, // Store the sanitized state without credential values
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
} }
@@ -247,7 +325,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
id: templateId, id: templateId,
message: 'Template created successfully', message: 'Template submitted for approval successfully',
}, },
{ status: 201 } { 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(), billingUsageNotificationsEnabled: z.boolean().optional(),
showFloatingControls: z.boolean().optional(), showFloatingControls: z.boolean().optional(),
showTrainingControls: z.boolean().optional(), showTrainingControls: z.boolean().optional(),
superUserModeEnabled: z.boolean().optional(),
}) })
// Default settings values // Default settings values
@@ -42,6 +43,7 @@ const defaultSettings = {
billingUsageNotificationsEnabled: true, billingUsageNotificationsEnabled: true,
showFloatingControls: true, showFloatingControls: true,
showTrainingControls: false, showTrainingControls: false,
superUserModeEnabled: false,
} }
export async function GET() { export async function GET() {
@@ -78,6 +80,7 @@ export async function GET() {
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true, billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
showFloatingControls: userSettings.showFloatingControls ?? true, showFloatingControls: userSettings.showFloatingControls ?? true,
showTrainingControls: userSettings.showTrainingControls ?? false, showTrainingControls: userSettings.showTrainingControls ?? false,
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
}, },
}, },
{ status: 200 } { status: 200 }

View File

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

View File

@@ -102,6 +102,7 @@ const WorkflowStateSchema = z.object({
lastSaved: z.number().optional(), lastSaved: z.number().optional(),
isDeployed: z.boolean().optional(), isDeployed: z.boolean().optional(),
deployedAt: z.coerce.date().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 }) logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId })
} }
// Update workflow's lastSynced timestamp // Update workflow's lastSynced timestamp and variables if provided
await db const updateData: any = {
.update(workflow) lastSynced: new Date(),
.set({ updatedAt: new Date(),
lastSynced: new Date(), }
updatedAt: new Date(),
}) // If variables are provided in the state, update them in the workflow record
.where(eq(workflow.id, workflowId)) if (state.variables !== undefined) {
updateData.variables = state.variables
}
await db.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
const elapsed = Date.now() - startTime const elapsed = Date.now() - startTime
logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) 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 { 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 { and, eq } from 'drizzle-orm'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template' import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template'
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
const logger = createLogger('TemplatePage') const logger = createLogger('TemplatePage')
@@ -20,36 +19,19 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId, id } = await params const { workspaceId, id } = await params
try { try {
// Validate the template ID format (basic UUID validation)
if (!id || typeof id !== 'string' || id.length !== 36) { if (!id || typeof id !== 'string' || id.length !== 36) {
notFound() notFound()
} }
const session = await getSession() 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 const templateData = await db
.select({ .select({
id: templates.id, template: templates,
workflowId: templates.workflowId, creator: templateCreators,
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,
}) })
.from(templates) .from(templates)
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
.where(eq(templates.id, id)) .where(eq(templates.id, id))
.limit(1) .limit(1)
@@ -57,61 +39,52 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
notFound() notFound()
} }
const template = templateData[0] const { template, creator } = templateData[0]
// Validate that required fields are present if (!session?.user?.id && template.status !== 'approved') {
if (!template.id || !template.name || !template.author) { notFound()
}
if (!template.id || !template.name) {
logger.error('Template missing required fields:', { logger.error('Template missing required fields:', {
id: template.id, id: template.id,
name: template.name, name: template.name,
author: template.author,
}) })
notFound() notFound()
} }
// Check if user has starred this template
let isStarred = false let isStarred = false
try { if (session?.user?.id) {
const starData = await db try {
.select({ id: templateStars.id }) const starData = await db
.from(templateStars) .select({ id: templateStars.id })
.where( .from(templateStars)
and(eq(templateStars.templateId, template.id), eq(templateStars.userId, session.user.id)) .where(
) and(
.limit(1) eq(templateStars.templateId, template.id),
isStarred = starData.length > 0 eq(templateStars.userId, session.user.id)
} catch { )
// Continue with isStarred = false )
.limit(1)
isStarred = starData.length > 0
} catch {
isStarred = false
}
} }
// Ensure proper serialization of the template data with null checks const serializedTemplate = {
const serializedTemplate: Template = { ...template,
id: template.id, creator: creator || null,
workflowId: template.workflowId, createdAt: template.createdAt.toISOString(),
userId: template.userId, updatedAt: template.updatedAt.toISOString(),
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(),
isStarred, isStarred,
} }
logger.info('Template from DB:', template)
logger.info('Serialized template:', serializedTemplate)
logger.info('Template state from DB:', template.state)
return ( return (
<TemplateDetails <TemplateDetails
template={serializedTemplate} template={JSON.parse(JSON.stringify(serializedTemplate))}
workspaceId={workspaceId} workspaceId={workspaceId}
currentUserId={session.user.id} currentUserId={session?.user?.id || null}
/> />
) )
} catch (error) { } 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> <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='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-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>
</div> </div>
) )

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { import {
ArrowLeft, ArrowLeft,
Award, Award,
@@ -45,12 +45,11 @@ import {
Wrench, Wrench,
Zap, Zap,
} from 'lucide-react' } from 'lucide-react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates' 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 { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import type { WorkflowState } from '@/stores/workflows/workflow/types' import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -59,7 +58,7 @@ const logger = createLogger('TemplateDetails')
interface TemplateDetailsProps { interface TemplateDetailsProps {
template: Template template: Template
workspaceId: string workspaceId: string
currentUserId: string currentUserId: string | null
} }
// Icon mapping - reuse from template-card // 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' /> 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({ export default function TemplateDetails({
template, template,
workspaceId, workspaceId,
currentUserId, currentUserId,
}: TemplateDetailsProps) { }: TemplateDetailsProps) {
const router = useRouter() const router = useRouter()
const [isStarred, setIsStarred] = useState(template?.isStarred || false) const searchParams = useSearchParams()
const [starCount, setStarCount] = useState(template?.stars || 0)
const [isStarring, setIsStarring] = useState(false)
const [isUsing, setIsUsing] = useState(false)
// Defensive check for template after hooks are initialized // Defensive check for template BEFORE initializing state hooks
if (!template) { if (!template) {
logger.error('Template prop is undefined or null in TemplateDetails component', {
template,
workspaceId,
currentUserId,
})
return ( return (
<div className='flex h-screen items-center justify-center'> <div className='flex h-screen items-center justify-center'>
<div className='text-center'> <div className='text-center'>
<h1 className='mb-4 font-bold text-2xl'>Template Not Found</h1> <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='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>
</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 // Render workflow preview exactly like deploy-modal.tsx
const renderWorkflowPreview = () => { const renderWorkflowPreview = () => {
// Follow the same pattern as deployed-workflow-card.tsx // Follow the same pattern as deployed-workflow-card.tsx
@@ -189,7 +209,7 @@ export default function TemplateDetails({
} }
const handleStarToggle = async () => { const handleStarToggle = async () => {
if (isStarring) return if (isStarring || !currentUserId) return
setIsStarring(true) setIsStarring(true)
try { try {
@@ -210,42 +230,78 @@ export default function TemplateDetails({
const handleUseTemplate = async () => { const handleUseTemplate = async () => {
if (isUsing) return 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) setIsUsing(true)
try { try {
// TODO: Implement proper template usage logic const response = await fetch(`/api/templates/${template.id}/use`, {
// 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', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ workspaceId }),
name: `${template.name} (Copy)`,
description: `Created from template: ${template.name}`,
color: template.color,
workspaceId,
folderId: null,
}),
}) })
if (!response.ok) { 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 // Navigate to the new workflow
router.push(`/workspace/${workspaceId}/w/${newWorkflow.id}`) router.push(`/workspace/${workspaceId}/w/${workflowId}`)
} catch (error) { } catch (error) {
logger.error('Error using template:', error) logger.error('Error using template:', error)
// Show error to user (could implement toast notification)
} finally { } finally {
setIsUsing(false) 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 ( return (
<div className='flex min-h-screen flex-col'> <div className='flex min-h-screen flex-col'>
{/* Header */} {/* Header */}
@@ -282,20 +338,36 @@ export default function TemplateDetails({
{/* Action buttons */} {/* Action buttons */}
<div className='flex items-center gap-3'> <div className='flex items-center gap-3'>
{/* Star button */} {/* Star button - only for logged-in users */}
<Button {currentUserId && (
variant='outline' <Button
size='sm' variant='outline'
onClick={handleStarToggle} size='sm'
disabled={isStarring} onClick={handleStarToggle}
className={cn( disabled={isStarring}
'transition-colors', className={cn(
isStarred && 'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100' '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 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 */} {/* Use template button */}
<Button <Button
@@ -303,28 +375,23 @@ export default function TemplateDetails({
disabled={isUsing} disabled={isUsing}
className='bg-purple-600 text-white hover:bg-purple-700' className='bg-purple-600 text-white hover:bg-purple-700'
> >
Use this template {isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
</Button> </Button>
</div> </div>
</div> </div>
{/* Tags */} {/* Tags */}
<div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'> <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 */} {/* Views */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'> <div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Eye className='h-3 w-3' /> <Eye className='h-3 w-3' />
<span>{template.views}</span> <span>{template.views} views</span>
</div> </div>
{/* Stars */} {/* Stars */}
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'> <div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
<Star className='h-3 w-3' /> <Star className='h-3 w-3' />
<span>{starCount}</span> <span>{starCount} stars</span>
</div> </div>
{/* Author */} {/* Author */}
@@ -332,6 +399,14 @@ export default function TemplateDetails({
<User className='h-3 w-3' /> <User className='h-3 w-3' />
<span>by {template.author}</span> <span>by {template.author}</span>
</div> </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> </div>
</div> </div>

View File

@@ -130,6 +130,8 @@ interface TemplateCardProps {
onTemplateUsed?: () => void onTemplateUsed?: () => void
// Callback when star state changes (for parent state updates) // Callback when star state changes (for parent state updates)
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
// User authentication status
isAuthenticated?: boolean
} }
// Skeleton component for loading states // Skeleton component for loading states
@@ -249,6 +251,7 @@ export function TemplateCard({
isStarred = false, isStarred = false,
onTemplateUsed, onTemplateUsed,
onStarChange, onStarChange,
isAuthenticated = true,
}: TemplateCardProps) { }: TemplateCardProps) {
const router = useRouter() const router = useRouter()
const params = useParams() 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) => { const handleUseClick = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
try { router.push(`/templates/${id}`)
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 handleCardClick = (e: React.MouseEvent) => {
const data = await response.json() // Don't navigate if clicking on action buttons
logger.info('Template use API response:', data) const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[data-action]')) {
if (!data.workflowId) { return
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)
} }
router.push(`/templates/${id}`)
} }
return ( return (
<div <div
onClick={handleCardClick}
className={cn( 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]', 'flex h-[142px]',
className className
)} )}
@@ -396,18 +374,21 @@ export function TemplateCard({
</h3> </h3>
</div> </div>
{/* Star and Use button */} {/* Actions */}
<div className='flex flex-shrink-0 items-center gap-3'> <div className='flex flex-shrink-0 items-center gap-2'>
<Star {/* Star button - only for authenticated users */}
onClick={handleStarClick} {isAuthenticated && (
className={cn( <Star
'h-4 w-4 cursor-pointer transition-colors duration-50', onClick={handleStarClick}
localIsStarred className={cn(
? 'fill-yellow-400 text-yellow-400' 'h-4 w-4 cursor-pointer transition-colors duration-50',
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400', localIsStarred
isStarLoading && 'opacity-50' ? 'fill-yellow-400 text-yellow-400'
)} : 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
/> isStarLoading && 'opacity-50'
)}
/>
)}
<button <button
onClick={handleUseClick} onClick={handleUseClick}
className={cn( className={cn(

View File

@@ -1,47 +1,6 @@
import { db } from '@sim/db' import { redirect } from 'next/navigation'
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'
export default async function TemplatesPage() { export default async function TemplatesPage() {
const session = await getSession() // Redirect all users to the root templates page
redirect('/templates')
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}
/>
)
} }

View File

@@ -1,8 +1,7 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useState } from 'react'
import { ChevronRight, Search } from 'lucide-react' import { Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { NavigationTabs } from '@/app/workspace/[workspaceId]/templates/components/navigation-tabs' 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') 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 // Template data structure
export interface Template { export interface Template {
id: string id: string
@@ -34,68 +21,38 @@ export interface Template {
name: string name: string
description: string | null description: string | null
author: string author: string
authorType: 'user' | 'organization'
organizationId: string | null
views: number views: number
stars: number stars: number
color: string color: string
icon: string icon: string
category: CategoryValue status: 'pending' | 'approved' | 'rejected'
state: WorkflowState state: WorkflowState
createdAt: Date | string createdAt: Date | string
updatedAt: Date | string updatedAt: Date | string
isStarred: boolean isStarred: boolean
isSuperUser?: boolean
} }
interface TemplatesProps { interface TemplatesProps {
initialTemplates: Template[] initialTemplates: Template[]
currentUserId: string currentUserId: string
isSuperUser: boolean
} }
export default function Templates({ initialTemplates, currentUserId }: TemplatesProps) { export default function Templates({
const router = useRouter() initialTemplates,
const params = useParams() currentUserId,
isSuperUser,
}: TemplatesProps) {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('your') const [activeTab, setActiveTab] = useState('your')
const [templates, setTemplates] = useState<Template[]>(initialTemplates) const [templates, setTemplates] = useState<Template[]>(initialTemplates)
const [loading, setLoading] = useState(false) 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) => { const handleTabClick = (tabId: string) => {
setActiveTab(tabId) 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 // Handle star change callback from template card
@@ -107,34 +64,39 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
) )
} }
const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => { // Get templates for the active tab with search filtering
let filteredByCategory = templates const getActiveTabTemplates = () => {
let filtered = templates
if (category === 'your') { // Filter by active tab
// For "your" templates, show templates created by you OR starred by you if (activeTab === 'your') {
filteredByCategory = templates.filter( filtered = filtered.filter(
(template) => template.userId === currentUserId || template.isStarred === true (template) => template.userId === currentUserId || template.isStarred === true
) )
} else if (category === 'recent') { } else if (activeTab === 'gallery') {
// For "recent" templates, show the 8 most recent templates // Show all approved templates
filteredByCategory = templates filtered = filtered.filter((template) => template.status === 'approved')
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) } else if (activeTab === 'pending') {
.slice(0, 8) // Show pending templates for super users
} else { filtered = filtered.filter((template) => template.status === 'pending')
filteredByCategory = templates.filter((template) => template.category === category)
} }
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( return filtered
(template) =>
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
)
} }
// Helper function to render template cards with proper type handling const activeTemplates = getActiveTabTemplates()
// Helper function to render template cards
const renderTemplateCard = (template: Template) => ( const renderTemplateCard = (template: Template) => (
<TemplateCard <TemplateCard
key={template.id} 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 }> }} state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
isStarred={template.isStarred} isStarred={template.isStarred}
onStarChange={handleStarChange} 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 // Render skeleton cards for loading state
const renderSkeletonCards = () => { const renderSkeletonCards = () => {
return Array.from({ length: 8 }).map((_, index) => ( 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 = [ 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', id: 'pending',
label: 'Your templates', label: 'Pending',
count: loading ? 8 : getTemplatesByCategory('your').length, 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 ( return (
@@ -250,124 +196,36 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
/> />
</div> </div>
{/* Your Templates Section */} {/* Templates Grid - Based on Active Tab */}
{yourTemplatesCount > 0 || loading ? ( <div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
<div ref={sectionRefs.your} className='mb-8'> {loading ? (
<div className='mb-4 flex items-center gap-2'> renderSkeletonCards()
<h2 className='font-medium font-sans text-foreground text-lg'>Your templates</h2> ) : activeTemplates.length === 0 ? (
<ChevronRight className='h-4 w-4 text-muted-foreground' /> <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>
) : (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'> activeTemplates.map((template) => renderTemplateCard(template))
{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>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,4 @@
export { ChatDeploy } from './chat-deploy/chat-deploy' export { ChatDeploy } from './chat-deploy/chat-deploy'
export { DeploymentInfo } from './deployment-info/deployment-info' export { DeploymentInfo } from './deployment-info/deployment-info'
export { ImageSelector } from './image-selector/image-selector' 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 { cn } from '@/lib/utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers' import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers' 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 { 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 { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
@@ -47,7 +50,7 @@ interface WorkflowDeploymentInfo {
needsRedeployment: boolean needsRedeployment: boolean
} }
type TabView = 'api' | 'versions' | 'chat' type TabView = 'api' | 'versions' | 'chat' | 'template'
export function DeployModal({ export function DeployModal({
open, open,
@@ -369,6 +372,9 @@ export function DeployModal({
setVersionToActivate(null) setVersionToActivate(null)
setApiDeployError(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) { } catch (error: unknown) {
logger.error('Error deploying workflow:', { error }) logger.error('Error deploying workflow:', { error })
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow' const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
@@ -687,6 +693,16 @@ export function DeployModal({
> >
Versions Versions
</button> </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>
</div> </div>
@@ -937,6 +953,10 @@ export function DeployModal({
onVersionActivated={() => setVersionToActivate(null)} onVersionActivated={() => setVersionToActivate(null)}
/> />
)} )}
{activeTab === 'template' && workflowId && (
<TemplateDeploy workflowId={workflowId} onDeploymentComplete={handleCloseModal} />
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -85,7 +85,6 @@ import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger' import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder' import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
import { categories } from '@/app/workspace/[workspaceId]/templates/templates'
const logger = createLogger('TemplateModal') const logger = createLogger('TemplateModal')
@@ -99,13 +98,19 @@ const templateSchema = z.object({
.string() .string()
.min(1, 'Author is required') .min(1, 'Author is required')
.max(100, 'Author must be less than 100 characters'), .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'), 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)'), 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> type TemplateFormData = z.infer<typeof templateSchema>
interface Organization {
id: string
name: string
}
interface TemplateModalProps { interface TemplateModalProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@@ -180,6 +185,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false) const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [organizations, setOrganizations] = useState<Organization[]>([])
const [loadingOrgs, setLoadingOrgs] = useState(false)
const form = useForm<TemplateFormData>({ const form = useForm<TemplateFormData>({
resolver: zodResolver(templateSchema), resolver: zodResolver(templateSchema),
@@ -187,7 +194,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
name: '', name: '',
description: '', description: '',
author: session?.user?.name || session?.user?.email || '', author: session?.user?.name || session?.user?.email || '',
category: '', authorType: 'user',
organizationId: undefined,
icon: 'FileText', icon: 'FileText',
color: '#3972F6', color: '#3972F6',
}, },
@@ -195,12 +203,37 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
// Watch form state to determine if all required fields are valid // Watch form state to determine if all required fields are valid
const formValues = form.watch() const formValues = form.watch()
const authorType = form.watch('authorType')
const isFormValid = const isFormValid =
form.formState.isValid && form.formState.isValid &&
formValues.name?.trim() && formValues.name?.trim() &&
formValues.description?.trim() && formValues.description?.trim() &&
formValues.author?.trim() && formValues.author?.trim()
formValues.category
// 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 // Check for existing template when modal opens
useEffect(() => { useEffect(() => {
@@ -224,7 +257,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
name: template.name, name: template.name,
description: template.description, description: template.description,
author: template.author, author: template.author,
category: template.category, authorType: template.authorType || 'user',
organizationId: template.organizationId || undefined,
icon: template.icon, icon: template.icon,
color: template.color, color: template.color,
}) })
@@ -236,7 +270,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
name: '', name: '',
description: '', description: '',
author: session?.user?.name || session?.user?.email || '', author: session?.user?.name || session?.user?.email || '',
category: '', authorType: 'user',
organizationId: undefined,
icon: 'FileText', icon: 'FileText',
color: '#3972F6', color: '#3972F6',
}) })
@@ -267,7 +302,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
name: data.name, name: data.name,
description: data.description || '', description: data.description || '',
author: data.author, author: data.author,
category: data.category, authorType: data.authorType,
organizationId: data.organizationId,
icon: data.icon, icon: data.icon,
color: data.color, color: data.color,
state: templateState, state: templateState,
@@ -400,14 +436,14 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
<Skeleton className='h-10 w-full' /> {/* Input */} <Skeleton className='h-10 w-full' /> {/* Input */}
</div> </div>
{/* Author and Category row */} {/* Author and Author Type row */}
<div className='grid grid-cols-2 gap-4'> <div className='grid grid-cols-2 gap-4'>
<div> <div>
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */} <Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
<Skeleton className='h-10 w-full' /> {/* Input */} <Skeleton className='h-10 w-full' /> {/* Input */}
</div> </div>
<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 */} <Skeleton className='h-10 w-full' /> {/* Select */}
</div> </div>
</div> </div>
@@ -535,24 +571,30 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
<FormField <FormField
control={form.control} control={form.control}
name='category' name='authorType'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className='!text-foreground font-medium text-sm'> <FormLabel className='!text-foreground font-medium text-sm'>
Category Author Type
</FormLabel> </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> <FormControl>
<SelectTrigger className='h-10 rounded-[8px]'> <SelectTrigger className='h-10 rounded-[8px]'>
<SelectValue placeholder='Select a category' /> <SelectValue placeholder='Select author type' />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{categories.map((category) => ( <SelectItem value='user'>User</SelectItem>
<SelectItem key={category.value} value={category.value}> <SelectItem value='organization'>Organization</SelectItem>
{category.label}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -561,6 +603,46 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
/> />
</div> </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 <FormField
control={form.control} control={form.control}
name='description' name='description'

View File

@@ -10,7 +10,6 @@ import {
RefreshCw, RefreshCw,
SkipForward, SkipForward,
StepForward, StepForward,
Store,
Trash2, Trash2,
Webhook, Webhook,
WifiOff, WifiOff,
@@ -39,7 +38,6 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import { import {
DeploymentControls, DeploymentControls,
ExportControls, ExportControls,
TemplateModal,
WebhookSettings, WebhookSettings,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components' } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' 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 [mounted, setMounted] = useState(false)
const [, forceUpdate] = useState({}) const [, forceUpdate] = useState({})
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
const [isWebhookSettingsOpen, setIsWebhookSettingsOpen] = useState(false) const [isWebhookSettingsOpen, setIsWebhookSettingsOpen] = useState(false)
const [isAutoLayouting, setIsAutoLayouting] = useState(false) const [isAutoLayouting, setIsAutoLayouting] = useState(false)
@@ -646,24 +643,28 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> <AlertDialogTitle>
{deleteState.showTemplateChoice ? 'Published Templates Found' : 'Delete workflow?'} {deleteState.showTemplateChoice ? 'Template Connected' : 'Delete workflow?'}
</AlertDialogTitle> </AlertDialogTitle>
{deleteState.showTemplateChoice ? ( {deleteState.showTemplateChoice ? (
<div className='space-y-3'> <div className='space-y-3'>
<AlertDialogDescription> <AlertDialogDescription asChild>
This workflow has {deleteState.publishedTemplates.length} published template <div className='space-y-2'>
{deleteState.publishedTemplates.length > 1 ? 's' : ''}: <div>
</AlertDialogDescription> This workflow is connected to a template:{' '}
{deleteState.publishedTemplates.length > 0 && ( <strong>{deleteState.publishedTemplates[0]?.name}</strong>
<ul className='list-disc space-y-1 pl-6'> </div>
{deleteState.publishedTemplates.map((template) => ( <div className='mt-3'>What would you like to do with it?</div>
<li key={template.id}>{template.name}</li> <div className='mt-2 space-y-1 text-xs'>
))} <div className='text-muted-foreground'>
</ul> <strong>Keep template:</strong> Template remains in the marketplace. You can
)} reconnect it later by clicking "Edit" on the template.
<AlertDialogDescription> </div>
What would you like to do with the published template <div className='text-muted-foreground'>
{deleteState.publishedTemplates.length > 1 ? 's' : ''}? <strong>Delete template:</strong> Permanently remove template from the
marketplace.
</div>
</div>
</div>
</AlertDialogDescription> </AlertDialogDescription>
</div> </div>
) : ( ) : (
@@ -685,14 +686,14 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
disabled={deleteState.isDeleting} disabled={deleteState.isDeleting}
className='h-9 flex-1 rounded-[8px]' className='h-9 flex-1 rounded-[8px]'
> >
Keep templates Keep template
</Button> </Button>
<Button <Button
onClick={() => handleTemplateAction('delete')} onClick={() => handleTemplateAction('delete')}
disabled={deleteState.isDeleting} 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' 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> </Button>
</div> </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 * Render debug mode toggle button
*/ */
@@ -1273,22 +1237,12 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
{isExpanded && renderWebhookButton()} {isExpanded && renderWebhookButton()}
{isExpanded && <ExportControls />} {isExpanded && <ExportControls />}
{isExpanded && renderAutoLayoutButton()} {isExpanded && renderAutoLayoutButton()}
{isExpanded && renderPublishButton()}
{renderDeleteButton()} {renderDeleteButton()}
{renderDuplicateButton()} {renderDuplicateButton()}
{!isDebugging && renderDebugModeToggle()} {!isDebugging && renderDebugModeToggle()}
{renderDeployButton()} {renderDeployButton()}
{isDebugging ? renderDebugControlsBar() : renderRunButton()} {isDebugging ? renderDebugControlsBar() : renderRunButton()}
{/* Template Modal */}
{activeWorkflowId && (
<TemplateModal
open={isTemplateModalOpen}
onOpenChange={setIsTemplateModalOpen}
workflowId={activeWorkflowId}
/>
)}
{/* Webhook Settings */} {/* Webhook Settings */}
{activeWorkflowId && ( {activeWorkflowId && (
<WebhookSettings <WebhookSettings

View File

@@ -26,6 +26,7 @@ export const NavigationItem = ({ item }: NavigationItemProps) => {
<Button <Button
variant='outline' variant='outline'
onClick={item.onClick} onClick={item.onClick}
data-settings-button={item.id === 'settings' ? '' : undefined}
className={cn( className={cn(
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200', 'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
isGrayHover && 'hover:bg-secondary', 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 { Info } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
@@ -12,6 +12,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useSession } from '@/lib/auth-client'
import { getEnv, isTruthy } from '@/lib/env' import { getEnv, isTruthy } from '@/lib/env'
import { useGeneralStore } from '@/stores/settings/general/store' 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.', 'Show floating controls for zoom, undo, and redo at the bottom of the workflow canvas.',
trainingControls: trainingControls:
'Show training controls for recording workflow edits to build copilot training datasets.', '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() { export function General() {
const { data: session } = useSession()
const [isSuperUser, setIsSuperUser] = useState(false)
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
const isLoading = useGeneralStore((state) => state.isLoading) const isLoading = useGeneralStore((state) => state.isLoading)
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED')) const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
const theme = useGeneralStore((state) => state.theme) const theme = useGeneralStore((state) => state.theme)
@@ -36,6 +43,7 @@ export function General() {
const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault) const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault)
const showFloatingControls = useGeneralStore((state) => state.showFloatingControls) const showFloatingControls = useGeneralStore((state) => state.showFloatingControls)
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls) const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
const superUserModeEnabled = useGeneralStore((state) => state.superUserModeEnabled)
// Loading states // Loading states
const isAutoConnectLoading = useGeneralStore((state) => state.isAutoConnectLoading) const isAutoConnectLoading = useGeneralStore((state) => state.isAutoConnectLoading)
@@ -47,6 +55,7 @@ export function General() {
const isThemeLoading = useGeneralStore((state) => state.isThemeLoading) const isThemeLoading = useGeneralStore((state) => state.isThemeLoading)
const isFloatingControlsLoading = useGeneralStore((state) => state.isFloatingControlsLoading) const isFloatingControlsLoading = useGeneralStore((state) => state.isFloatingControlsLoading)
const isTrainingControlsLoading = useGeneralStore((state) => state.isTrainingControlsLoading) const isTrainingControlsLoading = useGeneralStore((state) => state.isTrainingControlsLoading)
const isSuperUserModeLoading = useGeneralStore((state) => state.isSuperUserModeLoading)
const setTheme = useGeneralStore((state) => state.setTheme) const setTheme = useGeneralStore((state) => state.setTheme)
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect) const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
@@ -57,6 +66,34 @@ export function General() {
) )
const toggleFloatingControls = useGeneralStore((state) => state.toggleFloatingControls) const toggleFloatingControls = useGeneralStore((state) => state.toggleFloatingControls)
const toggleTrainingControls = useGeneralStore((state) => state.toggleTrainingControls) 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 // Sync theme from store to next-themes when theme changes
useEffect(() => { useEffect(() => {
@@ -327,6 +364,39 @@ export function General() {
/> />
</div> </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> </div>

View File

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

View File

@@ -7,6 +7,7 @@ import {
Home, Home,
Key, Key,
LogIn, LogIn,
Palette,
Server, Server,
Settings, Settings,
Shield, Shield,
@@ -32,6 +33,7 @@ interface SettingsNavigationProps {
| 'general' | 'general'
| 'environment' | 'environment'
| 'account' | 'account'
| 'creator-profile'
| 'credentials' | 'credentials'
| 'apikeys' | 'apikeys'
| 'files' | 'files'
@@ -51,6 +53,7 @@ type NavigationItem = {
| 'general' | 'general'
| 'environment' | 'environment'
| 'account' | 'account'
| 'creator-profile'
| 'credentials' | 'credentials'
| 'apikeys' | 'apikeys'
| 'files' | 'files'
@@ -100,6 +103,11 @@ const allNavigationItems: NavigationItem[] = [
label: 'Account', label: 'Account',
icon: User, icon: User,
}, },
{
id: 'creator-profile',
label: 'Creator Profile',
icon: Palette,
},
{ {
id: 'apikeys', id: 'apikeys',
label: 'API Keys', label: 'API Keys',
@@ -231,6 +239,7 @@ export function SettingsNavigation({
} }
}} }}
onClick={() => onSectionChange(item.id)} onClick={() => onSectionChange(item.id)}
data-section={item.id}
className={cn( 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', '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' activeSection === item.id ? 'bg-muted' : 'hover:bg-muted'

View File

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

View File

@@ -132,6 +132,7 @@ export {
} from './table' } from './table'
export { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs' export { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs'
export { checkTagTrigger, TagDropdown } from './tag-dropdown' export { checkTagTrigger, TagDropdown } from './tag-dropdown'
export { TagInput } from './tag-input'
export { Textarea } from './textarea' export { Textarea } from './textarea'
export { Toggle, toggleVariants } from './toggle' export { Toggle, toggleVariants } from './toggle'
export { ToolCallCompletion, ToolCallExecution } from './tool-call' 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( const refreshTools = useCallback(
async (forceRefresh = false) => { async (forceRefresh = false) => {
// Skip if no workspaceId (e.g., on template preview pages)
if (!workspaceId) {
setMcpTools([])
setIsLoading(false)
return
}
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)

View File

@@ -427,9 +427,7 @@ async function processTemplateFromDb(
.select({ .select({
id: templates.id, id: templates.id,
name: templates.name, name: templates.name,
description: templates.description, details: templates.details,
category: templates.category,
author: templates.author,
stars: templates.stars, stars: templates.stars,
state: templates.state, state: templates.state,
}) })
@@ -438,14 +436,11 @@ async function processTemplateFromDb(
.limit(1) .limit(1)
const t = rows?.[0] const t = rows?.[0]
if (!t) return null if (!t) return null
const workflowState = (t as any).state || {} const workflowState = t.state || {}
// Match get-user-workflow format: just the workflow state JSON
const summary = { const summary = {
id: t.id, id: t.id,
name: t.name, name: t.name,
description: t.description || '', description: (t.details as any)?.tagline || '',
category: t.category,
author: t.author,
stars: t.stars || 0, stars: t.stars || 0,
workflow: workflowState, 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 { import {
db, db,
workflow, workflow,
@@ -403,11 +404,19 @@ export async function deployWorkflow(params: {
return { success: false, error: 'Failed to load workflow state' } 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 = { const currentState = {
blocks: normalizedData.blocks, blocks: normalizedData.blocks,
edges: normalizedData.edges, edges: normalizedData.edges,
loops: normalizedData.loops, loops: normalizedData.loops,
parallels: normalizedData.parallels, parallels: normalizedData.parallels,
variables: workflowRecord?.variables || undefined,
lastSaved: Date.now(), lastSaved: Date.now(),
} }
@@ -447,6 +456,9 @@ export async function deployWorkflow(params: {
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) 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 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 type { Edge } from 'reactflow'
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credential-extractor'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types' 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 * Users need positions to restore the visual layout when importing
*/ */
export function sanitizeForExport(state: WorkflowState): ExportWorkflowState { export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
const clonedState = JSON.parse( // Preserve edges, loops, parallels, metadata, and variables
JSON.stringify({ const fullState = {
blocks: state.blocks, blocks: state.blocks,
edges: state.edges, edges: state.edges,
loops: state.loops || {}, loops: state.loops || {},
parallels: state.parallels || {}, parallels: state.parallels || {},
metadata: state.metadata, metadata: state.metadata,
variables: state.variables, variables: state.variables,
}) }
)
Object.values(clonedState.blocks).forEach((block: any) => { // Use unified sanitization with env var preservation for export
if (block.subBlocks) { const sanitizedState = sanitizeWorkflowForSharing(fullState, {
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows
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] = ''
}
})
}
}) })
return { return {
version: '1.0', version: '1.0',
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
state: clonedState, state: sanitizedState,
} }
} }

View File

@@ -151,6 +151,11 @@ export async function middleware(request: NextRequest) {
return NextResponse.next() 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 (url.pathname.startsWith('/workspace')) {
if (!hasActiveSession) { if (!hasActiveSession) {
return NextResponse.redirect(new URL('/login', request.url)) return NextResponse.redirect(new URL('/login', request.url))

View File

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

View File

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

View File

@@ -475,6 +475,18 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
useWorkflowStore.setState(workflowState) useWorkflowStore.setState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(id, (workflowState as any).blocks || {}) 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( window.dispatchEvent(
new CustomEvent('active-workflow-changed', { new CustomEvent('active-workflow-changed', {
detail: { workflowId: id }, 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, "when": 1762371130884,
"tag": "0106_bitter_captain_midlands", "tag": "0106_bitter_captain_midlands",
"breakpoints": true "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(), createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(), updatedAt: timestamp('updated_at').notNull(),
stripeCustomerId: text('stripe_customer_id'), stripeCustomerId: text('stripe_customer_id'),
isSuperUser: boolean('is_super_user').notNull().default(false),
}) })
export const session = pgTable( export const session = pgTable(
@@ -423,6 +424,7 @@ export const settings = pgTable('settings', {
// UI preferences // UI preferences
showFloatingControls: boolean('show_floating_controls').notNull().default(true), showFloatingControls: boolean('show_floating_controls').notNull().default(true),
showTrainingControls: boolean('show_training_controls').notNull().default(false), 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 // Copilot preferences - maps model_id to enabled/disabled boolean
copilotEnabledModels: jsonb('copilot_enabled_models').notNull().default('{}'), 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( export const templates = pgTable(
'templates', 'templates',
{ {
id: text('id').primaryKey(), id: text('id').primaryKey(),
workflowId: text('workflow_id').references(() => workflow.id), workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
name: text('name').notNull(), name: text('name').notNull(),
description: text('description'), details: jsonb('details'),
author: text('author').notNull(), creatorId: text('creator_id').references(() => templateCreators.id, { onDelete: 'set null' }),
views: integer('views').notNull().default(0), views: integer('views').notNull().default(0),
stars: integer('stars').notNull().default(0), stars: integer('stars').notNull().default(0),
color: text('color').notNull().default('#3972F6'), status: templateStatusEnum('status').notNull().default('pending'),
icon: text('icon').notNull().default('FileText'), // Lucide icon name as string tags: text('tags').array().notNull().default(sql`'{}'::text[]`), // Array of tags
category: text('category').notNull(), requiredCredentials: jsonb('required_credentials').notNull().default('[]'), // Array of credential requirements
state: jsonb('state').notNull(), // Using jsonb for better performance state: jsonb('state').notNull(), // Store the workflow state directly
createdAt: timestamp('created_at').notNull().defaultNow(), createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(),
}, },
(table) => ({ (table) => ({
// Primary access patterns // Primary access patterns
workflowIdIdx: index('templates_workflow_id_idx').on(table.workflowId), statusIdx: index('templates_status_idx').on(table.status),
userIdIdx: index('templates_user_id_idx').on(table.userId), creatorIdIdx: index('templates_creator_id_idx').on(table.creatorId),
categoryIdx: index('templates_category_idx').on(table.category),
// Sorting indexes for popular/trending templates // Sorting indexes for popular/trending templates
viewsIdx: index('templates_views_idx').on(table.views), viewsIdx: index('templates_views_idx').on(table.views),
starsIdx: index('templates_stars_idx').on(table.stars), starsIdx: index('templates_stars_idx').on(table.stars),
// Composite indexes for common queries // Composite indexes for common queries
categoryViewsIdx: index('templates_category_views_idx').on(table.category, table.views), statusViewsIdx: index('templates_status_views_idx').on(table.status, table.views),
categoryStarsIdx: index('templates_category_stars_idx').on(table.category, table.stars), statusStarsIdx: index('templates_status_stars_idx').on(table.status, table.stars),
userCategoryIdx: index('templates_user_category_idx').on(table.userId, table.category),
// Temporal indexes // Temporal indexes
createdAtIdx: index('templates_created_at_idx').on(table.createdAt), createdAtIdx: index('templates_created_at_idx').on(table.createdAt),