mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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:
committed by
GitHub
parent
6cdee5351c
commit
a73e2aaa8b
180
apps/sim/app/api/creator-profiles/[id]/route.ts
Normal file
180
apps/sim/app/api/creator-profiles/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
194
apps/sim/app/api/creator-profiles/route.ts
Normal file
194
apps/sim/app/api/creator-profiles/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,12 @@ export async function verifyFileAccess(
|
||||
// Infer context from key if not explicitly provided
|
||||
const inferredContext = context || inferContextFromKey(cloudKey)
|
||||
|
||||
// 0. Profile pictures: Public access (anyone can view creator profile pictures)
|
||||
if (inferredContext === 'profile-pictures') {
|
||||
logger.info('Profile picture access allowed (public)', { cloudKey })
|
||||
return true
|
||||
}
|
||||
|
||||
// 1. Workspace files: Check database first (most reliable for both local and cloud)
|
||||
if (inferredContext === 'workspace') {
|
||||
return await verifyWorkspaceFileAccess(cloudKey, userId, customConfig, isLocal)
|
||||
|
||||
@@ -31,6 +31,25 @@ export async function GET(
|
||||
|
||||
logger.info('File serve request:', { path })
|
||||
|
||||
const fullPath = path.join('/')
|
||||
const isS3Path = path[0] === 's3'
|
||||
const isBlobPath = path[0] === 'blob'
|
||||
const isCloudPath = isS3Path || isBlobPath
|
||||
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
|
||||
|
||||
const contextParam = request.nextUrl.searchParams.get('context')
|
||||
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
|
||||
|
||||
const context = contextParam || (isCloudPath ? inferContextFromKey(cloudKey) : undefined)
|
||||
|
||||
if (context === 'profile-pictures') {
|
||||
logger.info('Serving public profile picture:', { cloudKey })
|
||||
if (isUsingCloudStorage() || isCloudPath) {
|
||||
return await handleCloudProxyPublic(cloudKey, context, legacyBucketType)
|
||||
}
|
||||
return await handleLocalFilePublic(fullPath)
|
||||
}
|
||||
|
||||
const authResult = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
@@ -42,14 +61,6 @@ export async function GET(
|
||||
}
|
||||
|
||||
const userId = authResult.userId
|
||||
const fullPath = path.join('/')
|
||||
const isS3Path = path[0] === 's3'
|
||||
const isBlobPath = path[0] === 'blob'
|
||||
const isCloudPath = isS3Path || isBlobPath
|
||||
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
|
||||
|
||||
const contextParam = request.nextUrl.searchParams.get('context')
|
||||
const legacyBucketType = request.nextUrl.searchParams.get('bucket')
|
||||
|
||||
if (isUsingCloudStorage() || isCloudPath) {
|
||||
return await handleCloudProxy(cloudKey, userId, contextParam, legacyBucketType)
|
||||
@@ -174,3 +185,64 @@ async function handleCloudProxy(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCloudProxyPublic(
|
||||
cloudKey: string,
|
||||
context: StorageContext,
|
||||
legacyBucketType?: string | null
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
let fileBuffer: Buffer
|
||||
|
||||
if (context === 'copilot') {
|
||||
fileBuffer = await CopilotFiles.downloadCopilotFile(cloudKey)
|
||||
} else {
|
||||
fileBuffer = await downloadFile({
|
||||
key: cloudKey,
|
||||
context,
|
||||
})
|
||||
}
|
||||
|
||||
const originalFilename = cloudKey.split('/').pop() || 'download'
|
||||
const contentType = getContentType(originalFilename)
|
||||
|
||||
logger.info('Public cloud file served', {
|
||||
key: cloudKey,
|
||||
size: fileBuffer.length,
|
||||
context,
|
||||
})
|
||||
|
||||
return createFileResponse({
|
||||
buffer: fileBuffer,
|
||||
contentType,
|
||||
filename: originalFilename,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error serving public cloud file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLocalFilePublic(filename: string): Promise<NextResponse> {
|
||||
try {
|
||||
const filePath = findLocalFile(filename)
|
||||
|
||||
if (!filePath) {
|
||||
throw new FileNotFoundError(`File not found: ${filename}`)
|
||||
}
|
||||
|
||||
const fileBuffer = await readFile(filePath)
|
||||
const contentType = getContentType(filename)
|
||||
|
||||
logger.info('Public local file served', { filename, size: fileBuffer.length })
|
||||
|
||||
return createFileResponse({
|
||||
buffer: fileBuffer,
|
||||
contentType,
|
||||
filename,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error reading public local file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
106
apps/sim/app/api/templates/[id]/approve/route.ts
Normal file
106
apps/sim/app/api/templates/[id]/approve/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
57
apps/sim/app/api/templates/[id]/reject/route.ts
Normal file
57
apps/sim/app/api/templates/[id]/reject/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templates, workflow } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { member, templateCreators, templates, workflow } from '@sim/db/schema'
|
||||
import { and, eq, or, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { hasAdminPermission } from '@/lib/permissions/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import {
|
||||
extractRequiredCredentials,
|
||||
sanitizeCredentials,
|
||||
} from '@/lib/workflows/credential-extractor'
|
||||
|
||||
const logger = createLogger('TemplateByIdAPI')
|
||||
|
||||
@@ -19,45 +22,76 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized template access attempt for ID: ${id}`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Fetching template: ${id}`)
|
||||
|
||||
// Fetch the template by ID
|
||||
const result = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
// Fetch the template by ID with creator info
|
||||
const result = await db
|
||||
.select({
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (result.length === 0) {
|
||||
logger.warn(`[${requestId}] Template not found: ${id}`)
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const template = result[0]
|
||||
const { template, creator } = result[0]
|
||||
const templateWithCreator = {
|
||||
...template,
|
||||
creator: creator || undefined,
|
||||
}
|
||||
|
||||
// Increment the view count
|
||||
try {
|
||||
await db
|
||||
.update(templates)
|
||||
.set({
|
||||
views: sql`${templates.views} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
// Only show approved templates to non-authenticated users
|
||||
if (!session?.user?.id && template.status !== 'approved') {
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
|
||||
} catch (viewError) {
|
||||
// Log the error but don't fail the request
|
||||
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
|
||||
// Check if user has starred (only if authenticated)
|
||||
let isStarred = false
|
||||
if (session?.user?.id) {
|
||||
const { templateStars } = await import('@sim/db/schema')
|
||||
const starResult = await db
|
||||
.select()
|
||||
.from(templateStars)
|
||||
.where(
|
||||
sql`${templateStars.templateId} = ${id} AND ${templateStars.userId} = ${session.user.id}`
|
||||
)
|
||||
.limit(1)
|
||||
isStarred = starResult.length > 0
|
||||
}
|
||||
|
||||
const shouldIncrementView = template.status === 'approved'
|
||||
|
||||
if (shouldIncrementView) {
|
||||
try {
|
||||
await db
|
||||
.update(templates)
|
||||
.set({
|
||||
views: sql`${templates.views} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
|
||||
logger.debug(`[${requestId}] Incremented view count for template: ${id}`)
|
||||
} catch (viewError) {
|
||||
// Log the error but don't fail the request
|
||||
logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Successfully retrieved template: ${id}`)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...template,
|
||||
views: template.views + 1, // Return the incremented view count
|
||||
...templateWithCreator,
|
||||
views: template.views + (shouldIncrementView ? 1 : 0),
|
||||
isStarred,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
@@ -67,13 +101,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const updateTemplateSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(500),
|
||||
author: z.string().min(1).max(100),
|
||||
category: z.string().min(1),
|
||||
icon: z.string().min(1),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i),
|
||||
state: z.any().optional(), // Workflow state
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
details: z
|
||||
.object({
|
||||
tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(),
|
||||
about: z.string().optional(), // Markdown long description
|
||||
})
|
||||
.optional(),
|
||||
creatorId: z.string().optional(), // Creator profile ID
|
||||
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional(),
|
||||
updateState: z.boolean().optional(), // Explicitly request state update from current workflow
|
||||
})
|
||||
|
||||
// PUT /api/templates/[id] - Update a template
|
||||
@@ -99,7 +136,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
)
|
||||
}
|
||||
|
||||
const { name, description, author, category, icon, color, state } = validationResult.data
|
||||
const { name, details, creatorId, tags, updateState } = validationResult.data
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1)
|
||||
@@ -109,41 +146,63 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Template not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Permission: template owner OR admin of the workflow's workspace (if any)
|
||||
let canUpdate = existingTemplate[0].userId === session.user.id
|
||||
// No permission check needed - template updates only happen from within the workspace
|
||||
// where the user is already editing the connected workflow
|
||||
|
||||
if (!canUpdate && existingTemplate[0].workflowId) {
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingTemplate[0].workflowId))
|
||||
.limit(1)
|
||||
// Prepare update data - only include fields that were provided
|
||||
const updateData: any = {
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
|
||||
if (workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
|
||||
if (hasAdmin) canUpdate = true
|
||||
// Only update fields that were provided
|
||||
if (name !== undefined) updateData.name = name
|
||||
if (details !== undefined) updateData.details = details
|
||||
if (tags !== undefined) updateData.tags = tags
|
||||
if (creatorId !== undefined) updateData.creatorId = creatorId
|
||||
|
||||
// Only update the state if explicitly requested and the template has a connected workflow
|
||||
if (updateState && existingTemplate[0].workflowId) {
|
||||
// Load the current workflow state from normalized tables
|
||||
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/db-helpers')
|
||||
const normalizedData = await loadWorkflowFromNormalizedTables(existingTemplate[0].workflowId)
|
||||
|
||||
if (normalizedData) {
|
||||
// Also fetch workflow variables
|
||||
const [workflowRecord] = await db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, existingTemplate[0].workflowId))
|
||||
.limit(1)
|
||||
|
||||
const currentState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
variables: workflowRecord?.variables || undefined,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
// Extract credential requirements from the new state
|
||||
const requiredCredentials = extractRequiredCredentials(currentState)
|
||||
|
||||
// Sanitize the state before storing
|
||||
const sanitizedState = sanitizeCredentials(currentState)
|
||||
|
||||
updateData.state = sanitizedState
|
||||
updateData.requiredCredentials = requiredCredentials
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Updating template state and credentials from current workflow: ${existingTemplate[0].workflowId}`
|
||||
)
|
||||
} else {
|
||||
logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!canUpdate) {
|
||||
logger.warn(`[${requestId}] User denied permission to update template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Update the template
|
||||
const updatedTemplate = await db
|
||||
.update(templates)
|
||||
.set({
|
||||
name,
|
||||
description,
|
||||
author,
|
||||
category,
|
||||
icon,
|
||||
color,
|
||||
...(state && { state }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.set(updateData)
|
||||
.where(eq(templates.id, id))
|
||||
.returning()
|
||||
|
||||
@@ -183,27 +242,41 @@ export async function DELETE(
|
||||
|
||||
const template = existing[0]
|
||||
|
||||
// Permission: owner or admin of the workflow's workspace (if any)
|
||||
let canDelete = template.userId === session.user.id
|
||||
|
||||
if (!canDelete && template.workflowId) {
|
||||
// Look up workflow to get workspaceId
|
||||
const wfRows = await db
|
||||
.select({ workspaceId: workflow.workspaceId })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, template.workflowId))
|
||||
// Permission: Only admin/owner of creator profile can delete
|
||||
if (template.creatorId) {
|
||||
const creatorProfile = await db
|
||||
.select()
|
||||
.from(templateCreators)
|
||||
.where(eq(templateCreators.id, template.creatorId))
|
||||
.limit(1)
|
||||
|
||||
const workspaceId = wfRows[0]?.workspaceId as string | null | undefined
|
||||
if (workspaceId) {
|
||||
const hasAdmin = await hasAdminPermission(session.user.id, workspaceId)
|
||||
if (hasAdmin) canDelete = true
|
||||
}
|
||||
}
|
||||
if (creatorProfile.length > 0) {
|
||||
const creator = creatorProfile[0]
|
||||
let hasPermission = false
|
||||
|
||||
if (!canDelete) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
if (creator.referenceType === 'user') {
|
||||
hasPermission = creator.referenceId === session.user.id
|
||||
} else if (creator.referenceType === 'organization') {
|
||||
// For delete, require admin/owner role
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(
|
||||
eq(member.userId, session.user.id),
|
||||
eq(member.organizationId, creator.referenceId),
|
||||
or(eq(member.role, 'admin'), eq(member.role, 'owner'))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
hasPermission = membership.length > 0
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.delete(templates).where(eq(templates.id, id))
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templates, workflow, workflowBlocks, workflowEdges } from '@sim/db/schema'
|
||||
import { templates, workflow, workflowDeploymentVersion } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import { regenerateWorkflowStateIds } from '@/lib/workflows/db-helpers'
|
||||
|
||||
const logger = createLogger('TemplateUseAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
|
||||
// Type for template details
|
||||
interface TemplateDetails {
|
||||
tagline?: string
|
||||
about?: string
|
||||
}
|
||||
|
||||
// POST /api/templates/[id]/use - Use a template (increment views and create workflow)
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
@@ -24,9 +32,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get workspace ID from request body
|
||||
// Get workspace ID and connectToTemplate flag from request body
|
||||
const body = await request.json()
|
||||
const { workspaceId } = body
|
||||
const { workspaceId, connectToTemplate = false } = body
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.warn(`[${requestId}] Missing workspaceId in request body`)
|
||||
@@ -34,17 +42,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}`
|
||||
`[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}, connect: ${connectToTemplate}`
|
||||
)
|
||||
|
||||
// Get the template with its data
|
||||
// Get the template
|
||||
const template = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
details: templates.details,
|
||||
state: templates.state,
|
||||
color: templates.color,
|
||||
workflowId: templates.workflowId,
|
||||
})
|
||||
.from(templates)
|
||||
.where(eq(templates.id, id))
|
||||
@@ -59,119 +67,106 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
// Create a new workflow ID
|
||||
const newWorkflowId = uuidv4()
|
||||
const now = new Date()
|
||||
|
||||
// Use a transaction to ensure consistency
|
||||
// Extract variables from the template state and remap to the new workflow
|
||||
const templateVariables = (templateData.state as any)?.variables as
|
||||
| Record<string, any>
|
||||
| undefined
|
||||
const remappedVariables: Record<string, any> = (() => {
|
||||
if (!templateVariables || typeof templateVariables !== 'object') return {}
|
||||
const mapped: Record<string, any> = {}
|
||||
for (const [, variable] of Object.entries(templateVariables)) {
|
||||
const newVarId = uuidv4()
|
||||
mapped[newVarId] = { ...variable, id: newVarId, workflowId: newWorkflowId }
|
||||
}
|
||||
return mapped
|
||||
})()
|
||||
|
||||
// Step 1: Create the workflow record (like imports do)
|
||||
await db.insert(workflow).values({
|
||||
id: newWorkflowId,
|
||||
workspaceId: workspaceId,
|
||||
name:
|
||||
connectToTemplate && !templateData.workflowId
|
||||
? templateData.name
|
||||
: `${templateData.name} (copy)`,
|
||||
description: (templateData.details as TemplateDetails | null)?.tagline || null,
|
||||
userId: session.user.id,
|
||||
variables: remappedVariables, // Remap variable IDs and workflowId for the new workflow
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastSynced: now,
|
||||
isDeployed: connectToTemplate && !templateData.workflowId,
|
||||
deployedAt: connectToTemplate && !templateData.workflowId ? now : null,
|
||||
})
|
||||
|
||||
// Step 2: Regenerate IDs when creating a copy (not when connecting/editing template)
|
||||
// When connecting to template (edit mode), keep original IDs
|
||||
// When using template (copy mode), regenerate all IDs to avoid conflicts
|
||||
const workflowState = connectToTemplate
|
||||
? templateData.state
|
||||
: regenerateWorkflowStateIds(templateData.state)
|
||||
|
||||
// Step 3: Save the workflow state using the existing state endpoint (like imports do)
|
||||
// Ensure variables in state are remapped for the new workflow as well
|
||||
const workflowStateWithVariables = { ...workflowState, variables: remappedVariables }
|
||||
const stateResponse = await fetch(`${getBaseUrl()}/api/workflows/${newWorkflowId}/state`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Forward the session cookie for authentication
|
||||
cookie: request.headers.get('cookie') || '',
|
||||
},
|
||||
body: JSON.stringify(workflowStateWithVariables),
|
||||
})
|
||||
|
||||
if (!stateResponse.ok) {
|
||||
logger.error(`[${requestId}] Failed to save workflow state for template use`)
|
||||
// Clean up the workflow we created
|
||||
await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create workflow from template' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Use a transaction for template updates and deployment version
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// Increment the template views
|
||||
await tx
|
||||
.update(templates)
|
||||
.set({
|
||||
views: sql`${templates.views} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(templates.id, id))
|
||||
// Prepare template update data
|
||||
const updateData: any = {
|
||||
views: sql`${templates.views} + 1`,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
// If connecting to template for editing, also update the workflowId
|
||||
// Also create a new deployment version for this workflow with the same state
|
||||
if (connectToTemplate && !templateData.workflowId) {
|
||||
updateData.workflowId = newWorkflowId
|
||||
|
||||
// Create a new workflow from the template
|
||||
const newWorkflow = await tx
|
||||
.insert(workflow)
|
||||
.values({
|
||||
id: newWorkflowId,
|
||||
workspaceId: workspaceId,
|
||||
name: `${templateData.name} (copy)`,
|
||||
description: templateData.description,
|
||||
color: templateData.color,
|
||||
userId: session.user.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastSynced: now,
|
||||
})
|
||||
.returning({ id: workflow.id })
|
||||
|
||||
// Create workflow_blocks entries from the template state
|
||||
const templateState = templateData.state as any
|
||||
if (templateState?.blocks) {
|
||||
// Create a mapping from old block IDs to new block IDs for reference updates
|
||||
const blockIdMap = new Map<string, string>()
|
||||
|
||||
const blockEntries = Object.values(templateState.blocks).map((block: any) => {
|
||||
const newBlockId = uuidv4()
|
||||
blockIdMap.set(block.id, newBlockId)
|
||||
|
||||
return {
|
||||
id: newBlockId,
|
||||
// Create a deployment version for the new workflow
|
||||
if (templateData.state) {
|
||||
const newDeploymentVersionId = uuidv4()
|
||||
await tx.insert(workflowDeploymentVersion).values({
|
||||
id: newDeploymentVersionId,
|
||||
workflowId: newWorkflowId,
|
||||
type: block.type,
|
||||
name: block.name,
|
||||
positionX: block.position?.x?.toString() || '0',
|
||||
positionY: block.position?.y?.toString() || '0',
|
||||
enabled: block.enabled !== false,
|
||||
horizontalHandles: block.horizontalHandles !== false,
|
||||
isWide: block.isWide || false,
|
||||
advancedMode: block.advancedMode || false,
|
||||
height: block.height?.toString() || '0',
|
||||
subBlocks: block.subBlocks || {},
|
||||
outputs: block.outputs || {},
|
||||
data: block.data || {},
|
||||
parentId: block.parentId ? blockIdMap.get(block.parentId) || null : null,
|
||||
extent: block.extent || null,
|
||||
version: 1,
|
||||
state: templateData.state,
|
||||
isActive: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
|
||||
// Create edge entries with new IDs
|
||||
const edgeEntries = (templateState.edges || []).map((edge: any) => ({
|
||||
id: uuidv4(),
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: blockIdMap.get(edge.source) || edge.source,
|
||||
targetBlockId: blockIdMap.get(edge.target) || edge.target,
|
||||
sourceHandle: edge.sourceHandle || null,
|
||||
targetHandle: edge.targetHandle || null,
|
||||
createdAt: now,
|
||||
}))
|
||||
|
||||
// Update the workflow state with new block IDs
|
||||
const updatedState = { ...templateState }
|
||||
if (updatedState.blocks) {
|
||||
const newBlocks: any = {}
|
||||
Object.entries(updatedState.blocks).forEach(([oldId, blockData]: [string, any]) => {
|
||||
const newId = blockIdMap.get(oldId)
|
||||
if (newId) {
|
||||
newBlocks[newId] = {
|
||||
...blockData,
|
||||
id: newId,
|
||||
}
|
||||
}
|
||||
createdBy: session.user.id,
|
||||
})
|
||||
updatedState.blocks = newBlocks
|
||||
}
|
||||
|
||||
// Update edges to use new block IDs
|
||||
if (updatedState.edges) {
|
||||
updatedState.edges = updatedState.edges.map((edge: any) => ({
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
}
|
||||
|
||||
// Insert blocks and edges
|
||||
if (blockEntries.length > 0) {
|
||||
await tx.insert(workflowBlocks).values(blockEntries)
|
||||
}
|
||||
if (edgeEntries.length > 0) {
|
||||
await tx.insert(workflowEdges).values(edgeEntries)
|
||||
}
|
||||
}
|
||||
|
||||
return newWorkflow[0]
|
||||
// Update template with view count and potentially new workflow connection
|
||||
await tx.update(templates).set(updateData).where(eq(templates.id, id))
|
||||
|
||||
return { id: newWorkflowId }
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}, database returned: ${result.id}`
|
||||
`[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}`
|
||||
)
|
||||
|
||||
// Track template usage
|
||||
@@ -191,18 +186,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
// Verify the workflow was actually created
|
||||
const verifyWorkflow = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, newWorkflowId))
|
||||
.limit(1)
|
||||
|
||||
if (verifyWorkflow.length === 0) {
|
||||
logger.error(`[${requestId}] Workflow was not created properly: ${newWorkflowId}`)
|
||||
return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: 'Template used successfully',
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateStars, templates, workflow } from '@sim/db/schema'
|
||||
import {
|
||||
member,
|
||||
templateCreators,
|
||||
templateStars,
|
||||
templates,
|
||||
user,
|
||||
workflow,
|
||||
workflowDeploymentVersion,
|
||||
} from '@sim/db/schema'
|
||||
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
@@ -7,78 +15,43 @@ import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
import {
|
||||
extractRequiredCredentials,
|
||||
sanitizeCredentials,
|
||||
} from '@/lib/workflows/credential-extractor'
|
||||
|
||||
const logger = createLogger('TemplatesAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
// Function to sanitize sensitive data from workflow state
|
||||
// Now uses the more comprehensive sanitizeCredentials from credential-extractor
|
||||
function sanitizeWorkflowState(state: any): any {
|
||||
const sanitizedState = JSON.parse(JSON.stringify(state)) // Deep clone
|
||||
|
||||
if (sanitizedState.blocks) {
|
||||
Object.values(sanitizedState.blocks).forEach((block: any) => {
|
||||
if (block.subBlocks) {
|
||||
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
|
||||
// Clear OAuth credentials and API keys using regex patterns
|
||||
if (
|
||||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) ||
|
||||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(
|
||||
subBlock.type || ''
|
||||
) ||
|
||||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(
|
||||
subBlock.value || ''
|
||||
)
|
||||
) {
|
||||
subBlock.value = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Also clear from data field if present
|
||||
if (block.data) {
|
||||
Object.entries(block.data).forEach(([key, value]: [string, any]) => {
|
||||
if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) {
|
||||
block.data[key] = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return sanitizedState
|
||||
return sanitizeCredentials(state)
|
||||
}
|
||||
|
||||
// Schema for creating a template
|
||||
const CreateTemplateSchema = z.object({
|
||||
workflowId: z.string().min(1, 'Workflow ID is required'),
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, 'Description is required')
|
||||
.max(500, 'Description must be less than 500 characters'),
|
||||
author: z
|
||||
.string()
|
||||
.min(1, 'Author is required')
|
||||
.max(100, 'Author must be less than 100 characters'),
|
||||
category: z.string().min(1, 'Category is required'),
|
||||
icon: z.string().min(1, 'Icon is required'),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
|
||||
state: z.object({
|
||||
blocks: z.record(z.any()),
|
||||
edges: z.array(z.any()),
|
||||
loops: z.record(z.any()),
|
||||
parallels: z.record(z.any()),
|
||||
}),
|
||||
details: z
|
||||
.object({
|
||||
tagline: z.string().max(500, 'Tagline must be less than 500 characters').optional(),
|
||||
about: z.string().optional(), // Markdown long description
|
||||
})
|
||||
.optional(),
|
||||
creatorId: z.string().optional(), // Creator profile ID
|
||||
tags: z.array(z.string()).max(10, 'Maximum 10 tags allowed').optional().default([]),
|
||||
})
|
||||
|
||||
// Schema for query parameters
|
||||
const QueryParamsSchema = z.object({
|
||||
category: z.string().optional(),
|
||||
limit: z.coerce.number().optional().default(50),
|
||||
offset: z.coerce.number().optional().default(0),
|
||||
search: z.string().optional(),
|
||||
workflowId: z.string().optional(),
|
||||
status: z.enum(['pending', 'approved', 'rejected']).optional(),
|
||||
includeAllStatuses: z.coerce.boolean().optional().default(false), // For super users
|
||||
})
|
||||
|
||||
// GET /api/templates - Retrieve templates
|
||||
@@ -97,27 +70,41 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
logger.debug(`[${requestId}] Fetching templates with params:`, params)
|
||||
|
||||
// Check if user is a super user
|
||||
const currentUser = await db.select().from(user).where(eq(user.id, session.user.id)).limit(1)
|
||||
const isSuperUser = currentUser[0]?.isSuperUser || false
|
||||
|
||||
// Build query conditions
|
||||
const conditions = []
|
||||
|
||||
// Apply category filter if provided
|
||||
if (params.category) {
|
||||
conditions.push(eq(templates.category, params.category))
|
||||
// Apply workflow filter if provided (for getting template by workflow)
|
||||
// When fetching by workflowId, we want to get the template regardless of status
|
||||
// This is used by the deploy modal to check if a template exists
|
||||
if (params.workflowId) {
|
||||
conditions.push(eq(templates.workflowId, params.workflowId))
|
||||
// Don't apply status filter when fetching by workflowId - we want to show
|
||||
// the template to its owner even if it's pending
|
||||
} else {
|
||||
// Apply status filter - only approved templates for non-super users
|
||||
if (params.status) {
|
||||
conditions.push(eq(templates.status, params.status))
|
||||
} else if (!isSuperUser || !params.includeAllStatuses) {
|
||||
// Non-super users and super users without includeAllStatuses flag see only approved templates
|
||||
conditions.push(eq(templates.status, 'approved'))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search filter if provided
|
||||
if (params.search) {
|
||||
const searchTerm = `%${params.search}%`
|
||||
conditions.push(
|
||||
or(ilike(templates.name, searchTerm), ilike(templates.description, searchTerm))
|
||||
or(
|
||||
ilike(templates.name, searchTerm),
|
||||
sql`${templates.details}->>'tagline' ILIKE ${searchTerm}`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Apply workflow filter if provided (for getting template by workflow)
|
||||
if (params.workflowId) {
|
||||
conditions.push(eq(templates.workflowId, params.workflowId))
|
||||
}
|
||||
|
||||
// Combine conditions
|
||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
@@ -126,25 +113,27 @@ export async function GET(request: NextRequest) {
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
userId: templates.userId,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
author: templates.author,
|
||||
details: templates.details,
|
||||
creatorId: templates.creatorId,
|
||||
creator: templateCreators,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
color: templates.color,
|
||||
icon: templates.icon,
|
||||
category: templates.category,
|
||||
status: templates.status,
|
||||
tags: templates.tags,
|
||||
requiredCredentials: templates.requiredCredentials,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
|
||||
isSuperUser: sql<boolean>`${isSuperUser}`, // Include super user status in response
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(
|
||||
templateStars,
|
||||
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
|
||||
)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
.limit(params.limit)
|
||||
@@ -200,7 +189,6 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.debug(`[${requestId}] Creating template:`, {
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
workflowId: data.workflowId,
|
||||
})
|
||||
|
||||
@@ -216,26 +204,116 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Validate creator profile if provided
|
||||
if (data.creatorId) {
|
||||
// Verify the creator profile exists and user has access
|
||||
const creatorProfile = await db
|
||||
.select()
|
||||
.from(templateCreators)
|
||||
.where(eq(templateCreators.id, data.creatorId))
|
||||
.limit(1)
|
||||
|
||||
if (creatorProfile.length === 0) {
|
||||
logger.warn(`[${requestId}] Creator profile not found: ${data.creatorId}`)
|
||||
return NextResponse.json({ error: 'Creator profile not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const creator = creatorProfile[0]
|
||||
|
||||
// Verify user has permission to use this creator profile
|
||||
if (creator.referenceType === 'user') {
|
||||
if (creator.referenceId !== session.user.id) {
|
||||
logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'You do not have permission to use this creator profile' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
} else if (creator.referenceType === 'organization') {
|
||||
// Verify user is a member of the organization
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.userId, session.user.id), eq(member.organizationId, creator.referenceId))
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (membership.length === 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] User not a member of organization for creator: ${data.creatorId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'You must be a member of the organization to use its creator profile' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the template
|
||||
const templateId = uuidv4()
|
||||
const now = new Date()
|
||||
|
||||
// Sanitize the workflow state to remove sensitive credentials
|
||||
const sanitizedState = sanitizeWorkflowState(data.state)
|
||||
// Get the active deployment version for the workflow to copy its state
|
||||
const activeVersion = await db
|
||||
.select({
|
||||
id: workflowDeploymentVersion.id,
|
||||
state: workflowDeploymentVersion.state,
|
||||
})
|
||||
.from(workflowDeploymentVersion)
|
||||
.where(
|
||||
and(
|
||||
eq(workflowDeploymentVersion.workflowId, data.workflowId),
|
||||
eq(workflowDeploymentVersion.isActive, true)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (activeVersion.length === 0) {
|
||||
logger.warn(
|
||||
`[${requestId}] No active deployment version found for workflow: ${data.workflowId}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: 'Workflow must be deployed before creating a template' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Ensure the state includes workflow variables (if not already included)
|
||||
let stateWithVariables = activeVersion[0].state as any
|
||||
if (stateWithVariables && !stateWithVariables.variables) {
|
||||
// Fetch workflow variables if not in deployment version
|
||||
const [workflowRecord] = await db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, data.workflowId))
|
||||
.limit(1)
|
||||
|
||||
stateWithVariables = {
|
||||
...stateWithVariables,
|
||||
variables: workflowRecord?.variables || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Extract credential requirements before sanitizing
|
||||
const requiredCredentials = extractRequiredCredentials(stateWithVariables)
|
||||
|
||||
// Sanitize the workflow state to remove all credential values
|
||||
const sanitizedState = sanitizeWorkflowState(stateWithVariables)
|
||||
|
||||
const newTemplate = {
|
||||
id: templateId,
|
||||
workflowId: data.workflowId,
|
||||
userId: session.user.id,
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
author: data.author,
|
||||
details: data.details || null,
|
||||
creatorId: data.creatorId || null,
|
||||
views: 0,
|
||||
stars: 0,
|
||||
color: data.color,
|
||||
icon: data.icon,
|
||||
category: data.category,
|
||||
state: sanitizedState,
|
||||
status: 'pending' as const, // All new templates start as pending
|
||||
tags: data.tags || [],
|
||||
requiredCredentials: requiredCredentials, // Store the extracted credential requirements
|
||||
state: sanitizedState, // Store the sanitized state without credential values
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -247,7 +325,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: templateId,
|
||||
message: 'Template created successfully',
|
||||
message: 'Template submitted for approval successfully',
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
|
||||
42
apps/sim/app/api/user/super-user/route.ts
Normal file
42
apps/sim/app/api/user/super-user/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const SettingsSchema = z.object({
|
||||
billingUsageNotificationsEnabled: z.boolean().optional(),
|
||||
showFloatingControls: z.boolean().optional(),
|
||||
showTrainingControls: z.boolean().optional(),
|
||||
superUserModeEnabled: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// Default settings values
|
||||
@@ -42,6 +43,7 @@ const defaultSettings = {
|
||||
billingUsageNotificationsEnabled: true,
|
||||
showFloatingControls: true,
|
||||
showTrainingControls: false,
|
||||
superUserModeEnabled: false,
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
@@ -78,6 +80,7 @@ export async function GET() {
|
||||
billingUsageNotificationsEnabled: userSettings.billingUsageNotificationsEnabled ?? true,
|
||||
showFloatingControls: userSettings.showFloatingControls ?? true,
|
||||
showTrainingControls: userSettings.showTrainingControls ?? false,
|
||||
superUserModeEnabled: userSettings.superUserModeEnabled ?? true,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
|
||||
@@ -142,6 +142,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
isDeployed: workflowData.isDeployed || false,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
},
|
||||
// Include workflow variables
|
||||
variables: workflowData.variables || {},
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Loaded workflow ${workflowId} from normalized tables`)
|
||||
@@ -218,7 +220,13 @@ export async function DELETE(
|
||||
if (checkTemplates) {
|
||||
// Return template information for frontend to handle
|
||||
const publishedTemplates = await db
|
||||
.select()
|
||||
.select({
|
||||
id: templates.id,
|
||||
name: templates.name,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
status: templates.status,
|
||||
})
|
||||
.from(templates)
|
||||
.where(eq(templates.workflowId, workflowId))
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ const WorkflowStateSchema = z.object({
|
||||
lastSaved: z.number().optional(),
|
||||
isDeployed: z.boolean().optional(),
|
||||
deployedAt: z.coerce.date().optional(),
|
||||
variables: z.any().optional(), // Workflow variables
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -227,14 +228,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
|
||||
logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId })
|
||||
}
|
||||
|
||||
// Update workflow's lastSynced timestamp
|
||||
await db
|
||||
.update(workflow)
|
||||
.set({
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(workflow.id, workflowId))
|
||||
// Update workflow's lastSynced timestamp and variables if provided
|
||||
const updateData: any = {
|
||||
lastSynced: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
// If variables are provided in the state, update them in the workflow record
|
||||
if (state.variables !== undefined) {
|
||||
updateData.variables = state.variables
|
||||
}
|
||||
|
||||
await db.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
|
||||
|
||||
const elapsed = Date.now() - startTime
|
||||
logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`)
|
||||
|
||||
5
apps/sim/app/templates/[id]/page.tsx
Normal file
5
apps/sim/app/templates/[id]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import TemplateDetails from './template'
|
||||
|
||||
export default function TemplatePage() {
|
||||
return <TemplateDetails />
|
||||
}
|
||||
936
apps/sim/app/templates/[id]/template.tsx
Normal file
936
apps/sim/app/templates/[id]/template.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
apps/sim/app/templates/components/navigation-tabs.tsx
Normal file
33
apps/sim/app/templates/components/navigation-tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
463
apps/sim/app/templates/components/template-card.tsx
Normal file
463
apps/sim/app/templates/components/template-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
apps/sim/app/templates/layout.tsx
Normal file
5
apps/sim/app/templates/layout.tsx
Normal 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>
|
||||
}
|
||||
33
apps/sim/app/templates/navigation-tabs.tsx
Normal file
33
apps/sim/app/templates/navigation-tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
99
apps/sim/app/templates/page.tsx
Normal file
99
apps/sim/app/templates/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
564
apps/sim/app/templates/template-card.tsx
Normal file
564
apps/sim/app/templates/template-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
312
apps/sim/app/templates/templates.tsx
Normal file
312
apps/sim/app/templates/templates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateStars, templates } from '@sim/db/schema'
|
||||
import { templateCreators, templateStars, templates } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template'
|
||||
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
|
||||
const logger = createLogger('TemplatePage')
|
||||
|
||||
@@ -20,36 +19,19 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
const { workspaceId, id } = await params
|
||||
|
||||
try {
|
||||
// Validate the template ID format (basic UUID validation)
|
||||
if (!id || typeof id !== 'string' || id.length !== 36) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return <div>Please log in to view this template</div>
|
||||
}
|
||||
|
||||
// Fetch template data first without star status to avoid query issues
|
||||
const templateData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
userId: templates.userId,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
author: templates.author,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
color: templates.color,
|
||||
icon: templates.icon,
|
||||
category: templates.category,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
template: templates,
|
||||
creator: templateCreators,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
|
||||
.where(eq(templates.id, id))
|
||||
.limit(1)
|
||||
|
||||
@@ -57,61 +39,52 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const template = templateData[0]
|
||||
const { template, creator } = templateData[0]
|
||||
|
||||
// Validate that required fields are present
|
||||
if (!template.id || !template.name || !template.author) {
|
||||
if (!session?.user?.id && template.status !== 'approved') {
|
||||
notFound()
|
||||
}
|
||||
|
||||
if (!template.id || !template.name) {
|
||||
logger.error('Template missing required fields:', {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
author: template.author,
|
||||
})
|
||||
notFound()
|
||||
}
|
||||
|
||||
// Check if user has starred this template
|
||||
let isStarred = false
|
||||
try {
|
||||
const starData = await db
|
||||
.select({ id: templateStars.id })
|
||||
.from(templateStars)
|
||||
.where(
|
||||
and(eq(templateStars.templateId, template.id), eq(templateStars.userId, session.user.id))
|
||||
)
|
||||
.limit(1)
|
||||
isStarred = starData.length > 0
|
||||
} catch {
|
||||
// Continue with isStarred = false
|
||||
if (session?.user?.id) {
|
||||
try {
|
||||
const starData = await db
|
||||
.select({ id: templateStars.id })
|
||||
.from(templateStars)
|
||||
.where(
|
||||
and(
|
||||
eq(templateStars.templateId, template.id),
|
||||
eq(templateStars.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
isStarred = starData.length > 0
|
||||
} catch {
|
||||
isStarred = false
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure proper serialization of the template data with null checks
|
||||
const serializedTemplate: Template = {
|
||||
id: template.id,
|
||||
workflowId: template.workflowId,
|
||||
userId: template.userId,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
author: template.author,
|
||||
views: template.views,
|
||||
stars: template.stars,
|
||||
color: template.color || '#3972F6', // Default color if missing
|
||||
icon: template.icon || 'FileText', // Default icon if missing
|
||||
category: template.category as any,
|
||||
state: template.state as any,
|
||||
createdAt: template.createdAt ? template.createdAt.toISOString() : new Date().toISOString(),
|
||||
updatedAt: template.updatedAt ? template.updatedAt.toISOString() : new Date().toISOString(),
|
||||
const serializedTemplate = {
|
||||
...template,
|
||||
creator: creator || null,
|
||||
createdAt: template.createdAt.toISOString(),
|
||||
updatedAt: template.updatedAt.toISOString(),
|
||||
isStarred,
|
||||
}
|
||||
|
||||
logger.info('Template from DB:', template)
|
||||
logger.info('Serialized template:', serializedTemplate)
|
||||
logger.info('Template state from DB:', template.state)
|
||||
|
||||
return (
|
||||
<TemplateDetails
|
||||
template={serializedTemplate}
|
||||
template={JSON.parse(JSON.stringify(serializedTemplate))}
|
||||
workspaceId={workspaceId}
|
||||
currentUserId={session.user.id}
|
||||
currentUserId={session?.user?.id || null}
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -122,6 +95,9 @@ export default async function TemplatePage({ params }: TemplatePageProps) {
|
||||
<h1 className='mb-4 font-bold text-2xl'>Error Loading Template</h1>
|
||||
<p className='text-muted-foreground'>There was an error loading this template.</p>
|
||||
<p className='mt-2 text-muted-foreground text-sm'>Template ID: {id}</p>
|
||||
<p className='mt-2 text-red-500 text-xs'>
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Award,
|
||||
@@ -45,12 +45,11 @@ import {
|
||||
Wrench,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { categories } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
@@ -59,7 +58,7 @@ const logger = createLogger('TemplateDetails')
|
||||
interface TemplateDetailsProps {
|
||||
template: Template
|
||||
workspaceId: string
|
||||
currentUserId: string
|
||||
currentUserId: string | null
|
||||
}
|
||||
|
||||
// Icon mapping - reuse from template-card
|
||||
@@ -110,35 +109,56 @@ const getIconComponent = (icon: string): React.ReactNode => {
|
||||
return IconComponent ? <IconComponent className='h-6 w-6' /> : <FileText className='h-6 w-6' />
|
||||
}
|
||||
|
||||
// Get category display name
|
||||
const getCategoryDisplayName = (categoryValue: string): string => {
|
||||
const category = categories.find((c) => c.value === categoryValue)
|
||||
return category?.label || categoryValue
|
||||
}
|
||||
|
||||
export default function TemplateDetails({
|
||||
template,
|
||||
workspaceId,
|
||||
currentUserId,
|
||||
}: TemplateDetailsProps) {
|
||||
const router = useRouter()
|
||||
const [isStarred, setIsStarred] = useState(template?.isStarred || false)
|
||||
const [starCount, setStarCount] = useState(template?.stars || 0)
|
||||
const [isStarring, setIsStarring] = useState(false)
|
||||
const [isUsing, setIsUsing] = useState(false)
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// Defensive check for template after hooks are initialized
|
||||
// Defensive check for template BEFORE initializing state hooks
|
||||
if (!template) {
|
||||
logger.error('Template prop is undefined or null in TemplateDetails component', {
|
||||
template,
|
||||
workspaceId,
|
||||
currentUserId,
|
||||
})
|
||||
return (
|
||||
<div className='flex h-screen items-center justify-center'>
|
||||
<div className='text-center'>
|
||||
<h1 className='mb-4 font-bold text-2xl'>Template Not Found</h1>
|
||||
<p className='text-muted-foreground'>The template you're looking for doesn't exist.</p>
|
||||
<p className='mt-2 text-muted-foreground text-xs'>Template data failed to load</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
logger.info('Template loaded in TemplateDetails', {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
hasState: !!template.state,
|
||||
})
|
||||
|
||||
const [isStarred, setIsStarred] = useState(template.isStarred || false)
|
||||
const [starCount, setStarCount] = useState(template.stars || 0)
|
||||
const [isStarring, setIsStarring] = useState(false)
|
||||
const [isUsing, setIsUsing] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
|
||||
const isOwner = currentUserId && template.userId === currentUserId
|
||||
|
||||
// Auto-use template after login if use=true query param is present
|
||||
useEffect(() => {
|
||||
const shouldAutoUse = searchParams?.get('use') === 'true'
|
||||
if (shouldAutoUse && currentUserId && !isUsing) {
|
||||
handleUseTemplate()
|
||||
// Clean up URL
|
||||
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
|
||||
}
|
||||
}, [searchParams, currentUserId])
|
||||
|
||||
// Render workflow preview exactly like deploy-modal.tsx
|
||||
const renderWorkflowPreview = () => {
|
||||
// Follow the same pattern as deployed-workflow-card.tsx
|
||||
@@ -189,7 +209,7 @@ export default function TemplateDetails({
|
||||
}
|
||||
|
||||
const handleStarToggle = async () => {
|
||||
if (isStarring) return
|
||||
if (isStarring || !currentUserId) return
|
||||
|
||||
setIsStarring(true)
|
||||
try {
|
||||
@@ -210,42 +230,78 @@ export default function TemplateDetails({
|
||||
const handleUseTemplate = async () => {
|
||||
if (isUsing) return
|
||||
|
||||
// Check if user is logged in
|
||||
if (!currentUserId) {
|
||||
// Redirect to login with callback URL to use template after login
|
||||
const callbackUrl = encodeURIComponent(
|
||||
`/workspace/${workspaceId}/templates/${template.id}?use=true`
|
||||
)
|
||||
router.push(`/login?callbackUrl=${callbackUrl}`)
|
||||
return
|
||||
}
|
||||
|
||||
setIsUsing(true)
|
||||
try {
|
||||
// TODO: Implement proper template usage logic
|
||||
// This should create a new workflow from the template state
|
||||
// For now, we'll create a basic workflow and navigate to it
|
||||
logger.info('Using template:', template.id)
|
||||
|
||||
// Create a new workflow
|
||||
const response = await fetch('/api/workflows', {
|
||||
const response = await fetch(`/api/templates/${template.id}/use`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `${template.name} (Copy)`,
|
||||
description: `Created from template: ${template.name}`,
|
||||
color: template.color,
|
||||
workspaceId,
|
||||
folderId: null,
|
||||
}),
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create workflow from template')
|
||||
throw new Error('Failed to use template')
|
||||
}
|
||||
|
||||
const newWorkflow = await response.json()
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the new workflow
|
||||
router.push(`/workspace/${workspaceId}/w/${newWorkflow.id}`)
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
// Show error to user (could implement toast notification)
|
||||
} finally {
|
||||
setIsUsing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditTemplate = async () => {
|
||||
if (isEditing || !currentUserId) return
|
||||
|
||||
setIsEditing(true)
|
||||
try {
|
||||
// If template already has a connected workflowId, check if it exists in user's workspace
|
||||
if (template.workflowId) {
|
||||
// Try to fetch the workflow to see if it still exists
|
||||
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
|
||||
|
||||
if (checkResponse.ok) {
|
||||
// Workflow exists, redirect to it
|
||||
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No connected workflow or it was deleted - create a new one
|
||||
const response = await fetch(`/api/templates/${template.id}/edit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to edit template')
|
||||
}
|
||||
|
||||
const { workflowId } = await response.json()
|
||||
|
||||
// Navigate to the workflow
|
||||
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
|
||||
} catch (error) {
|
||||
logger.error('Error editing template:', error)
|
||||
} finally {
|
||||
setIsEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex min-h-screen flex-col'>
|
||||
{/* Header */}
|
||||
@@ -282,20 +338,36 @@ export default function TemplateDetails({
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='flex items-center gap-3'>
|
||||
{/* Star button */}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleStarToggle}
|
||||
disabled={isStarring}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
isStarred && 'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100'
|
||||
)}
|
||||
>
|
||||
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} />
|
||||
{starCount}
|
||||
</Button>
|
||||
{/* Star button - only for logged-in users */}
|
||||
{currentUserId && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleStarToggle}
|
||||
disabled={isStarring}
|
||||
className={cn(
|
||||
'transition-colors',
|
||||
isStarred &&
|
||||
'border-yellow-200 bg-yellow-50 text-yellow-700 hover:bg-yellow-100'
|
||||
)}
|
||||
>
|
||||
<Star className={cn('mr-2 h-4 w-4', isStarred && 'fill-current')} />
|
||||
{starCount}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Edit button - only for template owner when logged in */}
|
||||
{isOwner && currentUserId && (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={handleEditTemplate}
|
||||
disabled={isEditing}
|
||||
className='border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||
>
|
||||
<Edit className='mr-2 h-4 w-4' />
|
||||
{isEditing ? 'Opening...' : 'Edit'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Use template button */}
|
||||
<Button
|
||||
@@ -303,28 +375,23 @@ export default function TemplateDetails({
|
||||
disabled={isUsing}
|
||||
className='bg-purple-600 text-white hover:bg-purple-700'
|
||||
>
|
||||
Use this template
|
||||
{isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className='mt-6 flex items-center gap-3 text-muted-foreground text-sm'>
|
||||
{/* Category */}
|
||||
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
|
||||
<span>{getCategoryDisplayName(template.category)}</span>
|
||||
</div>
|
||||
|
||||
{/* Views */}
|
||||
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
|
||||
<Eye className='h-3 w-3' />
|
||||
<span>{template.views}</span>
|
||||
<span>{template.views} views</span>
|
||||
</div>
|
||||
|
||||
{/* Stars */}
|
||||
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
|
||||
<Star className='h-3 w-3' />
|
||||
<span>{starCount}</span>
|
||||
<span>{starCount} stars</span>
|
||||
</div>
|
||||
|
||||
{/* Author */}
|
||||
@@ -332,6 +399,14 @@ export default function TemplateDetails({
|
||||
<User className='h-3 w-3' />
|
||||
<span>by {template.author}</span>
|
||||
</div>
|
||||
|
||||
{/* Author Type - show if organization */}
|
||||
{template.authorType === 'organization' && (
|
||||
<div className='flex items-center gap-1 rounded-full bg-secondary px-3 py-1'>
|
||||
<Users className='h-3 w-3' />
|
||||
<span>Organization</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -130,6 +130,8 @@ interface TemplateCardProps {
|
||||
onTemplateUsed?: () => void
|
||||
// Callback when star state changes (for parent state updates)
|
||||
onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void
|
||||
// User authentication status
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
// Skeleton component for loading states
|
||||
@@ -249,6 +251,7 @@ export function TemplateCard({
|
||||
isStarred = false,
|
||||
onTemplateUsed,
|
||||
onStarChange,
|
||||
isAuthenticated = true,
|
||||
}: TemplateCardProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
@@ -320,52 +323,27 @@ export function TemplateCard({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle use template
|
||||
// Handle use click - just navigate to detail page
|
||||
const handleUseClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const response = await fetch(`/api/templates/${id}/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workspaceId: params.workspaceId,
|
||||
}),
|
||||
})
|
||||
router.push(`/templates/${id}`)
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
logger.info('Template use API response:', data)
|
||||
|
||||
if (!data.workflowId) {
|
||||
logger.error('No workflowId returned from API:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const workflowUrl = `/workspace/${params.workspaceId}/w/${data.workflowId}`
|
||||
logger.info('Template used successfully, navigating to:', workflowUrl)
|
||||
|
||||
// Call the callback if provided (for closing modals, etc.)
|
||||
if (onTemplateUsed) {
|
||||
onTemplateUsed()
|
||||
}
|
||||
|
||||
// Use window.location.href for more reliable navigation
|
||||
window.location.href = workflowUrl
|
||||
} else {
|
||||
const errorText = await response.text()
|
||||
logger.error('Failed to use template:', response.statusText, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error using template:', error)
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
// Don't navigate if clicking on action buttons
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('button') || target.closest('[data-action]')) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/templates/${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={cn(
|
||||
'group rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
|
||||
'group cursor-pointer rounded-[8px] border bg-card shadow-xs transition-shadow duration-200 hover:border-border/80 hover:shadow-sm',
|
||||
'flex h-[142px]',
|
||||
className
|
||||
)}
|
||||
@@ -396,18 +374,21 @@ export function TemplateCard({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Star and Use button */}
|
||||
<div className='flex flex-shrink-0 items-center gap-3'>
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer transition-colors duration-50',
|
||||
localIsStarred
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
{/* Actions */}
|
||||
<div className='flex flex-shrink-0 items-center gap-2'>
|
||||
{/* Star button - only for authenticated users */}
|
||||
{isAuthenticated && (
|
||||
<Star
|
||||
onClick={handleStarClick}
|
||||
className={cn(
|
||||
'h-4 w-4 cursor-pointer transition-colors duration-50',
|
||||
localIsStarred
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-muted-foreground hover:fill-yellow-400 hover:text-yellow-400',
|
||||
isStarLoading && 'opacity-50'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={handleUseClick}
|
||||
className={cn(
|
||||
|
||||
@@ -1,47 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { templateStars, templates } from '@sim/db/schema'
|
||||
import { and, desc, eq, sql } from 'drizzle-orm'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import type { Template } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default async function TemplatesPage() {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return <div>Please log in to view templates</div>
|
||||
}
|
||||
|
||||
// Fetch templates server-side with all necessary data
|
||||
const templatesData = await db
|
||||
.select({
|
||||
id: templates.id,
|
||||
workflowId: templates.workflowId,
|
||||
userId: templates.userId,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
author: templates.author,
|
||||
views: templates.views,
|
||||
stars: templates.stars,
|
||||
color: templates.color,
|
||||
icon: templates.icon,
|
||||
category: templates.category,
|
||||
state: templates.state,
|
||||
createdAt: templates.createdAt,
|
||||
updatedAt: templates.updatedAt,
|
||||
isStarred: sql<boolean>`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
|
||||
})
|
||||
.from(templates)
|
||||
.leftJoin(
|
||||
templateStars,
|
||||
and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
|
||||
)
|
||||
.orderBy(desc(templates.views), desc(templates.createdAt))
|
||||
|
||||
return (
|
||||
<Templates
|
||||
initialTemplates={templatesData as unknown as Template[]}
|
||||
currentUserId={session.user.id}
|
||||
/>
|
||||
)
|
||||
// Redirect all users to the root templates page
|
||||
redirect('/templates')
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronRight, Search } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { NavigationTabs } from '@/app/workspace/[workspaceId]/templates/components/navigation-tabs'
|
||||
@@ -14,18 +13,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('TemplatesPage')
|
||||
|
||||
// Shared categories definition
|
||||
export const categories = [
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'sales', label: 'Sales' },
|
||||
{ value: 'finance', label: 'Finance' },
|
||||
{ value: 'support', label: 'Support' },
|
||||
{ value: 'artificial-intelligence', label: 'Artificial Intelligence' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const
|
||||
|
||||
export type CategoryValue = (typeof categories)[number]['value']
|
||||
|
||||
// Template data structure
|
||||
export interface Template {
|
||||
id: string
|
||||
@@ -34,68 +21,38 @@ export interface Template {
|
||||
name: string
|
||||
description: string | null
|
||||
author: string
|
||||
authorType: 'user' | 'organization'
|
||||
organizationId: string | null
|
||||
views: number
|
||||
stars: number
|
||||
color: string
|
||||
icon: string
|
||||
category: CategoryValue
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
state: WorkflowState
|
||||
createdAt: Date | string
|
||||
updatedAt: Date | string
|
||||
isStarred: boolean
|
||||
isSuperUser?: boolean
|
||||
}
|
||||
|
||||
interface TemplatesProps {
|
||||
initialTemplates: Template[]
|
||||
currentUserId: string
|
||||
isSuperUser: boolean
|
||||
}
|
||||
|
||||
export default function Templates({ initialTemplates, currentUserId }: TemplatesProps) {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
export default function Templates({
|
||||
initialTemplates,
|
||||
currentUserId,
|
||||
isSuperUser,
|
||||
}: TemplatesProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('your')
|
||||
const [templates, setTemplates] = useState<Template[]>(initialTemplates)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Refs for scrolling to sections
|
||||
const sectionRefs = {
|
||||
your: useRef<HTMLDivElement>(null),
|
||||
recent: useRef<HTMLDivElement>(null),
|
||||
marketing: useRef<HTMLDivElement>(null),
|
||||
sales: useRef<HTMLDivElement>(null),
|
||||
finance: useRef<HTMLDivElement>(null),
|
||||
support: useRef<HTMLDivElement>(null),
|
||||
'artificial-intelligence': useRef<HTMLDivElement>(null),
|
||||
other: useRef<HTMLDivElement>(null),
|
||||
}
|
||||
|
||||
// Get your templates count (created by user OR starred by user)
|
||||
const yourTemplatesCount = templates.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
).length
|
||||
|
||||
// Handle case where active tab is "your" but user has no templates
|
||||
useEffect(() => {
|
||||
if (!loading && activeTab === 'your' && yourTemplatesCount === 0) {
|
||||
setActiveTab('recent') // Switch to recent tab
|
||||
}
|
||||
}, [loading, activeTab, yourTemplatesCount])
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId)
|
||||
const sectionRef = sectionRefs[tabId as keyof typeof sectionRefs]
|
||||
if (sectionRef.current) {
|
||||
sectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateNew = () => {
|
||||
// TODO: Open create template modal or navigate to create page
|
||||
logger.info('Create new template')
|
||||
}
|
||||
|
||||
// Handle star change callback from template card
|
||||
@@ -107,34 +64,39 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
)
|
||||
}
|
||||
|
||||
const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => {
|
||||
let filteredByCategory = templates
|
||||
// Get templates for the active tab with search filtering
|
||||
const getActiveTabTemplates = () => {
|
||||
let filtered = templates
|
||||
|
||||
if (category === 'your') {
|
||||
// For "your" templates, show templates created by you OR starred by you
|
||||
filteredByCategory = templates.filter(
|
||||
// Filter by active tab
|
||||
if (activeTab === 'your') {
|
||||
filtered = filtered.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
)
|
||||
} else if (category === 'recent') {
|
||||
// For "recent" templates, show the 8 most recent templates
|
||||
filteredByCategory = templates
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 8)
|
||||
} else {
|
||||
filteredByCategory = templates.filter((template) => template.category === category)
|
||||
} else if (activeTab === 'gallery') {
|
||||
// Show all approved templates
|
||||
filtered = filtered.filter((template) => template.status === 'approved')
|
||||
} else if (activeTab === 'pending') {
|
||||
// Show pending templates for super users
|
||||
filtered = filtered.filter((template) => template.status === 'pending')
|
||||
}
|
||||
|
||||
if (!searchQuery) return filteredByCategory
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(
|
||||
(template) =>
|
||||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return filteredByCategory.filter(
|
||||
(template) =>
|
||||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
template.author.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
return filtered
|
||||
}
|
||||
|
||||
// Helper function to render template cards with proper type handling
|
||||
const activeTemplates = getActiveTabTemplates()
|
||||
|
||||
// Helper function to render template cards
|
||||
const renderTemplateCard = (template: Template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
@@ -149,14 +111,10 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
state={template.state as { blocks?: Record<string, { type: string; name?: string }> }}
|
||||
isStarred={template.isStarred}
|
||||
onStarChange={handleStarChange}
|
||||
isAuthenticated={true}
|
||||
/>
|
||||
)
|
||||
|
||||
// Group templates by category for display
|
||||
const getTemplatesByCategory = (category: CategoryValue | 'your' | 'recent') => {
|
||||
return filteredTemplates(category)
|
||||
}
|
||||
|
||||
// Render skeleton cards for loading state
|
||||
const renderSkeletonCards = () => {
|
||||
return Array.from({ length: 8 }).map((_, index) => (
|
||||
@@ -164,45 +122,33 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
))
|
||||
}
|
||||
|
||||
// Calculate navigation tabs with real counts or skeleton counts
|
||||
// Calculate counts for tabs
|
||||
const yourTemplatesCount = templates.filter(
|
||||
(template) => template.userId === currentUserId || template.isStarred === true
|
||||
).length
|
||||
const galleryCount = templates.filter((template) => template.status === 'approved').length
|
||||
const pendingCount = templates.filter((template) => template.status === 'pending').length
|
||||
|
||||
const navigationTabs = [
|
||||
// Only include "Your templates" tab if user has created or starred templates
|
||||
...(yourTemplatesCount > 0 || loading
|
||||
{
|
||||
id: 'gallery',
|
||||
label: 'Gallery',
|
||||
count: galleryCount,
|
||||
},
|
||||
{
|
||||
id: 'your',
|
||||
label: 'Your Templates',
|
||||
count: yourTemplatesCount,
|
||||
},
|
||||
...(isSuperUser
|
||||
? [
|
||||
{
|
||||
id: 'your',
|
||||
label: 'Your templates',
|
||||
count: loading ? 8 : getTemplatesByCategory('your').length,
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
count: pendingCount,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'recent',
|
||||
label: 'Recent',
|
||||
count: loading ? 8 : getTemplatesByCategory('recent').length,
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
label: 'Marketing',
|
||||
count: loading ? 8 : getTemplatesByCategory('marketing').length,
|
||||
},
|
||||
{ id: 'sales', label: 'Sales', count: loading ? 8 : getTemplatesByCategory('sales').length },
|
||||
{
|
||||
id: 'finance',
|
||||
label: 'Finance',
|
||||
count: loading ? 8 : getTemplatesByCategory('finance').length,
|
||||
},
|
||||
{
|
||||
id: 'support',
|
||||
label: 'Support',
|
||||
count: loading ? 8 : getTemplatesByCategory('support').length,
|
||||
},
|
||||
{
|
||||
id: 'artificial-intelligence',
|
||||
label: 'Artificial Intelligence',
|
||||
count: loading ? 8 : getTemplatesByCategory('artificial-intelligence').length,
|
||||
},
|
||||
{ id: 'other', label: 'Other', count: loading ? 8 : getTemplatesByCategory('other').length },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -250,124 +196,36 @@ export default function Templates({ initialTemplates, currentUserId }: Templates
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Your Templates Section */}
|
||||
{yourTemplatesCount > 0 || loading ? (
|
||||
<div ref={sectionRefs.your} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Your templates</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
{/* Templates Grid - Based on Active Tab */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading ? (
|
||||
renderSkeletonCards()
|
||||
) : activeTemplates.length === 0 ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-muted-foreground text-sm'>
|
||||
{searchQuery
|
||||
? 'No templates found'
|
||||
: activeTab === 'pending'
|
||||
? 'No pending templates'
|
||||
: activeTab === 'your'
|
||||
? 'No templates yet'
|
||||
: 'No templates available'}
|
||||
</p>
|
||||
<p className='mt-1 text-muted-foreground/70 text-xs'>
|
||||
{searchQuery
|
||||
? 'Try a different search term'
|
||||
: activeTab === 'pending'
|
||||
? 'New submissions will appear here'
|
||||
: activeTab === 'your'
|
||||
? 'Create or star templates to see them here'
|
||||
: 'Templates will appear once approved'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('your').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Recent Templates Section */}
|
||||
<div ref={sectionRefs.recent} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Recent</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('recent').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketing Section */}
|
||||
<div ref={sectionRefs.marketing} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Marketing</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('marketing').map((template) =>
|
||||
renderTemplateCard(template)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sales Section */}
|
||||
<div ref={sectionRefs.sales} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Sales</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('sales').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Finance Section */}
|
||||
<div ref={sectionRefs.finance} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Finance</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('finance').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Support Section */}
|
||||
<div ref={sectionRefs.support} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Support</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('support').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artificial Intelligence Section */}
|
||||
<div ref={sectionRefs['artificial-intelligence']} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>
|
||||
Artificial Intelligence
|
||||
</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('artificial-intelligence').map((template) =>
|
||||
renderTemplateCard(template)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Section */}
|
||||
<div ref={sectionRefs.other} className='mb-8'>
|
||||
<div className='mb-4 flex items-center gap-2'>
|
||||
<h2 className='font-medium font-sans text-foreground text-lg'>Other</h2>
|
||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{loading
|
||||
? renderSkeletonCards()
|
||||
: getTemplatesByCategory('other').map((template) => renderTemplateCard(template))}
|
||||
</div>
|
||||
) : (
|
||||
activeTemplates.map((template) => renderTemplateCard(template))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { ChatDeploy } from './chat-deploy/chat-deploy'
|
||||
export { DeploymentInfo } from './deployment-info/deployment-info'
|
||||
export { ImageSelector } from './image-selector/image-selector'
|
||||
export { TemplateDeploy } from './template-deploy/template-deploy'
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,10 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/db-helpers'
|
||||
import { resolveStartCandidates, StartBlockPath } from '@/lib/workflows/triggers'
|
||||
import { DeploymentInfo } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
|
||||
import {
|
||||
DeploymentInfo,
|
||||
TemplateDeploy,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components'
|
||||
import { ChatDeploy } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy'
|
||||
import { DeployedWorkflowModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
@@ -47,7 +50,7 @@ interface WorkflowDeploymentInfo {
|
||||
needsRedeployment: boolean
|
||||
}
|
||||
|
||||
type TabView = 'api' | 'versions' | 'chat'
|
||||
type TabView = 'api' | 'versions' | 'chat' | 'template'
|
||||
|
||||
export function DeployModal({
|
||||
open,
|
||||
@@ -369,6 +372,9 @@ export function DeployModal({
|
||||
|
||||
setVersionToActivate(null)
|
||||
setApiDeployError(null)
|
||||
|
||||
// Templates connected to this workflow are automatically updated with the new state
|
||||
// The deployWorkflow function handles updating template states in db-helpers.ts
|
||||
} catch (error: unknown) {
|
||||
logger.error('Error deploying workflow:', { error })
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to deploy workflow'
|
||||
@@ -687,6 +693,16 @@ export function DeployModal({
|
||||
>
|
||||
Versions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('template')}
|
||||
className={`rounded-md px-3 py-1 text-sm transition-colors ${
|
||||
activeTab === 'template'
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -937,6 +953,10 @@ export function DeployModal({
|
||||
onVersionActivated={() => setVersionToActivate(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'template' && workflowId && (
|
||||
<TemplateDeploy workflowId={workflowId} onDeploymentComplete={handleCloseModal} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,6 @@ import { useSession } from '@/lib/auth-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buildWorkflowStateForTemplate } from '@/lib/workflows/state-builder'
|
||||
import { categories } from '@/app/workspace/[workspaceId]/templates/templates'
|
||||
|
||||
const logger = createLogger('TemplateModal')
|
||||
|
||||
@@ -99,13 +98,19 @@ const templateSchema = z.object({
|
||||
.string()
|
||||
.min(1, 'Author is required')
|
||||
.max(100, 'Author must be less than 100 characters'),
|
||||
category: z.string().min(1, 'Category is required'),
|
||||
authorType: z.enum(['user', 'organization']).default('user'),
|
||||
organizationId: z.string().optional(),
|
||||
icon: z.string().min(1, 'Icon is required'),
|
||||
color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'),
|
||||
})
|
||||
|
||||
type TemplateFormData = z.infer<typeof templateSchema>
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TemplateModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -180,6 +185,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([])
|
||||
const [loadingOrgs, setLoadingOrgs] = useState(false)
|
||||
|
||||
const form = useForm<TemplateFormData>({
|
||||
resolver: zodResolver(templateSchema),
|
||||
@@ -187,7 +194,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
category: '',
|
||||
authorType: 'user',
|
||||
organizationId: undefined,
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
},
|
||||
@@ -195,12 +203,37 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
|
||||
// Watch form state to determine if all required fields are valid
|
||||
const formValues = form.watch()
|
||||
const authorType = form.watch('authorType')
|
||||
const isFormValid =
|
||||
form.formState.isValid &&
|
||||
formValues.name?.trim() &&
|
||||
formValues.description?.trim() &&
|
||||
formValues.author?.trim() &&
|
||||
formValues.category
|
||||
formValues.author?.trim()
|
||||
|
||||
// Fetch user's organizations when modal opens
|
||||
useEffect(() => {
|
||||
const fetchOrganizations = async () => {
|
||||
if (!open || !session?.user?.id) return
|
||||
|
||||
setLoadingOrgs(true)
|
||||
try {
|
||||
const response = await fetch('/api/organizations')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setOrganizations(data.organizations || [])
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching organizations:', error)
|
||||
setOrganizations([])
|
||||
} finally {
|
||||
setLoadingOrgs(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
fetchOrganizations()
|
||||
}
|
||||
}, [open, session?.user?.id])
|
||||
|
||||
// Check for existing template when modal opens
|
||||
useEffect(() => {
|
||||
@@ -224,7 +257,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
author: template.author,
|
||||
category: template.category,
|
||||
authorType: template.authorType || 'user',
|
||||
organizationId: template.organizationId || undefined,
|
||||
icon: template.icon,
|
||||
color: template.color,
|
||||
})
|
||||
@@ -236,7 +270,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
name: '',
|
||||
description: '',
|
||||
author: session?.user?.name || session?.user?.email || '',
|
||||
category: '',
|
||||
authorType: 'user',
|
||||
organizationId: undefined,
|
||||
icon: 'FileText',
|
||||
color: '#3972F6',
|
||||
})
|
||||
@@ -267,7 +302,8 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
name: data.name,
|
||||
description: data.description || '',
|
||||
author: data.author,
|
||||
category: data.category,
|
||||
authorType: data.authorType,
|
||||
organizationId: data.organizationId,
|
||||
icon: data.icon,
|
||||
color: data.color,
|
||||
state: templateState,
|
||||
@@ -400,14 +436,14 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
|
||||
{/* Author and Category row */}
|
||||
{/* Author and Author Type row */}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-14' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Input */}
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className='mb-2 h-4 w-16' /> {/* Label */}
|
||||
<Skeleton className='mb-2 h-4 w-24' /> {/* Label */}
|
||||
<Skeleton className='h-10 w-full' /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,24 +571,30 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='category'
|
||||
name='authorType'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Category
|
||||
Author Type
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
// Reset org selection when switching to user
|
||||
if (value === 'user') {
|
||||
form.setValue('organizationId', undefined)
|
||||
}
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='h-10 rounded-[8px]'>
|
||||
<SelectValue placeholder='Select a category' />
|
||||
<SelectValue placeholder='Select author type' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.value} value={category.value}>
|
||||
{category.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value='user'>User</SelectItem>
|
||||
<SelectItem value='organization'>Organization</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
@@ -561,6 +603,46 @@ export function TemplateModal({ open, onOpenChange, workflowId }: TemplateModalP
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Organization selector - only show when authorType is 'organization' */}
|
||||
{authorType === 'organization' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='organizationId'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='!text-foreground font-medium text-sm'>
|
||||
Organization
|
||||
</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger className='h-10 rounded-[8px]'>
|
||||
<SelectValue placeholder='Select an organization' />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{loadingOrgs ? (
|
||||
<SelectItem value='loading' disabled>
|
||||
Loading organizations...
|
||||
</SelectItem>
|
||||
) : organizations.length === 0 ? (
|
||||
<SelectItem value='none' disabled>
|
||||
No organizations available
|
||||
</SelectItem>
|
||||
) : (
|
||||
organizations.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='description'
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
RefreshCw,
|
||||
SkipForward,
|
||||
StepForward,
|
||||
Store,
|
||||
Trash2,
|
||||
Webhook,
|
||||
WifiOff,
|
||||
@@ -39,7 +38,6 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import {
|
||||
DeploymentControls,
|
||||
ExportControls,
|
||||
TemplateModal,
|
||||
WebhookSettings,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
|
||||
import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution'
|
||||
@@ -111,7 +109,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [, forceUpdate] = useState({})
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
|
||||
const [isWebhookSettingsOpen, setIsWebhookSettingsOpen] = useState(false)
|
||||
const [isAutoLayouting, setIsAutoLayouting] = useState(false)
|
||||
|
||||
@@ -646,24 +643,28 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{deleteState.showTemplateChoice ? 'Published Templates Found' : 'Delete workflow?'}
|
||||
{deleteState.showTemplateChoice ? 'Template Connected' : 'Delete workflow?'}
|
||||
</AlertDialogTitle>
|
||||
{deleteState.showTemplateChoice ? (
|
||||
<div className='space-y-3'>
|
||||
<AlertDialogDescription>
|
||||
This workflow has {deleteState.publishedTemplates.length} published template
|
||||
{deleteState.publishedTemplates.length > 1 ? 's' : ''}:
|
||||
</AlertDialogDescription>
|
||||
{deleteState.publishedTemplates.length > 0 && (
|
||||
<ul className='list-disc space-y-1 pl-6'>
|
||||
{deleteState.publishedTemplates.map((template) => (
|
||||
<li key={template.id}>{template.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<AlertDialogDescription>
|
||||
What would you like to do with the published template
|
||||
{deleteState.publishedTemplates.length > 1 ? 's' : ''}?
|
||||
<AlertDialogDescription asChild>
|
||||
<div className='space-y-2'>
|
||||
<div>
|
||||
This workflow is connected to a template:{' '}
|
||||
<strong>{deleteState.publishedTemplates[0]?.name}</strong>
|
||||
</div>
|
||||
<div className='mt-3'>What would you like to do with it?</div>
|
||||
<div className='mt-2 space-y-1 text-xs'>
|
||||
<div className='text-muted-foreground'>
|
||||
<strong>Keep template:</strong> Template remains in the marketplace. You can
|
||||
reconnect it later by clicking "Edit" on the template.
|
||||
</div>
|
||||
<div className='text-muted-foreground'>
|
||||
<strong>Delete template:</strong> Permanently remove template from the
|
||||
marketplace.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
) : (
|
||||
@@ -685,14 +686,14 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
disabled={deleteState.isDeleting}
|
||||
className='h-9 flex-1 rounded-[8px]'
|
||||
>
|
||||
Keep templates
|
||||
Keep template
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleTemplateAction('delete')}
|
||||
disabled={deleteState.isDeleting}
|
||||
className='h-9 flex-1 rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
>
|
||||
{deleteState.isDeleting ? 'Deleting...' : 'Delete templates'}
|
||||
{deleteState.isDeleting ? 'Deleting...' : 'Delete template'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -983,43 +984,6 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render publish template button
|
||||
*/
|
||||
const renderPublishButton = () => {
|
||||
const canEdit = userPermissions.canEdit
|
||||
const isDisabled = isExecuting || isDebugging || !canEdit
|
||||
|
||||
const getTooltipText = () => {
|
||||
if (!canEdit) return 'Admin permission required to publish templates'
|
||||
if (isDebugging) return 'Cannot publish template while debugging'
|
||||
if (isExecuting) return 'Cannot publish template while workflow is running'
|
||||
return 'Publish as template'
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isDisabled ? (
|
||||
<div className='inline-flex h-12 w-12 cursor-not-allowed items-center justify-center rounded-[11px] border bg-card text-card-foreground opacity-50 shadow-xs transition-colors'>
|
||||
<Store className='h-4 w-4' />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => setIsTemplateModalOpen(true)}
|
||||
className='h-12 w-12 rounded-[11px] border bg-card text-card-foreground shadow-xs hover:bg-secondary'
|
||||
>
|
||||
<Store className='h-5 w-5' />
|
||||
<span className='sr-only'>Publish Template</span>
|
||||
</Button>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{getTooltipText()}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render debug mode toggle button
|
||||
*/
|
||||
@@ -1273,22 +1237,12 @@ export function ControlBar({ hasValidationErrors = false }: ControlBarProps) {
|
||||
{isExpanded && renderWebhookButton()}
|
||||
{isExpanded && <ExportControls />}
|
||||
{isExpanded && renderAutoLayoutButton()}
|
||||
{isExpanded && renderPublishButton()}
|
||||
{renderDeleteButton()}
|
||||
{renderDuplicateButton()}
|
||||
{!isDebugging && renderDebugModeToggle()}
|
||||
{renderDeployButton()}
|
||||
{isDebugging ? renderDebugControlsBar() : renderRunButton()}
|
||||
|
||||
{/* Template Modal */}
|
||||
{activeWorkflowId && (
|
||||
<TemplateModal
|
||||
open={isTemplateModalOpen}
|
||||
onOpenChange={setIsTemplateModalOpen}
|
||||
workflowId={activeWorkflowId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Webhook Settings */}
|
||||
{activeWorkflowId && (
|
||||
<WebhookSettings
|
||||
|
||||
@@ -26,6 +26,7 @@ export const NavigationItem = ({ item }: NavigationItemProps) => {
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={item.onClick}
|
||||
data-settings-button={item.id === 'settings' ? '' : undefined}
|
||||
className={cn(
|
||||
'h-[42px] w-[42px] rounded-[10px] border bg-background text-foreground shadow-xs transition-all duration-200',
|
||||
isGrayHover && 'hover:bg-secondary',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { getEnv, isTruthy } from '@/lib/env'
|
||||
import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
|
||||
@@ -24,9 +25,15 @@ const TOOLTIPS = {
|
||||
'Show floating controls for zoom, undo, and redo at the bottom of the workflow canvas.',
|
||||
trainingControls:
|
||||
'Show training controls for recording workflow edits to build copilot training datasets.',
|
||||
superUserMode:
|
||||
'Toggle super user mode UI. When enabled, you can see and approve pending templates. Your super user status in the database remains unchanged.',
|
||||
}
|
||||
|
||||
export function General() {
|
||||
const { data: session } = useSession()
|
||||
const [isSuperUser, setIsSuperUser] = useState(false)
|
||||
const [loadingSuperUser, setLoadingSuperUser] = useState(true)
|
||||
|
||||
const isLoading = useGeneralStore((state) => state.isLoading)
|
||||
const isTrainingEnabled = isTruthy(getEnv('NEXT_PUBLIC_COPILOT_TRAINING_ENABLED'))
|
||||
const theme = useGeneralStore((state) => state.theme)
|
||||
@@ -36,6 +43,7 @@ export function General() {
|
||||
const isConsoleExpandedByDefault = useGeneralStore((state) => state.isConsoleExpandedByDefault)
|
||||
const showFloatingControls = useGeneralStore((state) => state.showFloatingControls)
|
||||
const showTrainingControls = useGeneralStore((state) => state.showTrainingControls)
|
||||
const superUserModeEnabled = useGeneralStore((state) => state.superUserModeEnabled)
|
||||
|
||||
// Loading states
|
||||
const isAutoConnectLoading = useGeneralStore((state) => state.isAutoConnectLoading)
|
||||
@@ -47,6 +55,7 @@ export function General() {
|
||||
const isThemeLoading = useGeneralStore((state) => state.isThemeLoading)
|
||||
const isFloatingControlsLoading = useGeneralStore((state) => state.isFloatingControlsLoading)
|
||||
const isTrainingControlsLoading = useGeneralStore((state) => state.isTrainingControlsLoading)
|
||||
const isSuperUserModeLoading = useGeneralStore((state) => state.isSuperUserModeLoading)
|
||||
|
||||
const setTheme = useGeneralStore((state) => state.setTheme)
|
||||
const toggleAutoConnect = useGeneralStore((state) => state.toggleAutoConnect)
|
||||
@@ -57,6 +66,34 @@ export function General() {
|
||||
)
|
||||
const toggleFloatingControls = useGeneralStore((state) => state.toggleFloatingControls)
|
||||
const toggleTrainingControls = useGeneralStore((state) => state.toggleTrainingControls)
|
||||
const toggleSuperUserMode = useGeneralStore((state) => state.toggleSuperUserMode)
|
||||
|
||||
// Fetch super user status from database
|
||||
useEffect(() => {
|
||||
const fetchSuperUserStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/user/super-user')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setIsSuperUser(data.isSuperUser)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch super user status:', error)
|
||||
} finally {
|
||||
setLoadingSuperUser(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (session?.user?.id) {
|
||||
fetchSuperUserStatus()
|
||||
}
|
||||
}, [session?.user?.id])
|
||||
|
||||
const handleSuperUserModeToggle = async (checked: boolean) => {
|
||||
if (checked !== superUserModeEnabled && !isSuperUserModeLoading) {
|
||||
await toggleSuperUserMode()
|
||||
}
|
||||
}
|
||||
|
||||
// Sync theme from store to next-themes when theme changes
|
||||
useEffect(() => {
|
||||
@@ -327,6 +364,39 @@ export function General() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Super User Mode Toggle - Only visible to super users */}
|
||||
{!loadingSuperUser && isSuperUser && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='super-user-mode' className='font-normal'>
|
||||
Super User Mode
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 p-1 text-gray-500'
|
||||
aria-label='Learn more about super user mode'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Info className='h-5 w-5' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-[300px] p-3'>
|
||||
<p className='text-sm'>{TOOLTIPS.superUserMode}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch
|
||||
id='super-user-mode'
|
||||
checked={superUserModeEnabled}
|
||||
onCheckedChange={handleSuperUserModeToggle}
|
||||
disabled={isLoading || isSuperUserModeLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { Account } from './account/account'
|
||||
export { ApiKeys } from './api-keys/api-keys'
|
||||
export { Copilot } from './copilot/copilot'
|
||||
export { CreatorProfile } from './creator-profile/creator-profile'
|
||||
export { Credentials } from './credentials/credentials'
|
||||
export { CustomTools } from './custom-tools/custom-tools'
|
||||
export { EnvironmentVariables } from './environment/environment'
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Home,
|
||||
Key,
|
||||
LogIn,
|
||||
Palette,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
@@ -32,6 +33,7 @@ interface SettingsNavigationProps {
|
||||
| 'general'
|
||||
| 'environment'
|
||||
| 'account'
|
||||
| 'creator-profile'
|
||||
| 'credentials'
|
||||
| 'apikeys'
|
||||
| 'files'
|
||||
@@ -51,6 +53,7 @@ type NavigationItem = {
|
||||
| 'general'
|
||||
| 'environment'
|
||||
| 'account'
|
||||
| 'creator-profile'
|
||||
| 'credentials'
|
||||
| 'apikeys'
|
||||
| 'files'
|
||||
@@ -100,6 +103,11 @@ const allNavigationItems: NavigationItem[] = [
|
||||
label: 'Account',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
id: 'creator-profile',
|
||||
label: 'Creator Profile',
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
id: 'apikeys',
|
||||
label: 'API Keys',
|
||||
@@ -231,6 +239,7 @@ export function SettingsNavigation({
|
||||
}
|
||||
}}
|
||||
onClick={() => onSectionChange(item.id)}
|
||||
data-section={item.id}
|
||||
className={cn(
|
||||
'group flex h-9 w-full cursor-pointer items-center rounded-[8px] px-2 py-2 font-medium font-sans text-sm transition-colors',
|
||||
activeSection === item.id ? 'bg-muted' : 'hover:bg-muted'
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Account,
|
||||
ApiKeys,
|
||||
Copilot,
|
||||
CreatorProfile,
|
||||
Credentials,
|
||||
CustomTools,
|
||||
EnvironmentVariables,
|
||||
@@ -36,6 +37,7 @@ type SettingsSection =
|
||||
| 'general'
|
||||
| 'environment'
|
||||
| 'account'
|
||||
| 'creator-profile'
|
||||
| 'credentials'
|
||||
| 'apikeys'
|
||||
| 'files'
|
||||
@@ -154,6 +156,11 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
<Account onOpenChange={onOpenChange} />
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'creator-profile' && (
|
||||
<div className='h-full'>
|
||||
<CreatorProfile />
|
||||
</div>
|
||||
)}
|
||||
{activeSection === 'credentials' && (
|
||||
<div className='h-full'>
|
||||
<Credentials
|
||||
|
||||
@@ -132,6 +132,7 @@ export {
|
||||
} from './table'
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs'
|
||||
export { checkTagTrigger, TagDropdown } from './tag-dropdown'
|
||||
export { TagInput } from './tag-input'
|
||||
export { Textarea } from './textarea'
|
||||
export { Toggle, toggleVariants } from './toggle'
|
||||
export { ToolCallCompletion, ToolCallExecution } from './tool-call'
|
||||
|
||||
103
apps/sim/components/ui/tag-input.tsx
Normal file
103
apps/sim/components/ui/tag-input.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -57,6 +57,13 @@ export function useMcpTools(workspaceId: string): UseMcpToolsResult {
|
||||
|
||||
const refreshTools = useCallback(
|
||||
async (forceRefresh = false) => {
|
||||
// Skip if no workspaceId (e.g., on template preview pages)
|
||||
if (!workspaceId) {
|
||||
setMcpTools([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
|
||||
@@ -427,9 +427,7 @@ async function processTemplateFromDb(
|
||||
.select({
|
||||
id: templates.id,
|
||||
name: templates.name,
|
||||
description: templates.description,
|
||||
category: templates.category,
|
||||
author: templates.author,
|
||||
details: templates.details,
|
||||
stars: templates.stars,
|
||||
state: templates.state,
|
||||
})
|
||||
@@ -438,14 +436,11 @@ async function processTemplateFromDb(
|
||||
.limit(1)
|
||||
const t = rows?.[0]
|
||||
if (!t) return null
|
||||
const workflowState = (t as any).state || {}
|
||||
// Match get-user-workflow format: just the workflow state JSON
|
||||
const workflowState = t.state || {}
|
||||
const summary = {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description || '',
|
||||
category: t.category,
|
||||
author: t.author,
|
||||
description: (t.details as any)?.tagline || '',
|
||||
stars: t.stars || 0,
|
||||
workflow: workflowState,
|
||||
}
|
||||
|
||||
269
apps/sim/lib/workflows/credential-extractor.ts
Normal file
269
apps/sim/lib/workflows/credential-extractor.ts
Normal 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 })
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'crypto'
|
||||
import {
|
||||
db,
|
||||
workflow,
|
||||
@@ -403,11 +404,19 @@ export async function deployWorkflow(params: {
|
||||
return { success: false, error: 'Failed to load workflow state' }
|
||||
}
|
||||
|
||||
// Also fetch workflow variables
|
||||
const [workflowRecord] = await db
|
||||
.select({ variables: workflow.variables })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
const currentState = {
|
||||
blocks: normalizedData.blocks,
|
||||
edges: normalizedData.edges,
|
||||
loops: normalizedData.loops,
|
||||
parallels: normalizedData.parallels,
|
||||
variables: workflowRecord?.variables || undefined,
|
||||
lastSaved: Date.now(),
|
||||
}
|
||||
|
||||
@@ -447,6 +456,9 @@ export async function deployWorkflow(params: {
|
||||
|
||||
await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId))
|
||||
|
||||
// Note: Templates are NOT automatically updated on deployment
|
||||
// Template updates must be done explicitly through the "Update Template" button
|
||||
|
||||
return nextVersion
|
||||
})
|
||||
|
||||
@@ -492,3 +504,131 @@ export async function deployWorkflow(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerates all IDs in a workflow state to avoid conflicts when duplicating or using templates
|
||||
* Returns a new state with all IDs regenerated and references updated
|
||||
*/
|
||||
export function regenerateWorkflowStateIds(state: any): any {
|
||||
// Create ID mappings
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
const edgeIdMapping = new Map<string, string>()
|
||||
const loopIdMapping = new Map<string, string>()
|
||||
const parallelIdMapping = new Map<string, string>()
|
||||
|
||||
// First pass: Create all ID mappings
|
||||
// Map block IDs
|
||||
Object.keys(state.blocks || {}).forEach((oldId) => {
|
||||
blockIdMapping.set(oldId, crypto.randomUUID())
|
||||
})
|
||||
|
||||
// Map edge IDs
|
||||
|
||||
;(state.edges || []).forEach((edge: any) => {
|
||||
edgeIdMapping.set(edge.id, crypto.randomUUID())
|
||||
})
|
||||
|
||||
// Map loop IDs
|
||||
Object.keys(state.loops || {}).forEach((oldId) => {
|
||||
loopIdMapping.set(oldId, crypto.randomUUID())
|
||||
})
|
||||
|
||||
// Map parallel IDs
|
||||
Object.keys(state.parallels || {}).forEach((oldId) => {
|
||||
parallelIdMapping.set(oldId, crypto.randomUUID())
|
||||
})
|
||||
|
||||
// Second pass: Create new state with regenerated IDs and updated references
|
||||
const newBlocks: Record<string, any> = {}
|
||||
const newEdges: any[] = []
|
||||
const newLoops: Record<string, any> = {}
|
||||
const newParallels: Record<string, any> = {}
|
||||
|
||||
// Regenerate blocks with updated references
|
||||
Object.entries(state.blocks || {}).forEach(([oldId, block]: [string, any]) => {
|
||||
const newId = blockIdMapping.get(oldId)!
|
||||
const newBlock = { ...block, id: newId }
|
||||
|
||||
// Update parentId reference if it exists
|
||||
if (newBlock.data?.parentId) {
|
||||
const newParentId = blockIdMapping.get(newBlock.data.parentId)
|
||||
if (newParentId) {
|
||||
newBlock.data.parentId = newParentId
|
||||
}
|
||||
}
|
||||
|
||||
// Update any block references in subBlocks
|
||||
if (newBlock.subBlocks) {
|
||||
const updatedSubBlocks: Record<string, any> = {}
|
||||
Object.entries(newBlock.subBlocks).forEach(([subId, subBlock]: [string, any]) => {
|
||||
const updatedSubBlock = { ...subBlock }
|
||||
|
||||
// If subblock value contains block references, update them
|
||||
if (
|
||||
typeof updatedSubBlock.value === 'string' &&
|
||||
blockIdMapping.has(updatedSubBlock.value)
|
||||
) {
|
||||
updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value)
|
||||
}
|
||||
|
||||
updatedSubBlocks[subId] = updatedSubBlock
|
||||
})
|
||||
newBlock.subBlocks = updatedSubBlocks
|
||||
}
|
||||
|
||||
newBlocks[newId] = newBlock
|
||||
})
|
||||
|
||||
// Regenerate edges with updated source/target references
|
||||
|
||||
;(state.edges || []).forEach((edge: any) => {
|
||||
const newId = edgeIdMapping.get(edge.id)!
|
||||
const newSource = blockIdMapping.get(edge.source) || edge.source
|
||||
const newTarget = blockIdMapping.get(edge.target) || edge.target
|
||||
|
||||
newEdges.push({
|
||||
...edge,
|
||||
id: newId,
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
})
|
||||
})
|
||||
|
||||
// Regenerate loops with updated node references
|
||||
Object.entries(state.loops || {}).forEach(([oldId, loop]: [string, any]) => {
|
||||
const newId = loopIdMapping.get(oldId)!
|
||||
const newLoop = { ...loop, id: newId }
|
||||
|
||||
// Update nodes array with new block IDs
|
||||
if (newLoop.nodes) {
|
||||
newLoop.nodes = newLoop.nodes.map((nodeId: string) => blockIdMapping.get(nodeId) || nodeId)
|
||||
}
|
||||
|
||||
newLoops[newId] = newLoop
|
||||
})
|
||||
|
||||
// Regenerate parallels with updated node references
|
||||
Object.entries(state.parallels || {}).forEach(([oldId, parallel]: [string, any]) => {
|
||||
const newId = parallelIdMapping.get(oldId)!
|
||||
const newParallel = { ...parallel, id: newId }
|
||||
|
||||
// Update nodes array with new block IDs
|
||||
if (newParallel.nodes) {
|
||||
newParallel.nodes = newParallel.nodes.map(
|
||||
(nodeId: string) => blockIdMapping.get(nodeId) || nodeId
|
||||
)
|
||||
}
|
||||
|
||||
newParallels[newId] = newParallel
|
||||
})
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
edges: newEdges,
|
||||
loops: newLoops,
|
||||
parallels: newParallels,
|
||||
lastSaved: state.lastSaved || Date.now(),
|
||||
...(state.variables && { variables: state.variables }),
|
||||
...(state.metadata && { metadata: state.metadata }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credential-extractor'
|
||||
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
@@ -380,44 +381,24 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
|
||||
* Users need positions to restore the visual layout when importing
|
||||
*/
|
||||
export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
|
||||
const clonedState = JSON.parse(
|
||||
JSON.stringify({
|
||||
blocks: state.blocks,
|
||||
edges: state.edges,
|
||||
loops: state.loops || {},
|
||||
parallels: state.parallels || {},
|
||||
metadata: state.metadata,
|
||||
variables: state.variables,
|
||||
})
|
||||
)
|
||||
// Preserve edges, loops, parallels, metadata, and variables
|
||||
const fullState = {
|
||||
blocks: state.blocks,
|
||||
edges: state.edges,
|
||||
loops: state.loops || {},
|
||||
parallels: state.parallels || {},
|
||||
metadata: state.metadata,
|
||||
variables: state.variables,
|
||||
}
|
||||
|
||||
Object.values(clonedState.blocks).forEach((block: any) => {
|
||||
if (block.subBlocks) {
|
||||
Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => {
|
||||
if (
|
||||
/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) ||
|
||||
subBlock.type === 'oauth-input'
|
||||
) {
|
||||
subBlock.value = ''
|
||||
}
|
||||
if (key === 'tagFilters' || key === 'documentTags') {
|
||||
subBlock.value = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (block.data) {
|
||||
Object.entries(block.data).forEach(([key, value]: [string, any]) => {
|
||||
if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) {
|
||||
block.data[key] = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
// Use unified sanitization with env var preservation for export
|
||||
const sanitizedState = sanitizeWorkflowForSharing(fullState, {
|
||||
preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows
|
||||
})
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
state: clonedState,
|
||||
state: sanitizedState,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,11 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// Allow public access to template pages for SEO
|
||||
if (url.pathname.startsWith('/templates')) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/workspace')) {
|
||||
if (!hasActiveSession) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
|
||||
@@ -23,6 +23,7 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
isConsoleExpandedByDefault: true,
|
||||
showFloatingControls: true,
|
||||
showTrainingControls: false,
|
||||
superUserModeEnabled: true,
|
||||
theme: 'system' as const, // Keep for compatibility but not used
|
||||
telemetryEnabled: true,
|
||||
isLoading: false,
|
||||
@@ -37,6 +38,7 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
isBillingUsageNotificationsEnabled: true,
|
||||
isFloatingControlsLoading: false,
|
||||
isTrainingControlsLoading: false,
|
||||
isSuperUserModeLoading: false,
|
||||
}
|
||||
|
||||
// Optimistic update helper
|
||||
@@ -122,6 +124,17 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
)
|
||||
},
|
||||
|
||||
toggleSuperUserMode: async () => {
|
||||
if (get().isSuperUserModeLoading) return
|
||||
const newValue = !get().superUserModeEnabled
|
||||
await updateSettingOptimistic(
|
||||
'superUserModeEnabled',
|
||||
newValue,
|
||||
'isSuperUserModeLoading',
|
||||
'superUserModeEnabled'
|
||||
)
|
||||
},
|
||||
|
||||
setTheme: async (theme) => {
|
||||
if (get().isThemeLoading) return
|
||||
|
||||
@@ -219,6 +232,7 @@ export const useGeneralStore = create<GeneralStore>()(
|
||||
isConsoleExpandedByDefault: data.consoleExpandedByDefault ?? true,
|
||||
showFloatingControls: data.showFloatingControls ?? true,
|
||||
showTrainingControls: data.showTrainingControls ?? false,
|
||||
superUserModeEnabled: data.superUserModeEnabled ?? true,
|
||||
theme: data.theme || 'system',
|
||||
telemetryEnabled: data.telemetryEnabled,
|
||||
isBillingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true,
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface General {
|
||||
isConsoleExpandedByDefault: boolean
|
||||
showFloatingControls: boolean
|
||||
showTrainingControls: boolean
|
||||
superUserModeEnabled: boolean
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
telemetryEnabled: boolean
|
||||
isLoading: boolean
|
||||
@@ -17,6 +18,7 @@ export interface General {
|
||||
isBillingUsageNotificationsEnabled: boolean
|
||||
isFloatingControlsLoading: boolean
|
||||
isTrainingControlsLoading: boolean
|
||||
isSuperUserModeLoading: boolean
|
||||
}
|
||||
|
||||
export interface GeneralActions {
|
||||
@@ -25,6 +27,7 @@ export interface GeneralActions {
|
||||
toggleConsoleExpandedByDefault: () => Promise<void>
|
||||
toggleFloatingControls: () => Promise<void>
|
||||
toggleTrainingControls: () => Promise<void>
|
||||
toggleSuperUserMode: () => Promise<void>
|
||||
setTheme: (theme: 'system' | 'light' | 'dark') => Promise<void>
|
||||
setTelemetryEnabled: (enabled: boolean) => Promise<void>
|
||||
setBillingUsageNotificationsEnabled: (enabled: boolean) => Promise<void>
|
||||
@@ -41,6 +44,7 @@ export type UserSettings = {
|
||||
consoleExpandedByDefault: boolean
|
||||
showFloatingControls: boolean
|
||||
showTrainingControls: boolean
|
||||
superUserModeEnabled: boolean
|
||||
telemetryEnabled: boolean
|
||||
isBillingUsageNotificationsEnabled: boolean
|
||||
}
|
||||
|
||||
@@ -475,6 +475,18 @@ export const useWorkflowRegistry = create<WorkflowRegistry>()(
|
||||
useWorkflowStore.setState(workflowState)
|
||||
useSubBlockStore.getState().initializeFromWorkflow(id, (workflowState as any).blocks || {})
|
||||
|
||||
// Load workflow variables if they exist
|
||||
if (workflowData?.variables && typeof workflowData.variables === 'object') {
|
||||
useVariablesStore.setState((state) => {
|
||||
const withoutWorkflow = Object.fromEntries(
|
||||
Object.entries(state.variables).filter(([, v]: any) => v.workflowId !== id)
|
||||
)
|
||||
return {
|
||||
variables: { ...withoutWorkflow, ...workflowData.variables },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('active-workflow-changed', {
|
||||
detail: { workflowId: id },
|
||||
|
||||
7
apps/sim/types/creator-profile.ts
Normal file
7
apps/sim/types/creator-profile.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface CreatorProfileDetails {
|
||||
about?: string
|
||||
xUrl?: string
|
||||
linkedinUrl?: string
|
||||
websiteUrl?: string
|
||||
contactEmail?: string
|
||||
}
|
||||
47
packages/db/migrations/0107_silky_agent_brand.sql
Normal file
47
packages/db/migrations/0107_silky_agent_brand.sql
Normal 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";
|
||||
7672
packages/db/migrations/meta/0107_snapshot.json
Normal file
7672
packages/db/migrations/meta/0107_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -743,6 +743,13 @@
|
||||
"when": 1762371130884,
|
||||
"tag": "0106_bitter_captain_midlands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 107,
|
||||
"version": "7",
|
||||
"when": 1762565365042,
|
||||
"tag": "0107_silky_agent_brand",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const user = pgTable('user', {
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
isSuperUser: boolean('is_super_user').notNull().default(false),
|
||||
})
|
||||
|
||||
export const session = pgTable(
|
||||
@@ -423,6 +424,7 @@ export const settings = pgTable('settings', {
|
||||
// UI preferences
|
||||
showFloatingControls: boolean('show_floating_controls').notNull().default(true),
|
||||
showTrainingControls: boolean('show_training_controls').notNull().default(false),
|
||||
superUserModeEnabled: boolean('super_user_mode_enabled').notNull().default(true),
|
||||
|
||||
// Copilot preferences - maps model_id to enabled/disabled boolean
|
||||
copilotEnabledModels: jsonb('copilot_enabled_models').notNull().default('{}'),
|
||||
@@ -1305,40 +1307,61 @@ export const workflowCheckpoints = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
export const templateStatusEnum = pgEnum('template_status', ['pending', 'approved', 'rejected'])
|
||||
export const templateCreatorTypeEnum = pgEnum('template_creator_type', ['user', 'organization'])
|
||||
|
||||
export const templateCreators = pgTable(
|
||||
'template_creators',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
referenceType: templateCreatorTypeEnum('reference_type').notNull(),
|
||||
referenceId: text('reference_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
profileImageUrl: text('profile_image_url'),
|
||||
details: jsonb('details'),
|
||||
createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
referenceUniqueIdx: uniqueIndex('template_creators_reference_idx').on(
|
||||
table.referenceType,
|
||||
table.referenceId
|
||||
),
|
||||
referenceIdIdx: index('template_creators_reference_id_idx').on(table.referenceId),
|
||||
createdByIdx: index('template_creators_created_by_idx').on(table.createdBy),
|
||||
})
|
||||
)
|
||||
|
||||
export const templates = pgTable(
|
||||
'templates',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
workflowId: text('workflow_id').references(() => workflow.id),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
workflowId: text('workflow_id').references(() => workflow.id, { onDelete: 'set null' }),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
author: text('author').notNull(),
|
||||
details: jsonb('details'),
|
||||
creatorId: text('creator_id').references(() => templateCreators.id, { onDelete: 'set null' }),
|
||||
views: integer('views').notNull().default(0),
|
||||
stars: integer('stars').notNull().default(0),
|
||||
color: text('color').notNull().default('#3972F6'),
|
||||
icon: text('icon').notNull().default('FileText'), // Lucide icon name as string
|
||||
category: text('category').notNull(),
|
||||
state: jsonb('state').notNull(), // Using jsonb for better performance
|
||||
status: templateStatusEnum('status').notNull().default('pending'),
|
||||
tags: text('tags').array().notNull().default(sql`'{}'::text[]`), // Array of tags
|
||||
requiredCredentials: jsonb('required_credentials').notNull().default('[]'), // Array of credential requirements
|
||||
state: jsonb('state').notNull(), // Store the workflow state directly
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Primary access patterns
|
||||
workflowIdIdx: index('templates_workflow_id_idx').on(table.workflowId),
|
||||
userIdIdx: index('templates_user_id_idx').on(table.userId),
|
||||
categoryIdx: index('templates_category_idx').on(table.category),
|
||||
statusIdx: index('templates_status_idx').on(table.status),
|
||||
creatorIdIdx: index('templates_creator_id_idx').on(table.creatorId),
|
||||
|
||||
// Sorting indexes for popular/trending templates
|
||||
viewsIdx: index('templates_views_idx').on(table.views),
|
||||
starsIdx: index('templates_stars_idx').on(table.stars),
|
||||
|
||||
// Composite indexes for common queries
|
||||
categoryViewsIdx: index('templates_category_views_idx').on(table.category, table.views),
|
||||
categoryStarsIdx: index('templates_category_stars_idx').on(table.category, table.stars),
|
||||
userCategoryIdx: index('templates_user_category_idx').on(table.userId, table.category),
|
||||
statusViewsIdx: index('templates_status_views_idx').on(table.status, table.views),
|
||||
statusStarsIdx: index('templates_status_stars_idx').on(table.status, table.stars),
|
||||
|
||||
// Temporal indexes
|
||||
createdAtIdx: index('templates_created_at_idx').on(table.createdAt),
|
||||
|
||||
Reference in New Issue
Block a user