From a73e2aaa8bfec9b0ef449db1b9db780bdef90a98 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Fri, 7 Nov 2025 17:57:53 -0800 Subject: [PATCH] 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 Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com> --- .../app/api/creator-profiles/[id]/route.ts | 180 + apps/sim/app/api/creator-profiles/route.ts | 194 + apps/sim/app/api/files/authorization.ts | 6 + .../app/api/files/serve/[...path]/route.ts | 88 +- .../app/api/templates/[id]/approve/route.ts | 106 + .../app/api/templates/[id]/reject/route.ts | 57 + apps/sim/app/api/templates/[id]/route.ts | 231 +- apps/sim/app/api/templates/[id]/use/route.ts | 221 +- apps/sim/app/api/templates/route.ts | 234 +- apps/sim/app/api/user/super-user/route.ts | 42 + apps/sim/app/api/users/me/settings/route.ts | 3 + apps/sim/app/api/workflows/[id]/route.ts | 10 +- .../sim/app/api/workflows/[id]/state/route.ts | 21 +- apps/sim/app/templates/[id]/page.tsx | 5 + apps/sim/app/templates/[id]/template.tsx | 936 ++ .../templates/components/navigation-tabs.tsx | 33 + .../templates/components/template-card.tsx | 463 + apps/sim/app/templates/layout.tsx | 5 + apps/sim/app/templates/navigation-tabs.tsx | 33 + apps/sim/app/templates/page.tsx | 99 + apps/sim/app/templates/template-card.tsx | 564 ++ apps/sim/app/templates/templates.tsx | 312 + .../[workspaceId]/templates/[id]/page.tsx | 96 +- .../[workspaceId]/templates/[id]/template.tsx | 187 +- .../templates/components/template-card.tsx | 79 +- .../[workspaceId]/templates/page.tsx | 47 +- .../[workspaceId]/templates/templates.tsx | 318 +- .../deploy-modal/components/index.ts | 1 + .../template-deploy/template-deploy.tsx | 509 ++ .../components/deploy-modal/deploy-modal.tsx | 24 +- .../template-modal/template-modal.tsx | 120 +- .../components/control-bar/control-bar.tsx | 88 +- .../navigation-item/navigation-item.tsx | 1 + .../creator-profile/creator-profile.tsx | 526 ++ .../components/general/general.tsx | 72 +- .../settings-modal/components/index.ts | 1 + .../settings-navigation.tsx | 9 + .../settings-modal/settings-modal.tsx | 7 + apps/sim/components/ui/index.ts | 1 + apps/sim/components/ui/tag-input.tsx | 103 + apps/sim/hooks/use-mcp-tools.ts | 7 + apps/sim/lib/copilot/process-contents.ts | 11 +- .../sim/lib/workflows/credential-extractor.ts | 269 + apps/sim/lib/workflows/db-helpers.ts | 140 + apps/sim/lib/workflows/json-sanitizer.ts | 47 +- apps/sim/middleware.ts | 5 + apps/sim/stores/settings/general/store.ts | 14 + apps/sim/stores/settings/general/types.ts | 4 + apps/sim/stores/workflows/registry/store.ts | 12 + apps/sim/types/creator-profile.ts | 7 + .../db/migrations/0107_silky_agent_brand.sql | 47 + .../db/migrations/meta/0107_snapshot.json | 7672 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 55 +- 54 files changed, 13451 insertions(+), 878 deletions(-) create mode 100644 apps/sim/app/api/creator-profiles/[id]/route.ts create mode 100644 apps/sim/app/api/creator-profiles/route.ts create mode 100644 apps/sim/app/api/templates/[id]/approve/route.ts create mode 100644 apps/sim/app/api/templates/[id]/reject/route.ts create mode 100644 apps/sim/app/api/user/super-user/route.ts create mode 100644 apps/sim/app/templates/[id]/page.tsx create mode 100644 apps/sim/app/templates/[id]/template.tsx create mode 100644 apps/sim/app/templates/components/navigation-tabs.tsx create mode 100644 apps/sim/app/templates/components/template-card.tsx create mode 100644 apps/sim/app/templates/layout.tsx create mode 100644 apps/sim/app/templates/navigation-tabs.tsx create mode 100644 apps/sim/app/templates/page.tsx create mode 100644 apps/sim/app/templates/template-card.tsx create mode 100644 apps/sim/app/templates/templates.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/template-deploy/template-deploy.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/creator-profile/creator-profile.tsx create mode 100644 apps/sim/components/ui/tag-input.tsx create mode 100644 apps/sim/lib/workflows/credential-extractor.ts create mode 100644 apps/sim/types/creator-profile.ts create mode 100644 packages/db/migrations/0107_silky_agent_brand.sql create mode 100644 packages/db/migrations/meta/0107_snapshot.json diff --git a/apps/sim/app/api/creator-profiles/[id]/route.ts b/apps/sim/app/api/creator-profiles/[id]/route.ts new file mode 100644 index 000000000..6188d1ab7 --- /dev/null +++ b/apps/sim/app/api/creator-profiles/[id]/route.ts @@ -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 { + 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 }) + } +} diff --git a/apps/sim/app/api/creator-profiles/route.ts b/apps/sim/app/api/creator-profiles/route.ts new file mode 100644 index 000000000..db0dfa63e --- /dev/null +++ b/apps/sim/app/api/creator-profiles/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index 3291eda56..95f644458 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -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) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index b4d00c66f..1a52f36ee 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -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 { + 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 { + 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 + } +} diff --git a/apps/sim/app/api/templates/[id]/approve/route.ts b/apps/sim/app/api/templates/[id]/approve/route.ts new file mode 100644 index 000000000..0f36e1dc1 --- /dev/null +++ b/apps/sim/app/api/templates/[id]/approve/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/templates/[id]/reject/route.ts b/apps/sim/app/api/templates/[id]/reject/route.ts new file mode 100644 index 000000000..db29efa54 --- /dev/null +++ b/apps/sim/app/api/templates/[id]/reject/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 8420e6b51..22821fc53 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -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)) diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts index 88165d8a3..4047e9f3d 100644 --- a/apps/sim/app/api/templates/[id]/use/route.ts +++ b/apps/sim/app/api/templates/[id]/use/route.ts @@ -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 + | undefined + const remappedVariables: Record = (() => { + if (!templateVariables || typeof templateVariables !== 'object') return {} + const mapped: Record = {} + 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() - - 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', diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index 76383d647..6d805d012 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -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`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`, + isSuperUser: sql`${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 } ) diff --git a/apps/sim/app/api/user/super-user/route.ts b/apps/sim/app/api/user/super-user/route.ts new file mode 100644 index 000000000..9557c988d --- /dev/null +++ b/apps/sim/app/api/user/super-user/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index 309b2176a..0b8d36764 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -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 } diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts index ceddfaf25..1b51895c4 100644 --- a/apps/sim/app/api/workflows/[id]/route.ts +++ b/apps/sim/app/api/workflows/[id]/route.ts @@ -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)) diff --git a/apps/sim/app/api/workflows/[id]/state/route.ts b/apps/sim/app/api/workflows/[id]/state/route.ts index b3a338bff..ffd66cc16 100644 --- a/apps/sim/app/api/workflows/[id]/state/route.ts +++ b/apps/sim/app/api/workflows/[id]/state/route.ts @@ -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`) diff --git a/apps/sim/app/templates/[id]/page.tsx b/apps/sim/app/templates/[id]/page.tsx new file mode 100644 index 000000000..f2a333593 --- /dev/null +++ b/apps/sim/app/templates/[id]/page.tsx @@ -0,0 +1,5 @@ +import TemplateDetails from './template' + +export default function TemplatePage() { + return +} diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx new file mode 100644 index 000000000..67117b788 --- /dev/null +++ b/apps/sim/app/templates/[id]/template.tsx @@ -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