fix(templates): fix templates details page (#1942)

* Fix template details

* Fix deps
This commit is contained in:
Siddharth Ganesan
2025-11-12 11:16:15 -08:00
committed by GitHub
parent cb39e697e2
commit 79b318fd9c
6 changed files with 189 additions and 666 deletions

View File

@@ -51,7 +51,6 @@ import {
} from 'lucide-react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import { Tooltip } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
@@ -60,6 +59,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { cn } from '@/lib/utils'
import type { CredentialRequirement } from '@/lib/workflows/credential-extractor'
@@ -110,14 +110,19 @@ const iconMap = {
Award,
}
export default function TemplateDetails() {
interface TemplateDetailsProps {
isWorkspaceContext?: boolean
}
export default function TemplateDetails({ isWorkspaceContext = false }: TemplateDetailsProps) {
const router = useRouter()
const searchParams = useSearchParams()
const params = useParams()
const templateId = params?.id as string
const workspaceId = isWorkspaceContext ? (params?.workspaceId as string) : null
const { data: session } = useSession()
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 }>
@@ -139,6 +144,8 @@ export default function TemplateDetails() {
const [showWorkspaceSelectorForEdit, setShowWorkspaceSelectorForEdit] = useState(false)
const [showWorkspaceSelectorForUse, setShowWorkspaceSelectorForUse] = useState(false)
const currentUserId = session?.user?.id || null
// Fetch template data on client side
useEffect(() => {
if (!templateId) {
@@ -156,28 +163,15 @@ export default function TemplateDetails() {
setStarCount(data.data.stars || 0)
}
} catch (error) {
console.error('Error fetching template:', error)
logger.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 () => {
if (!currentUserId) return
try {
const response = await fetch('/api/organizations')
if (response.ok) {
@@ -192,11 +186,13 @@ export default function TemplateDetails() {
setCurrentUserOrgRoles(orgRoles)
}
} catch (error) {
console.error('Error fetching organizations:', error)
logger.error('Error fetching organizations:', error)
}
}
const fetchSuperUserStatus = async () => {
if (!currentUserId) return
try {
const response = await fetch('/api/user/super-user')
if (response.ok) {
@@ -204,15 +200,14 @@ export default function TemplateDetails() {
setIsSuperUser(data.isSuperUser || false)
}
} catch (error) {
console.error('Error fetching super user status:', error)
logger.error('Error fetching super user status:', error)
}
}
fetchTemplate()
fetchCurrentUser()
fetchSuperUserStatus()
fetchUserOrganizations()
}, [templateId])
}, [templateId, currentUserId])
// Fetch workspaces when user is logged in
useEffect(() => {
@@ -235,7 +230,7 @@ export default function TemplateDetails() {
setWorkspaces(availableWorkspaces)
}
} catch (error) {
console.error('Error fetching workspaces:', error)
logger.error('Error fetching workspaces:', error)
} finally {
setIsLoadingWorkspaces(false)
}
@@ -247,9 +242,14 @@ export default function TemplateDetails() {
// Clean up URL when returning from login
useEffect(() => {
if (template && searchParams?.get('use') === 'true' && currentUserId) {
router.replace(`/templates/${template.id}`)
if (isWorkspaceContext && workspaceId) {
handleWorkspaceSelectForUse(workspaceId)
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
} else {
router.replace(`/templates/${template.id}`)
}
}
}, [searchParams, currentUserId, template, router])
}, [searchParams, currentUserId, template, isWorkspaceContext, workspaceId, router])
// Check if user can edit template
const canEditTemplate = (() => {
@@ -355,7 +355,7 @@ export default function TemplateDetails() {
/>
)
} catch (error) {
console.error('Error rendering workflow preview:', error)
logger.error('Error rendering workflow preview:', error)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-muted-foreground'>
@@ -368,7 +368,11 @@ export default function TemplateDetails() {
}
const handleBack = () => {
router.push('/templates')
if (isWorkspaceContext) {
router.back()
} else {
router.push('/templates')
}
}
const handleStarToggle = async () => {
@@ -392,37 +396,59 @@ export default function TemplateDetails() {
const handleUseTemplate = () => {
if (!currentUserId) {
const callbackUrl = encodeURIComponent(`/templates/${template.id}`)
const callbackUrl =
isWorkspaceContext && workspaceId
? encodeURIComponent(`/workspace/${workspaceId}/templates/${template.id}?use=true`)
: encodeURIComponent(`/templates/${template.id}`)
router.push(`/login?callbackUrl=${callbackUrl}`)
return
}
setShowWorkspaceSelectorForUse(true)
// In workspace context, use current workspace directly
if (isWorkspaceContext && workspaceId) {
handleWorkspaceSelectForUse(workspaceId)
} else {
setShowWorkspaceSelectorForUse(true)
}
}
const handleEditTemplate = async () => {
if (!currentUserId || !template) return
// Check if workflow exists and user has access
if (template.workflowId) {
// In workspace context with existing workflow, navigate directly
if (isWorkspaceContext && workspaceId && template.workflowId) {
setIsEditing(true)
try {
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.ok) {
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
return
}
} catch (error) {
logger.error('Error checking workflow:', error)
} finally {
setIsEditing(false)
}
// If workflow doesn't exist, fall through to workspace selector
}
// Check if workflow exists and user has access (global context)
if (template.workflowId && !isWorkspaceContext) {
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}`
const templateWorkspaceId = result.data?.workspaceId
if (templateWorkspaceId) {
window.location.href = `/workspace/${templateWorkspaceId}/w/${template.workflowId}`
return
}
}
@@ -433,8 +459,12 @@ export default function TemplateDetails() {
}
}
// Workflow doesn't exist or was deleted - show workspace selector
setShowWorkspaceSelectorForEdit(true)
// Workflow doesn't exist - show workspace selector or use current workspace
if (isWorkspaceContext && workspaceId) {
handleWorkspaceSelectForEdit(workspaceId)
} else {
setShowWorkspaceSelectorForEdit(true)
}
}
const handleWorkspaceSelectForUse = async (workspaceId: string) => {
@@ -505,7 +535,11 @@ export default function TemplateDetails() {
// Update template status optimistically
setTemplate({ ...template, status: 'approved' })
// Redirect back to templates page after approval
router.push('/templates')
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
router.push('/templates')
}
}
} catch (error) {
logger.error('Error approving template:', error)
@@ -527,7 +561,11 @@ export default function TemplateDetails() {
// Update template status optimistically
setTemplate({ ...template, status: 'rejected' })
// Redirect back to templates page after rejection
router.push('/templates')
if (isWorkspaceContext && workspaceId) {
router.push(`/workspace/${workspaceId}/templates`)
} else {
router.push('/templates')
}
}
} catch (error) {
logger.error('Error rejecting template:', error)
@@ -537,7 +575,7 @@ export default function TemplateDetails() {
}
return (
<div className='flex min-h-screen flex-col'>
<div className={cn('flex min-h-screen flex-col', isWorkspaceContext && 'pl-64')}>
{/* Header */}
<div className='border-b bg-background p-6'>
<div className='mx-auto max-w-7xl'>
@@ -621,32 +659,17 @@ export default function TemplateDetails() {
</Button>
)}
{/* Edit button - for template owners (approved or pending) */}
{/* Edit button - for template owners */}
{canEditTemplate && currentUserId && (
<>
{template.workflowId && !showWorkspaceSelectorForEdit ? (
<Tooltip.Root>
<Tooltip.Trigger 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>
</Tooltip.Trigger>
{hasWorkspaceAccess === false && (
<Tooltip.Content>
<p>Don't have access to workspace to edit template</p>
</Tooltip.Content>
)}
</Tooltip.Root>
{(isWorkspaceContext || template.workflowId) && !showWorkspaceSelectorForEdit ? (
<Button
onClick={handleEditTemplate}
disabled={isEditing || (!isWorkspaceContext && hasWorkspaceAccess === false)}
className='bg-blue-600 text-white hover:bg-blue-700'
>
{isEditing ? 'Opening...' : 'Edit Template'}
</Button>
) : (
<DropdownMenu
open={showWorkspaceSelectorForEdit}
@@ -654,9 +677,7 @@ export default function TemplateDetails() {
>
<DropdownMenuTrigger asChild>
<Button
onClick={() =>
!template.workflowId && setShowWorkspaceSelectorForEdit(true)
}
onClick={() => setShowWorkspaceSelectorForEdit(true)}
disabled={isUsing || isLoadingWorkspaces}
className='bg-blue-600 text-white hover:bg-blue-700'
>
@@ -701,13 +722,26 @@ export default function TemplateDetails() {
{!currentUserId ? (
<Button
onClick={() => {
const callbackUrl = encodeURIComponent(`/templates/${template.id}`)
const callbackUrl =
isWorkspaceContext && workspaceId
? encodeURIComponent(
`/workspace/${workspaceId}/templates/${template.id}?use=true`
)
: encodeURIComponent(`/templates/${template.id}`)
router.push(`/login?callbackUrl=${callbackUrl}`)
}}
className='bg-purple-600 text-white hover:bg-purple-700'
>
Sign in to use
</Button>
) : isWorkspaceContext ? (
<Button
onClick={handleUseTemplate}
disabled={isUsing}
className='bg-purple-600 text-white hover:bg-purple-700'
>
{isUsing ? 'Creating...' : 'Use this template'}
</Button>
) : (
<DropdownMenu
open={showWorkspaceSelectorForUse}

View File

@@ -1,5 +1,12 @@
'use client'
import { Tooltip } from '@/components/emcn'
import { season } from '@/app/fonts/season/season'
export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
return <div className={`${season.variable} font-season`}>{children}</div>
return (
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
<div className={`${season.variable} font-season`}>{children}</div>
</Tooltip.Provider>
)
}

View File

@@ -1,9 +1,34 @@
import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import TemplateDetails from '@/app/templates/[id]/template'
interface TemplatePageProps {
params: Promise<{
workspaceId: string
id: string
}>
}
/**
* Workspace-scoped template detail page.
* Data fetching is handled client-side in the TemplateDetails component.
* Requires authentication and workspace membership to access.
* Uses the shared TemplateDetails component with workspace context.
*/
export default function TemplatePage() {
return <TemplateDetails />
export default async function TemplatePage({ params }: TemplatePageProps) {
const { workspaceId } = await params
const session = await getSession()
// Require authentication
if (!session?.user?.id) {
redirect('/login')
}
// Verify workspace membership
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
return <TemplateDetails isWorkspaceContext={true} />
}

View File

@@ -1,566 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import {
ArrowLeft,
Award,
BarChart3,
Bell,
BookOpen,
Bot,
Brain,
Briefcase,
Calculator,
Cloud,
Code,
Cpu,
CreditCard,
Database,
DollarSign,
Edit,
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 { Button } from '@/components/emcn'
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/workspace/[workspaceId]/templates/templates'
import { WorkflowPreview } from '@/app/workspace/[workspaceId]/w/components/workflow-preview/workflow-preview'
import { getBlock } from '@/blocks/registry'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('TemplateDetails')
// Icon mapping - reuse from template-card
const iconMap = {
FileText,
NotebookPen,
BookOpen,
Edit,
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,
}
/**
* Get icon component from template icon name
*/
const getIconComponent = (icon: string): React.ReactNode => {
const IconComponent = iconMap[icon as keyof typeof iconMap]
return IconComponent ? (
<IconComponent className='h-[14px] w-[14px]' />
) : (
<FileText className='h-[14px] w-[14px]' />
)
}
/**
* Template detail page component
* Fetches and displays detailed information about a specific template
*/
export default function TemplateDetails() {
const router = useRouter()
const searchParams = useSearchParams()
const params = useParams()
const workspaceId = params?.workspaceId as string
const templateId = params?.id as string
// State for template data
const [template, setTemplate] = useState<Template | null>(null)
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
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)
// Fetch template data on client side
useEffect(() => {
if (!templateId || !workspaceId) {
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) {
logger.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) {
logger.error('Error fetching session:', error)
setCurrentUserId(null)
}
}
fetchTemplate()
fetchCurrentUser()
}, [templateId, workspaceId])
// Auto-use template after login if use=true query param is present
useEffect(() => {
if (!template?.id) return
const shouldAutoUse = searchParams?.get('use') === 'true'
if (shouldAutoUse && currentUserId && !isUsing) {
handleUseTemplate()
router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
}
}, [searchParams, currentUserId, template?.id])
if (loading) {
return (
<div className='flex h-[100vh] items-center justify-center pl-64'>
<div className='text-center'>
<div className='mb-[14px] font-medium text-[18px]'>Loading...</div>
<p className='text-[14px] text-[var(--text-tertiary)]'>Fetching template details</p>
</div>
</div>
)
}
if (!template) {
return (
<div className='flex h-[100vh] items-center justify-center pl-64'>
<div className='text-center'>
<h1 className='mb-[14px] font-medium text-[18px]'>Template Not Found</h1>
<p className='text-[14px] text-[var(--text-tertiary)]'>
The template you're looking for doesn't exist.
</p>
</div>
</div>
)
}
const templateAuthor = template.author || template.creator?.name || 'Unknown'
const templateAuthorType = template.authorType || template.creator?.referenceType || 'user'
const templateDescription = template.description || template.details?.tagline || null
const templateColor = template.color || 'var(--brand-primary)'
const templateIcon = template.icon || 'Workflow'
const templateOwnerId =
template.userId ||
(template.creator?.referenceType === 'user' ? template.creator.referenceId : null)
const isOwner = currentUserId && templateOwnerId === currentUserId
const renderWorkflowPreview = () => {
if (!template?.state) {
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-[var(--text-tertiary)]'>
<div className='mb-[10px] font-medium text-[14px]'> No Workflow Data</div>
<div className='text-[12px]'>This template doesn't contain workflow state data.</div>
</div>
</div>
)
}
try {
return (
<WorkflowPreview
workflowState={template.state as WorkflowState}
showSubBlocks={true}
height='100%'
width='100%'
isPannable={true}
defaultPosition={{ x: 0, y: 0 }}
defaultZoom={1}
/>
)
} catch (error) {
logger.error('Error rendering workflow preview:', error)
return (
<div className='flex h-full items-center justify-center text-center'>
<div className='text-[var(--text-tertiary)]'>
<div className='mb-[10px] font-medium text-[14px]'>⚠️ Preview Error</div>
<div className='text-[12px]'>Unable to render workflow preview</div>
</div>
</div>
)
}
}
const handleBack = () => {
router.back()
}
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 = async () => {
if (isUsing) return
if (!currentUserId) {
const callbackUrl = encodeURIComponent(
`/workspace/${workspaceId}/templates/${template.id}?use=true`
)
router.push(`/login?callbackUrl=${callbackUrl}`)
return
}
setIsUsing(true)
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()
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
} catch (error) {
logger.error('Error using template:', error)
} finally {
setIsUsing(false)
}
}
const handleEditTemplate = async () => {
if (isEditing || !currentUserId) return
setIsEditing(true)
try {
if (template.workflowId) {
const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
if (checkResponse.ok) {
router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
return
}
}
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()
router.push(`/workspace/${workspaceId}/w/${workflowId}`)
} catch (error) {
logger.error('Error editing template:', error)
} finally {
setIsEditing(false)
}
}
return (
<div className='flex h-[100vh] flex-col pl-64'>
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
<button
onClick={handleBack}
className='mb-[14px] flex items-center gap-[8px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
<ArrowLeft className='h-[14px] w-[14px]' />
<span className='font-medium text-[12px]'>Go back</span>
</button>
<div>
<div className='flex items-start gap-[12px]'>
<div
className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px]'
style={{ backgroundColor: templateColor }}
>
{getIconComponent(templateIcon)}
</div>
<h1 className='font-medium text-[18px]'>{template.name}</h1>
</div>
<p className='mt-[10px] font-base text-[14px] text-[var(--text-tertiary)]'>
{templateDescription}
</p>
</div>
<div className='mt-[14px] flex items-center justify-between'>
<div className='flex items-center gap-[12px] font-medium text-[12px] text-[var(--text-tertiary)]'>
<div className='flex items-center gap-[6px]'>
<Eye className='h-[12px] w-[12px]' />
<span>{template.views} views</span>
</div>
<div className='flex items-center gap-[6px]'>
<Star className='h-[12px] w-[12px]' />
<span>{starCount} stars</span>
</div>
<div className='flex items-center gap-[6px]'>
<User className='h-[12px] w-[12px]' />
<span>by {templateAuthor}</span>
</div>
{templateAuthorType === 'organization' && (
<div className='flex items-center gap-[6px]'>
<Users className='h-[12px] w-[12px]' />
<span>Organization</span>
</div>
)}
</div>
{/* Action buttons */}
<div className='flex items-center gap-[8px]'>
{/* Star button - only for logged-in users */}
{currentUserId && (
<Button
variant={isStarred ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={handleStarToggle}
disabled={isStarring}
>
<Star className={cn('mr-[6px] h-[14px] w-[14px]', isStarred && 'fill-current')} />
<span className='font-medium text-[12px]'>{starCount}</span>
</Button>
)}
{/* Edit button - only for template owner when logged in */}
{isOwner && currentUserId && (
<Button
variant='default'
className='h-[32px] rounded-[6px]'
onClick={handleEditTemplate}
disabled={isEditing}
>
<Edit className='mr-[6px] h-[14px] w-[14px]' />
<span className='font-medium text-[12px]'>{isEditing ? 'Opening...' : 'Edit'}</span>
</Button>
)}
{/* Use template button */}
<Button
variant='active'
className='h-[32px] rounded-[6px]'
onClick={handleUseTemplate}
disabled={isUsing}
>
<span className='font-medium text-[12px]'>
{isUsing ? 'Creating...' : currentUserId ? 'Use this template' : 'Sign in to use'}
</span>
</Button>
</div>
</div>
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
{/* Creator Profile */}
{template.creator && (
<div className='mt-[24px]'>
<h3 className='mb-[12px] font-medium text-[14px]'>Creator</h3>
<div className='rounded-[8px] border border-[var(--border)] bg-[var(--surface-3)] p-[16px]'>
<div className='flex items-start gap-[16px]'>
<div className='flex-shrink-0'>
{template.creator.profileImageUrl ? (
<div className='relative h-[56px] w-[56px] 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-[56px] w-[56px] items-center justify-center rounded-full bg-[var(--brand-primary)]'>
<User className='h-[28px] w-[28px] text-white' />
</div>
)}
</div>
<div className='flex-1'>
<h4 className='font-medium text-[14px]'>{template.creator.name}</h4>
{template.creator.details?.about && (
<p className='mt-[8px] text-[13px] text-[var(--text-tertiary)] leading-relaxed'>
{template.creator.details.about}
</p>
)}
{(template.creator.details?.xUrl ||
template.creator.details?.linkedinUrl ||
template.creator.details?.websiteUrl ||
template.creator.details?.contactEmail) && (
<div className='mt-[12px] flex flex-wrap gap-[12px]'>
{template.creator.details.xUrl && (
<a
href={template.creator.details.xUrl}
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center gap-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
<Twitter className='h-[14px] w-[14px]' />
<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-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
<Linkedin className='h-[14px] w-[14px]' />
<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-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
<Globe className='h-[14px] w-[14px]' />
<span>Website</span>
</a>
)}
{template.creator.details.contactEmail && (
<a
href={`mailto:${template.creator.details.contactEmail}`}
className='inline-flex items-center gap-[6px] text-[13px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]'
>
<Mail className='h-[14px] w-[14px]' />
<span>Contact</span>
</a>
)}
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Description */}
{template.details?.about && (
<div className='mt-[24px]'>
<h3 className='mb-[12px] font-medium text-[14px]'>Description</h3>
<div className='prose prose-sm dark:prose-invert max-w-none text-[13px] text-[var(--text-secondary)]'>
<ReactMarkdown>{template.details.about}</ReactMarkdown>
</div>
</div>
)}
<div className='mt-[24px]'>
<h2 className='mb-[14px] font-medium text-[14px]'>Workflow Preview</h2>
<div className='h-[600px] w-full overflow-hidden rounded-[8px] bg-[var(--surface-3)]'>
{renderWorkflowPreview()}
</div>
</div>
{/* Required Credentials */}
{Array.isArray(template.requiredCredentials) && template.requiredCredentials.length > 0 && (
<div className='mt-[24px]'>
<h3 className='mb-[12px] font-medium text-[14px]'>Credentials Needed</h3>
<ul className='list-disc space-y-[4px] pl-[20px] text-[13px] text-[var(--text-tertiary)]'>
{template.requiredCredentials.map((cred: CredentialRequirement, idx: number) => {
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>
)}
</div>
</div>
)
}

View File

@@ -1,39 +1,53 @@
import { db } from '@sim/db'
import { settings, templateCreators, templateStars, templates, user } from '@sim/db/schema'
import { and, desc, eq, sql } from 'drizzle-orm'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
import type { Template as WorkspaceTemplate } from '@/app/workspace/[workspaceId]/templates/templates'
import Templates from '@/app/workspace/[workspaceId]/templates/templates'
interface TemplatesPageProps {
params: Promise<{
workspaceId: string
}>
}
/**
* Workspace-scoped Templates page.
*
* Mirrors the global templates data loading while rendering the workspace
* templates UI (which accounts for the sidebar layout). This avoids redirecting
* to the global /templates route and keeps users within their workspace context.
* Requires authentication and workspace membership to access.
*/
export default async function TemplatesPage() {
export default async function TemplatesPage({ params }: TemplatesPageProps) {
const { workspaceId } = await params
const session = await getSession()
// Determine effective super user (DB flag AND UI mode 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
effectiveSuperUser = isSuperUser && superUserModeEnabled
// Require authentication
if (!session?.user?.id) {
redirect('/login')
}
// Verify workspace membership
const hasPermission = await verifyWorkspaceMembership(session.user.id, workspaceId)
if (!hasPermission) {
redirect('/')
}
// Determine effective super user (DB flag AND UI mode enabled)
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
const effectiveSuperUser = isSuperUser && superUserModeEnabled
// Load templates from database
let rows:
| Array<{

View File

@@ -193,6 +193,15 @@ export default function Templates({
>
Your Templates
</Button>
{isSuperUser && (
<Button
variant={activeTab === 'pending' ? 'active' : 'default'}
className='h-[32px] rounded-[6px]'
onClick={() => setActiveTab('pending')}
>
Pending
</Button>
)}
</div>
</div>