fix(settings): fix broken api keys, help modal, logs, workflow renaming (#1945)

* fix(settings): fix broken api keys, help modal, logs, workflow renaming

* fix build

* cleanup

* use emcn
This commit is contained in:
Waleed
2025-11-12 13:43:48 -08:00
committed by GitHub
parent 6315cc105b
commit 2fbe0de5d3
45 changed files with 1314 additions and 1211 deletions

View File

@@ -16,7 +16,7 @@ import {
RotateCcw,
} from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Button, Tooltip } from '@/components/emcn'
import { Trash } from '@/components/emcn/icons/trash'
import {
AlertDialog,
@@ -28,7 +28,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { SearchHighlight } from '@/components/ui/search-highlight'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
@@ -1006,7 +1005,6 @@ export function KnowledgeBase({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleRetryDocument(doc.id)
@@ -1024,7 +1022,6 @@ export function KnowledgeBase({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleToggleEnabled(doc.id)
@@ -1059,7 +1056,6 @@ export function KnowledgeBase({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleDeleteDocument(doc.id)
@@ -1070,7 +1066,7 @@ export function KnowledgeBase({
}
className='h-8 w-8 p-0 text-gray-500 hover:text-red-600 disabled:opacity-50'
>
<Trash className='h-4 w-4' />
<Trash className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
@@ -1097,7 +1093,6 @@ export function KnowledgeBase({
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='sm'
onClick={prevPage}
disabled={!hasPrevPage || isLoadingDocuments}
className='h-8 w-8 p-0'
@@ -1138,7 +1133,6 @@ export function KnowledgeBase({
<Button
variant='ghost'
size='sm'
onClick={nextPage}
disabled={!hasNextPage || isLoadingDocuments}
className='h-8 w-8 p-0'

View File

@@ -1,13 +1,12 @@
'use client'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/utils'
interface PrimaryButtonProps {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
size?: 'sm' | 'default' | 'lg'
className?: string
type?: 'button' | 'submit' | 'reset'
}
@@ -16,7 +15,6 @@ export function PrimaryButton({
children,
onClick,
disabled = false,
size = 'sm',
className,
type = 'button',
}: PrimaryButtonProps) {
@@ -25,9 +23,9 @@ export function PrimaryButton({
type={type}
onClick={onClick}
disabled={disabled}
size={size}
variant='primary'
className={cn(
'flex items-center gap-1 bg-[var(--brand-primary-hex)] font-[480] text-white shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:bg-[var(--brand-primary-hover-hex)] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
'flex h-8 items-center gap-1 px-[8px] py-[6px] font-[480] shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
disabled && 'disabled:cursor-not-allowed disabled:opacity-50',
className
)}

View File

@@ -3,8 +3,7 @@
import { useMemo, useState } from 'react'
import { Check, ChevronDown, LibraryBig, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Button, Tooltip } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -111,7 +110,7 @@ export function Knowledge() {
{/* Sort Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>

View File

@@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import { Loader2, RefreshCw, Search } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { Button, Tooltip } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { soehne } from '@/app/fonts/soehne/soehne'
@@ -49,7 +48,7 @@ export function Controls({
placeholder='Search workflows...'
value={searchQuery}
onChange={(e) => setSearchQuery?.(e.target.value)}
className='h-9 w-full rounded-[11px] border-[#E5E5E5] bg-[var(--white)] pr-10 pl-9 dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
className='h-9 w-full border-[#E5E5E5] bg-[var(--white)] pr-10 pl-9 dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
/>
{searchQuery && (
<button
@@ -77,9 +76,8 @@ export function Controls({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={resetToNow}
className='h-9 rounded-[11px] hover:bg-secondary'
className='h-9 w-9 p-0 hover:bg-secondary'
disabled={isRefetching}
>
{isRefetching ? (
@@ -97,9 +95,8 @@ export function Controls({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={onExport}
className='h-9 rounded-[11px] hover:bg-secondary'
className='h-9 w-9 p-0 hover:bg-secondary'
aria-label='Export CSV'
>
<svg
@@ -123,7 +120,6 @@ export function Controls({
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
<Button
variant='ghost'
size='sm'
onClick={() => setLive((v) => !v)}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',
@@ -140,7 +136,6 @@ export function Controls({
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
<Button
variant='ghost'
size='sm'
onClick={() => setViewMode('logs')}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',
@@ -154,7 +149,6 @@ export function Controls({
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => setViewMode('dashboard')}
className={cn(
'h-7 rounded-[8px] px-3 font-normal text-xs',

View File

@@ -9,25 +9,25 @@ export interface AggregateMetrics {
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
return (
<div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Total executions</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.totalExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Success rate</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.successRate.toFixed(1)}%
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Failed executions</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.failedExecutions.toLocaleString()}
</div>
</div>
<div className='rounded-[12px] border bg-card p-4 shadow-sm'>
<div className='border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Active workflows</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
</div>

View File

@@ -71,6 +71,12 @@ export function WorkflowDetails({
}) {
const router = useRouter()
const { workflows } = useWorkflowRegistry()
// Check if any logs have pending status to show Resume column
const hasPendingExecutions = useMemo(() => {
return details?.logs?.some((log) => log.hasPendingPause === true) || false
}, [details])
const workflowColor = useMemo(
() => workflows[expandedWorkflowId]?.color || '#3972F6',
[workflows, expandedWorkflowId]
@@ -136,15 +142,15 @@ export function WorkflowDetails({
</button>
</div>
<div className='flex items-center gap-2'>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Executions</span>
<span className='font-[500] text-sm leading-none'>{overview.total}</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Success</span>
<span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span>
</div>
<div className='inline-flex h-7 items-center gap-2 rounded-[10px] border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Failures</span>
<span className='font-[500] text-sm leading-none'>{overview.failures}</span>
</div>
@@ -172,7 +178,7 @@ export function WorkflowDetails({
})
: 'Selected segment'
return (
<div className='mb-4 flex items-center justify-between rounded-[10px] border bg-muted/30 px-3 py-2 text-[13px] text-foreground'>
<div className='mb-4 flex items-center justify-between border bg-muted/30 px-3 py-2 text-[13px] text-foreground'>
<div className='flex items-center gap-2'>
<div className='h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-primary/30' />
<span className='font-medium'>
@@ -264,8 +270,15 @@ export function WorkflowDetails({
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='w-full overflow-x-auto'>
<div>
<div className='border-border border-b'>
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4'>
<div className='border-b-0'>
<div
className={cn(
'grid min-w-[980px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4',
hasPendingExecutions
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
)}
>
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
Time
</div>
@@ -287,9 +300,11 @@ export function WorkflowDetails({
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Duration
</div>
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Resume
</div>
{hasPendingExecutions && (
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
Resume
</div>
)}
</div>
</div>
</div>
@@ -333,14 +348,21 @@ export function WorkflowDetails({
<div
key={log.id}
className={cn(
'cursor-pointer border-border border-b transition-all duration-200',
'cursor-pointer transition-all duration-200',
isExpanded ? 'bg-accent/30' : 'hover:bg-accent/20'
)}
onClick={() =>
setExpandedRowId((prev) => (prev === log.id ? null : log.id))
}
>
<div className='grid min-w-[980px] grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4'>
<div
className={cn(
'grid min-w-[980px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4',
hasPendingExecutions
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
)}
>
<div>
<div className='text-[13px]'>
<span className='font-sm text-muted-foreground'>
@@ -356,34 +378,40 @@ export function WorkflowDetails({
</div>
<div>
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-[400] text-xs transition-all duration-200 lg:px-[8px]',
isError
? 'bg-red-500 text-white'
: isPending
? 'bg-amber-300 text-amber-900 dark:bg-amber-500/90 dark:text-black'
: 'bg-secondary text-card-foreground'
)}
>
{statusLabel}
</div>
{isError || !isPending ? (
<div
className={cn(
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
isError
? 'gap-[5px] border-[#883827] bg-[#491515]'
: 'gap-[8px] border-[#686868] bg-[#383838]'
)}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{
backgroundColor: isError ? '#EF4444' : '#B7B7B7',
}}
/>
<span
className='font-medium text-[11.5px]'
style={{ color: isError ? '#EF4444' : '#B7B7B7' }}
>
{statusLabel}
</span>
</div>
) : (
<div className='inline-flex items-center bg-amber-300 px-[6px] py-[2px] font-[400] text-amber-900 text-xs dark:bg-amber-500/90 dark:text-black'>
{statusLabel}
</div>
)}
</div>
<div>
{log.trigger ? (
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[6px] py-[2px] font-[400] text-xs transition-all duration-200 lg:px-[8px]',
log.trigger.toLowerCase() === 'manual'
? 'bg-secondary text-card-foreground'
: 'text-white'
)}
style={
log.trigger.toLowerCase() === 'manual'
? undefined
: { backgroundColor: getTriggerColor(log.trigger) }
}
className='inline-flex items-center rounded-[6px] px-[6px] py-[2px] font-[400] text-white text-xs lg:px-[8px]'
style={{ backgroundColor: getTriggerColor(log.trigger) }}
>
{log.trigger}
</div>
@@ -403,7 +431,7 @@ export function WorkflowDetails({
{log.workflowName ? (
<div className='inline-flex items-center gap-2'>
<span
className='h-3.5 w-3.5 rounded'
className='h-3.5 w-3.5'
style={{ backgroundColor: log.workflowColor || '#64748b' }}
/>
<span
@@ -437,23 +465,25 @@ export function WorkflowDetails({
</div>
</div>
<div className='flex justify-end'>
{isPending && log.executionId ? (
<Link
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
className='inline-flex h-7 w-7 items-center justify-center rounded-md border border-primary/60 border-dashed text-primary hover:bg-primary/10'
aria-label='Open resume console'
>
<ArrowUpRight className='h-4 w-4' />
</Link>
) : (
<span className='h-7 w-7' />
)}
</div>
{hasPendingExecutions && (
<div className='flex justify-end'>
{isPending && log.executionId ? (
<Link
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
className='inline-flex h-7 w-7 items-center justify-center border border-primary/60 border-dashed text-primary hover:bg-primary/10'
aria-label='Open resume console'
>
<ArrowUpRight className='h-4 w-4' />
</Link>
) : (
<span className='h-7 w-7' />
)}
</div>
)}
</div>
{isExpanded && (
<div className='px-2 pt-0 pb-4'>
<div className='rounded-md border bg-muted/30 p-2'>
<div className='border bg-muted/30 p-2'>
<pre className='max-h-60 overflow-auto whitespace-pre-wrap break-words text-xs'>
{log.level === 'error' && errorStr ? errorStr : outputsStr}
</pre>

View File

@@ -59,7 +59,7 @@ export function WorkflowsList({
}
return (
<div
className='overflow-hidden rounded-lg border bg-card shadow-sm'
className='overflow-hidden border bg-card shadow-sm'
style={{ height: '380px', display: 'flex', flexDirection: 'column' }}
>
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'>
@@ -89,7 +89,7 @@ export function WorkflowsList({
return (
<div
key={workflow.workflowId}
className={`flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1.5 transition-colors ${
className={`flex cursor-pointer items-center gap-4 px-2 py-1.5 transition-colors ${
isSelected ? 'bg-accent/40' : 'hover:bg-accent/20'
}`}
onClick={() => onToggleWorkflow(workflow.workflowId)}
@@ -97,7 +97,7 @@ export function WorkflowsList({
<div className='w-52 min-w-0 flex-shrink-0'>
<div className='flex items-center gap-2'>
<div
className='h-[14px] w-[14px] flex-shrink-0 rounded'
className='h-[14px] w-[14px] flex-shrink-0'
style={{
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
}}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
@@ -35,9 +35,8 @@ interface FolderOption {
}
export default function FolderFilter() {
const triggerRef = useRef<HTMLButtonElement | null>(null)
const { folderIds, toggleFolderId, setFolderIds } = useFilterStore()
const { getFolderTree, getFolderPath, fetchFolders } = useFolderStore()
const { getFolderTree, fetchFolders } = useFolderStore()
const params = useParams()
const workspaceId = params.workspaceId as string
const [folders, setFolders] = useState<FolderOption[]>([])
@@ -111,7 +110,7 @@ export default function FolderFilter() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{loading ? 'Loading folders...' : getSelectedFoldersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>

View File

@@ -1,5 +1,5 @@
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -28,8 +28,7 @@ export default function Level() {
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
className='h-8 w-full justify-between border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
{getDisplayLabel()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
@@ -37,7 +36,7 @@ export default function Level() {
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
className='w-[180px] rounded-lg border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
className='w-[180px] border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
>
<DropdownMenuItem
key='all'
@@ -45,7 +44,7 @@ export default function Level() {
e.preventDefault()
setLevel('all')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>Any status</span>
{level === 'all' && <Check className='h-4 w-4 text-muted-foreground' />}
@@ -60,7 +59,7 @@ export default function Level() {
e.preventDefault()
setLevel(levelItem.value)
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex items-center'>
<div className={`mr-2 h-2 w-2 rounded-full ${levelItem.color}`} />

View File

@@ -1,5 +1,5 @@
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
DropdownMenu,
DropdownMenuContent,
@@ -37,7 +37,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{timeRange}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
@@ -58,7 +58,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
onSelect={() => {
setTimeRange('All time')
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>All time</span>
{timeRange === 'All time' && <Check className='h-4 w-4 text-muted-foreground' />}
@@ -72,7 +72,7 @@ export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
onSelect={() => {
setTimeRange(range)
}}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>{range}</span>
{timeRange === range && <Check className='h-4 w-4 text-muted-foreground' />}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import { useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
@@ -26,7 +26,6 @@ import type { TriggerType } from '@/stores/logs/filters/types'
export default function Trigger() {
const { triggers, toggleTrigger, setTriggers } = useFilterStore()
const [search, setSearch] = useState('')
const triggerRef = useRef<HTMLButtonElement | null>(null)
const triggerOptions: { value: TriggerType; label: string; color?: string }[] = [
{ value: 'manual', label: 'Manual', color: 'bg-gray-500' },
{ value: 'api', label: 'API', color: 'bg-blue-500' },
@@ -58,7 +57,7 @@ export default function Trigger() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{getSelectedTriggersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import {
Command,
CommandEmpty,
@@ -33,7 +33,6 @@ interface WorkflowOption {
}
export default function Workflow() {
const triggerRef = useRef<HTMLButtonElement | null>(null)
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
@@ -91,7 +90,7 @@ export default function Workflow() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ref={triggerRef} variant='outline' size='sm' className={filterButtonClass}>
<Button variant='outline' className={filterButtonClass}>
{loading ? 'Loading workflows...' : getSelectedWorkflowsText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>

View File

@@ -1,7 +1,7 @@
'use client'
import { TimerOff } from 'lucide-react'
import { Button } from '@/components/ui'
import { Button } from '@/components/emcn'
import { isProd } from '@/lib/environment'
import {
FilterSection,
@@ -33,7 +33,7 @@ export function Filters() {
<div className='h-full w-60 overflow-auto border-r p-4'>
{/* Show retention policy for free users in production only */}
{!isLoading && !isPaid && isProd && (
<div className='mb-4 overflow-hidden rounded-md border border-border'>
<div className='mb-4 overflow-hidden border border-border'>
<div className='flex items-center gap-2 border-b bg-background p-3'>
<TimerOff className='h-4 w-4 text-muted-foreground' />
<span className='font-medium text-sm'>Log Retention Policy</span>
@@ -44,9 +44,8 @@ export function Filters() {
</p>
<div className='mt-2.5'>
<Button
size='sm'
variant='secondary'
className='h-8 w-full px-3 py-1.5 text-xs'
variant='default'
className='h-8 w-full px-3 text-xs'
onClick={handleUpgradeClick}
>
Upgrade Plan

View File

@@ -2,8 +2,8 @@
import { useState } from 'react'
import { Maximize2, Minimize2, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { cn } from '@/lib/utils'
import { FrozenCanvas } from '@/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas'
@@ -37,7 +37,7 @@ export function FrozenCanvasModal({
className={cn(
'flex flex-col gap-0 p-0',
isFullscreen
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw] rounded-none'
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw]'
: 'h-[90vh] max-h-[90vh] overflow-hidden sm:max-w-[1100px]'
)}
hideCloseButton={true}
@@ -68,19 +68,14 @@ export function FrozenCanvasModal({
</div>
<div className='flex items-center gap-[8px]'>
<Button
variant='ghost'
size='sm'
onClick={toggleFullscreen}
className='h-[32px] w-[32px] p-0'
>
<Button variant='ghost' onClick={toggleFullscreen} className='h-[32px] w-[32px] p-0'>
{isFullscreen ? (
<Minimize2 className='h-[14px] w-[14px]' />
) : (
<Maximize2 className='h-[14px] w-[14px]' />
)}
</Button>
<Button variant='ghost' size='sm' onClick={onClose} className='h-[32px] w-[32px] p-0'>
<Button variant='ghost' onClick={onClose} className='h-[32px] w-[32px] p-0'>
<X className='h-[14px] w-[14px]' />
</Button>
</div>

View File

@@ -42,7 +42,7 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
{isLargeData && (
<button
onClick={() => setIsModalOpen(true)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
title='Expand in modal'
>
<Maximize2 className='h-[12px] w-[12px]' />
@@ -62,7 +62,7 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
</div>
<div
className={cn(
'overflow-y-auto rounded-[8px] bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
'overflow-y-auto bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
isExpanded ? 'max-h-96' : 'max-h-32'
)}
>
@@ -75,14 +75,14 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
{/* Modal for large data */}
{isModalOpen && (
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
<div className='mx-[16px] h-[80vh] w-full max-w-4xl rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='mx-[16px] h-[80vh] w-full max-w-4xl border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<div className='flex items-center justify-between border-b p-[16px] dark:border-[var(--border)]'>
<h3 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{title}
</h3>
<button
onClick={() => setIsModalOpen(false)}
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
>
<X className='h-[14px] w-[14px]' />
</button>
@@ -194,7 +194,7 @@ function PinnedLogs({
}
return (
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
<CardHeader className='pb-[12px]'>
<div className='flex items-center justify-between'>
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
@@ -217,7 +217,7 @@ function PinnedLogs({
</CardHeader>
<CardContent className='space-y-[16px]'>
<div className='rounded-[8px] bg-[var(--surface-5)] p-[16px] text-center'>
<div className='bg-[var(--surface-5)] p-[16px] text-center'>
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
This block was not executed because the workflow failed before reaching it.
</div>

View File

@@ -2,8 +2,8 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2, Search, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { parseQuery } from '@/lib/logs/query-parser'
import { SearchSuggestions } from '@/lib/logs/search-suggestions'
@@ -131,7 +131,7 @@ export function AutocompleteSearch({
{/* Search Input */}
<div
className={cn(
'relative flex items-center gap-2 rounded-lg border bg-background pr-2 pl-3 transition-all duration-200',
'relative flex items-center gap-2 border bg-background pr-2 pl-3 transition-all duration-200',
'h-9 w-full min-w-[600px] max-w-[800px]',
state.isOpen && 'ring-1 ring-ring'
)}
@@ -190,7 +190,6 @@ export function AutocompleteSearch({
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 w-6 p-0 hover:bg-muted/50'
onMouseDown={(e) => {
e.preventDefault()
@@ -206,7 +205,7 @@ export function AutocompleteSearch({
{state.isOpen && state.suggestions.length > 0 && (
<div
ref={dropdownRef}
className='min-w[500px] absolute z-[9999] mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-md'
className='min-w[500px] absolute z-[9999] mt-1 w-full overflow-hidden border bg-popover shadow-md'
id={listboxId}
role='listbox'
aria-labelledby={inputId}
@@ -284,7 +283,6 @@ export function AutocompleteSearch({
<Button
type='button'
variant='ghost'
size='sm'
className='ml-1 h-3 w-3 p-0 text-muted-foreground hover:bg-muted/50 hover:text-foreground'
onClick={() => removeFilter(filter)}
>
@@ -296,7 +294,6 @@ export function AutocompleteSearch({
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 text-muted-foreground text-xs hover:text-foreground'
onMouseDown={(e) => {
e.preventDefault()

View File

@@ -1,9 +1,9 @@
'use client'
import { useState } from 'react'
import { Download, Loader2 } from 'lucide-react'
import { ArrowDown, Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/emcn'
import { createLogger } from '@/lib/logs/console/logger'
import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils'
@@ -96,7 +96,6 @@ export function FileDownload({
return (
<Button
variant='ghost'
size='sm'
className={`h-7 px-2 text-xs ${className}`}
onClick={handleDownload}
disabled={isDownloading}
@@ -104,7 +103,7 @@ export function FileDownload({
{isDownloading ? (
<Loader2 className='h-3 w-3 animate-spin' />
) : (
<Download className='h-3 w-3' />
<ArrowDown className='h-[14px] w-[14px]' />
)}
{isDownloading ? 'Downloading...' : 'Download'}
</Button>

View File

@@ -2,8 +2,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, Eye, Loader2, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Button } from '@/components/ui/button'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
import { Button, Tooltip } from '@/components/emcn'
import { CopyButton } from '@/components/ui/copy-button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
@@ -15,6 +18,7 @@ import { TraceSpans } from '@/app/workspace/[workspaceId]/logs/components/trace-
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { formatCost } from '@/providers/utils'
import type { WorkflowLog } from '@/stores/logs/filters/types'
import '@/components/emcn/components/code/code.css'
interface LogSidebarProps {
log: WorkflowLog | null
@@ -72,12 +76,17 @@ const formatJsonContent = (content: string, blockInput?: Record<string, any>): R
const { isJson, formatted } = tryPrettifyJson(content)
return (
<div className='group relative w-full rounded-md bg-secondary/30 p-3'>
<div className='group relative w-full rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
<CopyButton text={formatted} className='z-10 h-7 w-7' />
{isJson ? (
<pre className='max-h-[500px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
{formatted}
</pre>
<div className='code-editor-theme'>
<pre
className='max-h-[500px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(formatted, languages.json, 'json'),
}}
/>
</div>
) : (
<LogMarkdownRenderer content={formatted} />
)}
@@ -123,7 +132,7 @@ const BlockContentDisplay = ({
<div className='mb-2 flex space-x-1'>
<button
onClick={() => setActiveTab('output')}
className={`rounded-md px-3 py-1 text-xs transition-colors ${
className={`px-3 py-1 text-xs transition-colors ${
activeTab === 'output'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/50'
@@ -133,7 +142,7 @@ const BlockContentDisplay = ({
</button>
<button
onClick={() => setActiveTab('input')}
className={`rounded-md px-3 py-1 text-xs transition-colors ${
className={`px-3 py-1 text-xs transition-colors ${
activeTab === 'input'
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/50'
@@ -145,14 +154,19 @@ const BlockContentDisplay = ({
)}
{/* Content based on active tab */}
<div className='group relative rounded-md bg-secondary/30 p-3'>
<div className='group relative rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
{activeTab === 'output' ? (
<>
<CopyButton text={outputString} className='z-10 h-7 w-7' />
{isJson ? (
<pre className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
{outputString}
</pre>
<div className='code-editor-theme'>
<pre
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(outputString, languages.json, 'json'),
}}
/>
</div>
) : (
<LogMarkdownRenderer content={outputString} />
)}
@@ -160,9 +174,14 @@ const BlockContentDisplay = ({
) : blockInputString ? (
<>
<CopyButton text={blockInputString} className='z-10 h-7 w-7' />
<pre className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all text-sm'>
{blockInputString}
</pre>
<div className='code-editor-theme'>
<pre
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(blockInputString, languages.json, 'json'),
}}
/>
</div>
</>
) : null}
</div>
@@ -323,8 +342,8 @@ export function Sidebar({
return (
<div
className={`fixed top-[96px] right-[16px] bottom-[16px] z-50 flex transform flex-col rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)] ${
isOpen ? 'translate-x-0' : 'translate-x-[calc(100%+1rem)]'
className={`fixed top-[94px] right-0 bottom-0 z-50 flex transform flex-col overflow-hidden border-l bg-[var(--surface-1)] dark:border-[var(--border)] dark:bg-[var(--surface-1)] ${
isOpen ? 'translate-x-0' : 'translate-x-full'
} ${isDragging ? '' : 'transition-all duration-300 ease-in-out'}`}
style={{ width: `${width}px`, minWidth: `${MIN_WIDTH}px` }}
aria-label='Log details sidebar'
@@ -340,16 +359,15 @@ export function Sidebar({
{log && (
<>
{/* Header */}
<div className='flex items-center justify-between px-[12px] pt-[12px] pb-[4px]'>
<div className='flex items-center justify-between px-[8px] pt-[14px] pb-[14px]'>
<h2 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Log Details
</h2>
<div className='flex items-center gap-[8px]'>
<div className='flex items-center gap-[4px]'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
className='h-[32px] w-[32px] p-0'
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
disabled={!hasPrev}
@@ -364,7 +382,6 @@ export function Sidebar({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
className='h-[32px] w-[32px] p-0'
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
disabled={!hasNext}
@@ -378,7 +395,6 @@ export function Sidebar({
<Button
variant='ghost'
size='icon'
className='h-[32px] w-[32px] p-0'
onClick={onClose}
aria-label='Close'
@@ -389,7 +405,7 @@ export function Sidebar({
</div>
{/* Content */}
<div className='flex-1 overflow-hidden px-[12px]'>
<div className='flex-1 overflow-hidden px-[8px]'>
<ScrollArea className='h-full w-full overflow-y-auto' ref={scrollAreaRef}>
<div className='w-full space-y-[16px] pr-[12px] pb-[16px]'>
{/* Timestamp */}
@@ -409,22 +425,15 @@ export function Sidebar({
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Workflow
</h3>
<div
className='group relative text-[13px]'
style={{
color: log.workflow.color,
}}
>
<div className='group relative text-[13px]'>
<CopyButton text={log.workflow.name} />
<div
className='inline-flex items-center rounded-[8px] px-[8px] py-[4px] text-[12px]'
<span
style={{
backgroundColor: `${log.workflow.color}20`,
color: log.workflow.color,
}}
>
{log.workflow.name}
</div>
</span>
</div>
</div>
)}
@@ -506,7 +515,7 @@ export function Sidebar({
{log.files.map((file, index) => (
<div
key={file.id || index}
className='flex items-center justify-between rounded-[8px] border bg-muted/30 p-[8px] dark:border-[var(--border)]'
className='flex items-center justify-between border bg-muted/30 p-[8px] dark:border-[var(--border)]'
>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-[13px]' title={file.name}>
@@ -534,9 +543,8 @@ export function Sidebar({
</h3>
<Button
variant='ghost'
size='sm'
onClick={() => setIsFrozenCanvasOpen(true)}
className='w-full justify-start gap-[8px] rounded-[8px] border bg-muted/30 hover:bg-muted/50 dark:border-[var(--border)]'
className='h-8 w-full justify-start gap-[8px] border bg-muted/30 hover:bg-muted/50 dark:border-[var(--border)]'
>
<Eye className='h-[14px] w-[14px]' />
View Snapshot
@@ -568,7 +576,7 @@ export function Sidebar({
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Tool Calls
</h3>
<div className='w-full overflow-x-hidden rounded-[8px] bg-secondary/30 p-[12px]'>
<div className='w-full overflow-x-hidden bg-secondary/30 p-[12px]'>
<ToolCallsDisplay metadata={log.executionData} />
</div>
</div>
@@ -580,7 +588,7 @@ export function Sidebar({
<h3 className='mb-[4px] font-medium text-[12px] text-[var(--text-tertiary)] dark:text-[var(--text-tertiary)]'>
Cost Breakdown
</h3>
<div className='overflow-hidden rounded-[8px] border dark:border-[var(--border)]'>
<div className='overflow-hidden border dark:border-[var(--border)]'>
<div className='space-y-[8px] p-[12px]'>
<div className='flex items-center justify-between'>
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>

View File

@@ -1,5 +1,7 @@
import type React from 'react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-json'
import { transformBlockData } from '@/app/workspace/[workspaceId]/logs/components/trace-spans/utils'
import '@/components/emcn/components/code/code.css'
export function BlockDataDisplay({
data,
@@ -14,66 +16,11 @@ export function BlockDataDisplay({
}) {
if (!data) return null
const renderValue = (value: unknown, key?: string): React.ReactNode => {
if (value === null) return <span className='text-muted-foreground italic'>null</span>
if (value === undefined) return <span className='text-muted-foreground italic'>undefined</span>
if (typeof value === 'string') {
return <span className='break-all text-emerald-700 dark:text-emerald-400'>"{value}"</span>
}
if (typeof value === 'number') {
return <span className='font-mono text-blue-700 dark:text-blue-400'>{value}</span>
}
if (typeof value === 'boolean') {
return (
<span className='font-mono text-amber-700 dark:text-amber-400'>{value.toString()}</span>
)
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className='text-muted-foreground'>[]</span>
return (
<div className='space-y-0.5'>
<span className='text-muted-foreground'>[</span>
<div className='ml-2 space-y-0.5'>
{value.map((item, index) => (
<div key={index} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-mono text-slate-600 text-xs dark:text-slate-400'>
{index}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(item)}</div>
</div>
))}
</div>
<span className='text-muted-foreground'>]</span>
</div>
)
}
if (typeof value === 'object') {
const entries = Object.entries(value)
if (entries.length === 0) return <span className='text-muted-foreground'>{'{}'}</span>
return (
<div className='space-y-0.5'>
{entries.map(([objKey, objValue]) => (
<div key={objKey} className='flex min-w-0 gap-1.5'>
<span className='flex-shrink-0 font-medium text-indigo-700 dark:text-indigo-400'>
{objKey}:
</span>
<div className='min-w-0 flex-1 overflow-hidden'>{renderValue(objValue, objKey)}</div>
</div>
))}
</div>
)
}
return <span>{String(value)}</span>
}
const transformedData = transformBlockData(data, blockType || 'unknown', isInput)
const dataToDisplay = transformedData || data
// Format the data as JSON string
const jsonString = JSON.stringify(dataToDisplay, null, 2)
if (isError && typeof data === 'object' && data !== null && 'error' in data) {
const errorData = data as { error: string; [key: string]: unknown }
@@ -86,15 +33,25 @@ export function BlockDataDisplay({
{transformedData &&
Object.keys(transformedData).filter((key) => key !== 'error' && key !== 'success')
.length > 0 && (
<div className='space-y-0.5'>
{Object.entries(transformedData)
.filter(([key]) => key !== 'error' && key !== 'success')
.map(([key, value]) => (
<div key={key} className='flex gap-1.5'>
<span className='font-medium text-indigo-700 dark:text-indigo-400'>{key}:</span>
{renderValue(value, key)}
</div>
))}
<div className='code-editor-theme'>
<pre
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(
JSON.stringify(
Object.fromEntries(
Object.entries(transformedData).filter(
([key]) => key !== 'error' && key !== 'success'
)
),
null,
2
),
languages.json,
'json'
),
}}
/>
</div>
)}
</div>
@@ -102,6 +59,13 @@ export function BlockDataDisplay({
}
return (
<div className='space-y-1 overflow-hidden text-xs'>{renderValue(transformedData || data)}</div>
<div className='code-editor-theme overflow-hidden'>
<pre
className='w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(jsonString, languages.json, 'json'),
}}
/>
</div>
)
}

View File

@@ -34,7 +34,7 @@ export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInput
Input
</button>
{inputExpanded && (
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
<div className='mb-2 overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
<BlockDataDisplay data={span.input} blockType={span.type} isInput={true} />
</div>
)}
@@ -55,7 +55,7 @@ export function CollapsibleInputOutput({ span, spanId, depth }: CollapsibleInput
{span.status === 'error' ? 'Error Details' : 'Output'}
</button>
{outputExpanded && (
<div className='mb-2 overflow-hidden rounded-md bg-secondary/30 p-3'>
<div className='mb-2 overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
<BlockDataDisplay
data={span.output}
blockType={span.type}

View File

@@ -610,7 +610,7 @@ export function TraceSpanItem({
})()}
{localHoveredPercent != null && (
<div
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-white/45'
className='pointer-events-none absolute inset-y-0 w-px bg-black/30 dark:bg-gray-600'
style={{
left: `${Math.max(0, Math.min(100, localHoveredPercent))}%`,
zIndex: 12,

View File

@@ -215,10 +215,7 @@ export function TraceSpans({ traceSpans, totalDuration = 0, onExpansionChange }:
})()}
</div>
</div>
<div
ref={containerRef}
className='relative w-full overflow-hidden rounded-md border shadow-sm'
>
<div ref={containerRef} className='relative w-full overflow-hidden border shadow-sm'>
{filtered.map((span, index) => {
const normalizedSpan = normalizeChildWorkflowSpan(span)
const hasSubItems = Boolean(

View File

@@ -810,17 +810,17 @@ export default function Dashboard() {
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
<span>Filters:</span>
{workflowIds.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{workflowIds.length} workflow{workflowIds.length !== 1 ? 's' : ''}
</span>
)}
{folderIds.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{folderIds.length} folder{folderIds.length !== 1 ? 's' : ''}
</span>
)}
{triggers.length > 0 && (
<span className='inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-primary text-xs'>
<span className='inline-flex items-center rounded-[6px] bg-primary/10 px-2 py-0.5 text-primary text-xs'>
{triggers.length} trigger{triggers.length !== 1 ? 's' : ''}
</span>
)}

View File

@@ -809,18 +809,33 @@ export default function Logs() {
{/* Status */}
<div>
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[8px] py-[2px] font-medium text-[12px] transition-all duration-200',
isError
? 'bg-red-500 text-white'
: isPending
? 'bg-amber-300 text-amber-900 dark:bg-amber-500/90 dark:text-black'
: 'bg-secondary text-card-foreground'
)}
>
{statusLabel}
</div>
{isError || !isPending ? (
<div
className={cn(
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
isError
? 'gap-[5px] border-[#883827] bg-[#491515]'
: 'gap-[8px] border-[#686868] bg-[#383838]'
)}
>
<div
className='h-[6px] w-[6px] rounded-[2px]'
style={{
backgroundColor: isError ? '#EF4444' : '#B7B7B7',
}}
/>
<span
className='font-medium text-[11.5px]'
style={{ color: isError ? '#EF4444' : '#B7B7B7' }}
>
{statusLabel}
</span>
</div>
) : (
<div className='inline-flex items-center bg-amber-300 px-[8px] py-[2px] font-medium text-[12px] text-amber-900 dark:bg-amber-500/90 dark:text-black'>
{statusLabel}
</div>
)}
</div>
{/* Workflow */}
@@ -843,17 +858,8 @@ export default function Logs() {
<div className='hidden xl:block'>
{log.trigger ? (
<div
className={cn(
'inline-flex items-center rounded-[8px] px-[8px] py-[2px] font-medium text-[12px] transition-all duration-200',
log.trigger.toLowerCase() === 'manual'
? 'bg-secondary text-card-foreground'
: 'text-white'
)}
style={
log.trigger.toLowerCase() === 'manual'
? undefined
: { backgroundColor: getTriggerColor(log.trigger) }
}
className='inline-flex items-center rounded-[6px] px-[8px] py-[2px] font-medium text-[12px] text-white'
style={{ backgroundColor: getTriggerColor(log.trigger) }}
>
{log.trigger}
</div>

View File

@@ -2,8 +2,10 @@
import { useCallback, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { Button, Rocket } from '@/components/emcn'
import { Button, Rocket, Tooltip } from '@/components/emcn'
import { cn } from '@/lib/utils'
import { DeployModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
import type { WorkspaceUserPermissions } from '@/hooks/use-user-permissions'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useChangeDetection, useDeployedState, useDeployment } from './hooks'
@@ -21,6 +23,7 @@ interface DeployProps {
export function Deploy({ activeWorkflowId, userPermissions, className }: DeployProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const { isLoading: isRegistryLoading } = useWorkflowRegistry()
const { hasBlocks } = useCurrentWorkflow()
// Get deployment status from registry
const deploymentStatus = useWorkflowRegistry((state) =>
@@ -49,8 +52,9 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
refetchDeployedState,
})
const isEmpty = !hasBlocks()
const canDeploy = userPermissions.canAdmin
const isDisabled = isDeploying || !canDeploy
const isDisabled = isDeploying || !canDeploy || isEmpty
const isPreviousVersionActive = isDeployed && changeDetected
/**
@@ -75,21 +79,65 @@ export function Deploy({ activeWorkflowId, userPermissions, className }: DeployP
}
}
/**
* Get tooltip text based on current state
*/
const getTooltipText = () => {
if (isEmpty) {
return 'Cannot deploy an empty workflow'
}
if (!canDeploy) {
return 'Admin permissions required'
}
if (isDeploying) {
return 'Deploying...'
}
if (changeDetected) {
return 'Update deployment'
}
if (isDeployed) {
return 'Active deployment'
}
return 'Deploy workflow'
}
const buttonContent = (
<>
{isDeploying ? (
<Loader2 className='h-[13px] w-[13px] animate-spin' />
) : (
<Rocket className='h-[13px] w-[13px]' />
)}
{changeDetected ? 'Update' : isDeployed ? 'Active' : 'Deploy'}
</>
)
return (
<>
<Button
className='h-[32px] gap-[8px] px-[10px]'
variant='active'
onClick={onDeployClick}
disabled={isDisabled}
>
{isDeploying ? (
<Loader2 className='h-[13px] w-[13px] animate-spin' />
) : (
<Rocket className='h-[13px] w-[13px]' />
)}
{changeDetected ? 'Update' : isDeployed ? 'Active' : 'Deploy'}
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
{isDisabled ? (
<div
className={cn(
'inline-flex h-[32px] items-center justify-center gap-[8px] px-[10px]',
'rounded-md border border-input bg-background font-medium text-sm opacity-50',
'shadow-sm transition-colors'
)}
>
{buttonContent}
</div>
) : (
<Button
className='h-[32px] gap-[8px] px-[10px]'
variant='active'
onClick={onDeployClick}
>
{buttonContent}
</Button>
)}
</Tooltip.Trigger>
<Tooltip.Content>{getTooltipText()}</Tooltip.Content>
</Tooltip.Root>
<DeployModal
open={isModalOpen}

View File

@@ -817,11 +817,38 @@ try {
},
]
// Ensure modal overlay appears above Settings modal (z-index: 9999999)
useEffect(() => {
if (!open) return
const styleId = 'custom-tool-modal-z-index'
let styleEl = document.getElementById(styleId) as HTMLStyleElement
if (!styleEl) {
styleEl = document.createElement('style')
styleEl.id = styleId
styleEl.textContent = `
[data-radix-portal] [data-radix-dialog-overlay] {
z-index: 99999998 !important;
}
`
document.head.appendChild(styleEl)
}
return () => {
const el = document.getElementById(styleId)
if (el) {
el.remove()
}
}
}, [open])
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent
className='flex h-[80vh] flex-col gap-0 p-0 sm:max-w-[700px]'
style={{ zIndex: 99999999 }}
hideCloseButton
onKeyDown={(e) => {
// Intercept Escape key when dropdowns are open

View File

@@ -210,8 +210,9 @@ export function Editor() {
/>
) : (
<h2
className='min-w-0 flex-1 truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
className='min-w-0 flex-1 cursor-pointer truncate pr-[8px] font-medium text-[14px] text-[var(--white)] dark:text-[var(--white)]'
title={title}
onDoubleClick={handleStartRename}
>
{title}
</h2>

View File

@@ -7,7 +7,7 @@ import { Loader2, X } from 'lucide-react'
import Image from 'next/image'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { Button, Input, Modal, ModalContent } from '@/components/emcn'
import { Button, Input, Modal, ModalContent, ModalTitle } from '@/components/emcn'
import { Label } from '@/components/ui/label'
import {
Select,
@@ -355,9 +355,9 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
<ModalContent className='flex h-[75vh] max-h-[75vh] w-full max-w-[700px] flex-col gap-0 p-0'>
{/* Modal Header */}
<div className='flex-shrink-0 px-6 py-5'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
<ModalTitle className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Help & Support
</h2>
</ModalTitle>
</div>
{/* Modal Body */}

View File

@@ -3,22 +3,17 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Check, Copy, Info, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
Button,
Modal,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
} from '@/components/emcn'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { Input, Label, Skeleton, Switch } from '@/components/ui'
import { useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -374,7 +369,8 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
if (!allowPersonalApiKeys && keyType === 'personal') {
setKeyType('workspace')
}
}, [allowPersonalApiKeys, keyType])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowPersonalApiKeys])
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
@@ -398,7 +394,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header */}
<div className='px-6 pt-2 pb-2'>
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-lg' />
@@ -417,7 +413,7 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
{/* Scrollable Content */}
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
<div className='space-y-2 pt-2 pb-6'>
{isLoading ? (
<div className='space-y-2'>
<ApiKeySkeleton />
@@ -432,44 +428,46 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<>
{/* Allow Personal API Keys Toggle */}
{!searchTerm.trim() && (
<div className='mb-6 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[12px] text-foreground'>
Allow personal API keys
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='rounded-full p-1 text-muted-foreground transition hover:text-foreground'
>
<Info className='h-3 w-3' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs text-xs'>
Allow collaborators to create and use their own keys with billing charged to
them.
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Provider delayDuration={150}>
<div className='mb-6 flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='font-medium text-[12px] text-foreground'>
Allow personal API keys
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='rounded-full p-1 text-muted-foreground transition hover:text-foreground'
>
<Info className='h-3 w-3' strokeWidth={2} />
</button>
</Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs text-xs'>
Allow collaborators to create and use their own keys with billing charged
to them.
</Tooltip.Content>
</Tooltip.Root>
</div>
{workspaceSettingsLoading ? (
<Skeleton className='h-5 w-16 rounded-full' />
) : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
onCheckedChange={async (checked) => {
const previous = allowPersonalApiKeys
setAllowPersonalApiKeys(checked)
try {
await updateWorkspaceSettings({ allowPersonalApiKeys: checked })
} catch (error) {
setAllowPersonalApiKeys(previous)
}
}}
/>
)}
</div>
{workspaceSettingsLoading ? (
<Skeleton className='h-5 w-16 rounded-full' />
) : (
<Switch
checked={allowPersonalApiKeys}
disabled={!canManageWorkspaceKeys || workspaceSettingsUpdating}
onCheckedChange={async (checked) => {
const previous = allowPersonalApiKeys
setAllowPersonalApiKeys(checked)
try {
await updateWorkspaceSettings({ allowPersonalApiKeys: checked })
} catch (error) {
setAllowPersonalApiKeys(previous)
}
}}
/>
)}
</div>
</Tooltip.Provider>
)}
{/* Workspace section */}
@@ -494,7 +492,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
@@ -527,7 +524,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
@@ -566,7 +562,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => {
setDeleteKey(key)
setShowDeleteDialog(true)
@@ -607,17 +602,19 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
) : (
<Button
onClick={() => {
onClick={(e) => {
if (createButtonDisabled) {
return
}
// Remove focus from button before opening dialog to prevent focus trap
e.currentTarget.blur()
setIsCreateDialogOpen(true)
setKeyType(defaultKeyType)
setCreateError(null)
}}
variant='ghost'
disabled={createButtonDisabled}
className='h-8 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-60'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-60'
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Key
@@ -627,16 +624,16 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
{/* Create API Key Dialog */}
<AlertDialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Create new API key</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalHeader>
<ModalTitle>Create new API key</ModalTitle>
<ModalDescription>
{keyType === 'workspace'
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
</AlertDialogDescription>
</AlertDialogHeader>
</ModalDescription>
</ModalHeader>
<div className='space-y-4 py-2'>
{canManageWorkspaceKeys && (
@@ -645,26 +642,24 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
<div className='flex gap-2'>
<Button
type='button'
variant={keyType === 'personal' ? 'default' : 'outline'}
size='sm'
variant={keyType === 'personal' ? 'outline' : 'default'}
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
disabled={!allowPersonalApiKeys}
className='h-8 disabled:cursor-not-allowed disabled:opacity-60 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
className='h-8 disabled:cursor-not-allowed disabled:opacity-60'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'default' : 'outline'}
size='sm'
variant={keyType === 'workspace' ? 'outline' : 'default'}
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
className='h-8 data-[variant=outline]:border-border data-[variant=outline]:bg-background data-[variant=outline]:text-foreground data-[variant=outline]:hover:bg-muted dark:data-[variant=outline]:border-border dark:data-[variant=outline]:bg-background dark:data-[variant=outline]:text-foreground dark:data-[variant=outline]:hover:bg-muted/80'
className='h-8'
>
Workspace
</Button>
@@ -685,24 +680,30 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
className='h-9 rounded-[8px]'
autoFocus
/>
{createError && <div className='text-red-600 text-sm'>{createError}</div>}
{createError && (
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{createError}
</div>
)}
</div>
</div>
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px] border-border bg-background text-foreground hover:bg-muted dark:border-border dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
<ModalFooter className='flex'>
<Button
className='h-9 w-full rounded-[8px] bg-background text-foreground hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
onClick={() => {
setIsCreateDialogOpen(false)
setNewKeyName('')
setKeyType(defaultKeyType)
}}
>
Cancel
</AlertDialogCancel>
</Button>
<Button
type='button'
variant='primary'
onClick={handleCreateKey}
className='h-9 w-full rounded-[8px] bg-primary text-white hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50'
className='h-9 w-full rounded-[8px] disabled:cursor-not-allowed disabled:opacity-50'
disabled={
!newKeyName.trim() ||
isSubmittingCreate ||
@@ -711,14 +712,14 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
>
Create {keyType === 'workspace' ? 'Workspace' : 'Personal'} Key
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ModalFooter>
</ModalContent>
</Modal>
{/* New API Key Dialog */}
<AlertDialog
<Modal
open={showNewKeyDialog}
onOpenChange={(open) => {
onOpenChange={(open: boolean) => {
setShowNewKeyDialog(open)
if (!open) {
setNewKey(null)
@@ -726,14 +727,14 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}}
>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Your API key has been created</AlertDialogTitle>
<AlertDialogDescription>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalHeader>
<ModalTitle>Your API key has been created</ModalTitle>
<ModalDescription>
This is the only time you will see your API key.{' '}
<span className='font-semibold'>Copy it now and store it securely.</span>
</AlertDialogDescription>
</AlertDialogHeader>
</ModalDescription>
</ModalHeader>
{newKey && (
<div className='relative'>
@@ -744,7 +745,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-1 h-7 w-7 rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground'
onClick={() => copyToClipboard(newKey.key)}
>
@@ -753,19 +753,19 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</Button>
</div>
)}
</AlertDialogContent>
</AlertDialog>
</ModalContent>
</Modal>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className='rounded-[10px] sm:max-w-md'>
<AlertDialogHeader>
<AlertDialogTitle>Delete API key?</AlertDialogTitle>
<AlertDialogDescription>
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ModalContent className='rounded-[10px] sm:max-w-md' showClose={false}>
<ModalHeader>
<ModalTitle>Delete API key?</ModalTitle>
<ModalDescription>
Deleting this API key will immediately revoke access for any integrations using it.{' '}
<span className='text-red-500 dark:text-red-500'>This action cannot be undone.</span>
</AlertDialogDescription>
</AlertDialogHeader>
</ModalDescription>
</ModalHeader>
{deleteKey && (
<div className='py-2'>
@@ -783,17 +783,18 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</div>
)}
<AlertDialogFooter className='flex'>
<AlertDialogCancel
className='h-9 w-full rounded-[8px]'
<ModalFooter className='flex'>
<Button
className='h-9 w-full rounded-[8px] bg-background text-foreground hover:bg-muted dark:bg-background dark:text-foreground dark:hover:bg-muted/80'
onClick={() => {
setShowDeleteDialog(false)
setDeleteKey(null)
setDeleteConfirmationName('')
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
</Button>
<Button
onClick={() => {
handleDeleteKey()
setDeleteConfirmationName('')
@@ -802,10 +803,10 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
disabled={!deleteKey || deleteConfirmationName !== deleteKey.name}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
)
}

View File

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

View File

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

View File

@@ -3,10 +3,8 @@
import { useEffect, useRef, useState } from 'react'
import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react'
import { useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/emcn'
import { Input, Label, Skeleton } from '@/components/ui'
import { client, useSession } from '@/lib/auth-client'
import { createLogger } from '@/lib/logs/console/logger'
import { OAUTH_PROVIDERS, type OAuthServiceConfig } from '@/lib/oauth/oauth'
@@ -386,7 +384,6 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
</p>
<Button
variant='outline'
size='sm'
onClick={scrollToHighlightedService}
className='mt-3 flex h-8 items-center gap-1.5 self-start border-primary/20 px-3 font-medium text-muted-foreground text-sm transition-colors hover:border-primary hover:bg-primary/10 hover:text-muted-foreground'
>
@@ -462,7 +459,6 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
{service.accounts && service.accounts.length > 0 ? (
<Button
variant='ghost'
size='sm'
onClick={() => handleDisconnect(service, service.accounts![0].id)}
disabled={isConnecting === `${service.id}-${service.accounts![0].id}`}
className={cn(
@@ -476,7 +472,6 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP
) : (
<Button
variant='outline'
size='sm'
onClick={() => handleConnect(service)}
disabled={isConnecting === service.id}
className={cn('h-8', isConnecting === service.id && 'cursor-not-allowed')}

View File

@@ -3,7 +3,8 @@
import { useEffect, useState } from 'react'
import { AlertCircle, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Alert, AlertDescription, Button, Input, Skeleton } from '@/components/ui'
import { Button, Label } from '@/components/emcn'
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
@@ -12,13 +13,17 @@ const logger = createLogger('CustomToolsSettings')
function CustomToolSkeleton() {
return (
<div className='rounded-[8px] border bg-background p-4'>
<div className='flex items-center justify-between'>
<div className='flex-1 space-y-2'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-3 w-48' />
<div className='flex flex-col gap-2'>
<Skeleton className='h-4 w-32' /> {/* Tool title */}
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<Skeleton className='h-8 w-24 rounded-[8px]' /> {/* Function name */}
<Skeleton className='h-4 w-48' /> {/* Description */}
</div>
<div className='flex items-center gap-2'>
<Skeleton className='h-8 w-12' /> {/* Edit button */}
<Skeleton className='h-8 w-16' /> {/* Delete button */}
</div>
<Skeleton className='h-8 w-20' />
</div>
</div>
)
@@ -91,27 +96,22 @@ export function CustomTools() {
}
return (
<div className='flex h-full flex-col'>
{/* Header */}
<div className='border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<div>
<h2 className='font-semibold text-foreground text-lg'>Custom Tools</h2>
<p className='mt-1 text-muted-foreground text-sm'>
Manage workspace-scoped custom tools for your agents
</p>
</div>
{!showAddForm && !editingTool && (
<Button size='sm' onClick={() => setShowAddForm(true)} className='h-9'>
<Plus className='mr-2 h-4 w-4' />
Add Tool
</Button>
)}
</div>
<div className='relative flex h-full flex-col'>
{/* Fixed Header with Search */}
<div className='px-6 pt-4 pb-2'>
{/* Error Alert - only show when modal is not open */}
{error && !showAddForm && !editingTool && (
<Alert variant='destructive' className='mb-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Search */}
{tools.length > 0 && !showAddForm && !editingTool && (
<div className='mt-4 flex h-9 w-56 items-center gap-2 rounded-lg border bg-transparent pr-2 pl-3'>
{/* Search Input */}
{isLoading ? (
<Skeleton className='h-9 w-56 rounded-[8px]' />
) : (
<div className='flex h-9 w-56 items-center gap-2 rounded-[8px] border bg-transparent pr-2 pl-3'>
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
<Input
placeholder='Search tools...'
@@ -121,79 +121,98 @@ export function CustomTools() {
/>
</div>
)}
{/* Error Alert - only show when modal is not open */}
{error && !showAddForm && !editingTool && (
<Alert variant='destructive' className='mt-4'>
<AlertCircle className='h-4 w-4' />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-4 py-2'>
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='space-y-2 pt-2 pb-6'>
{isLoading ? (
<div className='space-y-4'>
<div className='space-y-2'>
<CustomToolSkeleton />
<CustomToolSkeleton />
<CustomToolSkeleton />
</div>
) : filteredTools.length === 0 && !showAddForm && !editingTool ? (
) : tools.length === 0 && !showAddForm && !editingTool ? (
<div className='flex h-full items-center justify-center text-muted-foreground text-sm'>
{searchTerm.trim() ? (
<>No tools found matching "{searchTerm}"</>
) : (
<>Click "Add Tool" above to create your first custom tool</>
)}
Click "Create Tool" below to get started
</div>
) : (
<div className='space-y-4'>
{filteredTools.map((tool) => (
<div
key={tool.id}
className='flex items-center justify-between gap-4 rounded-[8px] border bg-background p-4'
>
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center gap-2'>
<code className='font-medium font-mono text-foreground text-sm'>
{tool.title}
</code>
<>
<div className='space-y-2'>
{filteredTools.map((tool) => (
<div key={tool.id} className='flex flex-col gap-2'>
<Label className='font-normal text-muted-foreground text-xs uppercase'>
{tool.title}
</Label>
<div className='flex items-center justify-between gap-4'>
<div className='flex items-center gap-3'>
<div className='flex h-8 items-center rounded-[8px] bg-muted px-3'>
<code className='font-mono text-foreground text-xs'>
{tool.schema?.function?.name || 'unnamed'}
</code>
</div>
{tool.schema?.function?.description && (
<p className='truncate text-muted-foreground text-xs'>
{tool.schema.function.description}
</p>
)}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
onClick={() => setEditingTool(tool.id)}
className='h-8'
>
Edit
</Button>
<Button
variant='ghost'
onClick={() => handleDeleteTool(tool.id)}
disabled={deletingTools.has(tool.id)}
className='h-8'
>
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
{tool.schema?.function?.description && (
<p className='truncate text-muted-foreground text-xs'>
{tool.schema.function.description}
</p>
)}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => setEditingTool(tool.id)}
className='h-8 text-muted-foreground hover:text-foreground'
>
Edit
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDeleteTool(tool.id)}
disabled={deletingTools.has(tool.id)}
className='h-8 text-muted-foreground hover:text-foreground'
>
{deletingTools.has(tool.id) ? 'Deleting...' : 'Delete'}
</Button>
</div>
</div>
))}
))}
</div>
{/* Show message when search has no results */}
{searchTerm.trim() && filteredTools.length === 0 && tools.length > 0 && (
<div className='py-8 text-center text-muted-foreground text-sm'>
No tools found matching "{searchTerm}"
</div>
)}
</div>
</>
)}
</div>
</div>
{/* Footer */}
<div className='bg-background'>
<div className='flex w-full items-center justify-between px-6 py-4'>
{isLoading ? (
<>
<Skeleton className='h-9 w-[117px] rounded-[8px]' />
<div className='w-[200px]' />
</>
) : (
<>
<Button
onClick={() => setShowAddForm(true)}
variant='ghost'
className='h-9 rounded-[8px] border bg-background px-3 shadow-xs hover:bg-muted focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0'
disabled={isLoading}
>
<Plus className='h-4 w-4 stroke-[2px]' />
Create Tool
</Button>
<div className='text-muted-foreground text-xs'>
Custom tools extend agent capabilities with workspace-specific functions
</div>
</>
)}
</div>
</div>

View File

@@ -3,7 +3,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Plus, Search, Share2 } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Tooltip } from '@/components/emcn'
import { Button, Tooltip } from '@/components/emcn'
import { Input, Skeleton } from '@/components/ui'
import {
AlertDialog,
AlertDialogAction,
@@ -14,9 +15,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { createLogger } from '@/lib/logs/console/logger'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import type { EnvironmentVariable as StoreEnvironmentVariable } from '@/stores/settings/environment/types'
@@ -437,7 +435,6 @@ export function EnvironmentVariables({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
disabled={!envVar.key || !envVar.value || isConflict || !workspaceId}
onClick={() => {
if (!envVar.key || !envVar.value || !workspaceId) return
@@ -458,7 +455,6 @@ export function EnvironmentVariables({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => removeEnvVar(originalIndex)}
className='h-9 w-9 rounded-[8px] bg-muted p-0 text-muted-foreground hover:bg-muted/70'
>
@@ -532,7 +528,7 @@ export function EnvironmentVariables({
{/* Scrollable Content */}
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
<div className='space-y-2 pt-2 pb-6'>
{isLoading || isWorkspaceLoading ? (
<>
{/* Show 3 skeleton rows */}
@@ -584,7 +580,6 @@ export function EnvironmentVariables({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setWorkspaceVars((prev) => {
const next = { ...prev }
@@ -638,7 +633,6 @@ export function EnvironmentVariables({
<Tooltip.Trigger asChild>
<Button
variant='ghost'
size='icon'
onClick={() => {
setWorkspaceVars((prev) => {
const next = { ...prev }

View File

@@ -1,10 +1,10 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Download, Search, Trash2 } from 'lucide-react'
import { ArrowDown, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button, Tooltip, Trash } from '@/components/emcn'
import { Input, Progress, Skeleton } from '@/components/ui'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
@@ -51,7 +51,7 @@ interface StorageInfo {
percentUsed: number
}
export function FileUploads() {
export function Files() {
const params = useParams()
const workspaceId = params?.workspaceId as string
const [files, setFiles] = useState<WorkspaceFileRecord[]>([])
@@ -351,7 +351,13 @@ export function FileUploads() {
</div>
{/* Error message */}
{uploadError && <div className='px-6 pb-2 text-red-600 text-sm'>{uploadError}</div>}
{uploadError && (
<div className='px-6 pb-2'>
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{uploadError}
</div>
</div>
)}
{/* Files Table */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
@@ -396,28 +402,34 @@ export function FileUploads() {
</TableCell>
<TableCell className='px-3'>
<div className='flex items-center gap-1'>
<Button
variant='ghost'
size='icon'
onClick={() => handleDownload(file)}
title='Download'
className='h-6 w-6'
aria-label={`Download ${file.name}`}
>
<Download className='h-3.5 w-3.5 text-muted-foreground' />
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleDownload(file)}
className='h-6 w-6 p-0'
aria-label={`Download ${file.name}`}
>
<ArrowDown className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Download file</Tooltip.Content>
</Tooltip.Root>
{userPermissions.canEdit && (
<Button
variant='ghost'
size='icon'
onClick={() => handleDelete(file)}
className='h-6 w-6 text-destructive hover:text-destructive'
disabled={deletingFileId === file.id}
title='Delete'
aria-label={`Delete ${file.name}`}
>
<Trash2 className='h-3.5 w-3.5' />
</Button>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => handleDelete(file)}
className='h-6 w-6 p-0'
disabled={deletingFileId === file.id}
aria-label={`Delete ${file.name}`}
>
<Trash className='h-[14px] w-[14px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Delete file</Tooltip.Content>
</Tooltip.Root>
)}
</div>
</TableCell>

View File

@@ -4,7 +4,7 @@ export { Copilot } from './copilot/copilot'
export { Credentials } from './credentials/credentials'
export { CustomTools } from './custom-tools/custom-tools'
export { EnvironmentVariables } from './environment/environment'
export { FileUploads } from './file-uploads/file-uploads'
export { Files as FileUploads } from './files/files'
export { General } from './general/general'
export { MCP } from './mcp/mcp'
export { Privacy } from './privacy/privacy'

View File

@@ -1,7 +1,7 @@
'use client'
import { Plus, X } from 'lucide-react'
import { Button, Input, Label } from '@/components/ui'
import { Button, Input, Label } from '@/components/emcn'
import { EnvVarDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/formatted-text'
import type { McpServerFormData, McpServerTestResult } from '../types'
@@ -181,7 +181,6 @@ export function AddServerForm({
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => onRemoveHeader(key)}
className='h-9 w-9 p-0 text-muted-foreground hover:text-foreground'
>
@@ -241,7 +240,6 @@ export function AddServerForm({
<Button
type='button'
variant='outline'
size='sm'
onClick={onAddHeader}
className='h-9 text-muted-foreground hover:text-foreground'
>
@@ -255,7 +253,9 @@ export function AddServerForm({
<div className='space-y-1.5'>
{/* Error message above buttons */}
{testResult && !testResult.success && (
<p className='text-red-600 text-sm'>{testResult.error || testResult.message}</p>
<div className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{testResult.error || testResult.message}
</div>
)}
{/* Buttons row */}
@@ -263,26 +263,25 @@ export function AddServerForm({
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={onTestConnection}
disabled={isTestingConnection || !formData.name.trim() || !formData.url?.trim()}
className='text-muted-foreground hover:text-foreground'
className='h-9 text-muted-foreground hover:text-foreground'
>
{isTestingConnection ? 'Testing...' : 'Test Connection'}
</Button>
{testResult?.success && <span className='text-green-600 text-xs'> Connected</span>}
{testResult?.success && (
<span className='text-muted-foreground text-xs'> Connected</span>
)}
</div>
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
onClick={onCancel}
className='text-muted-foreground hover:text-foreground'
className='h-9 text-muted-foreground hover:text-foreground'
>
Cancel
</Button>
<Button
size='sm'
onClick={onAddServer}
disabled={
serversLoading ||

View File

@@ -3,7 +3,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Alert, AlertDescription, Button, Input, Skeleton } from '@/components/ui'
import { Button } from '@/components/emcn'
import { Alert, AlertDescription, Input, Skeleton } from '@/components/ui'
import { createLogger } from '@/lib/logs/console/logger'
import { checkEnvVarTrigger } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/env-var-dropdown'
import { useMcpServerTest } from '@/hooks/use-mcp-server-test'
@@ -254,7 +255,7 @@ export function MCP() {
return (
<div className='relative flex h-full flex-col'>
{/* Fixed Header with Search */}
<div className='px-6 pt-2 pb-2'>
<div className='px-6 pt-4 pb-2'>
{/* Search Input */}
{serversLoading ? (
<Skeleton className='h-9 w-56 rounded-[8px]' />
@@ -281,7 +282,7 @@ export function MCP() {
{/* Scrollable Content */}
<div className='min-h-0 flex-1 overflow-y-auto px-6'>
<div className='h-full space-y-2 py-2'>
<div className='space-y-2 pt-2 pb-6'>
{/* Server List */}
{serversLoading ? (
<div className='space-y-2'>
@@ -367,7 +368,6 @@ export function MCP() {
</div>
<Button
variant='ghost'
size='sm'
onClick={() => handleRemoveServer(server.id)}
disabled={deletingServers.has(server.id)}
className='h-8 text-muted-foreground hover:text-foreground'

View File

@@ -114,7 +114,7 @@ const allNavigationItems: NavigationItem[] = [
},
{
id: 'files',
label: 'File Uploads',
label: 'Files',
icon: Files,
},
// {

View File

@@ -680,7 +680,7 @@ export function SSO() {
))}
</select>
{showErrors && errors.providerId.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.providerId.join(' ')}</p>
</div>
)}
@@ -711,7 +711,7 @@ export function SSO() {
)}
/>
{showErrors && errors.issuerUrl.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.issuerUrl.join(' ')}</p>
</div>
)}
@@ -740,7 +740,7 @@ export function SSO() {
)}
/>
{showErrors && errors.domain.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.domain.join(' ')}</p>
</div>
)}
@@ -771,7 +771,7 @@ export function SSO() {
)}
/>
{showErrors && errors.clientId.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.clientId.join(' ')}</p>
</div>
)}
@@ -820,7 +820,7 @@ export function SSO() {
</button>
</div>
{showErrors && errors.clientSecret.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.clientSecret.join(' ')}</p>
</div>
)}
@@ -845,7 +845,7 @@ export function SSO() {
)}
/>
{showErrors && errors.scopes.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.scopes.join(' ')}</p>
</div>
)}
@@ -875,7 +875,7 @@ export function SSO() {
)}
/>
{showErrors && errors.entryPoint.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.entryPoint.join(' ')}</p>
</div>
)}
@@ -901,7 +901,7 @@ export function SSO() {
rows={4}
/>
{showErrors && errors.cert.length > 0 && (
<div className='mt-1 text-red-400 text-xs'>
<div className='mt-1 text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
<p>{errors.cert.join(' ')}</p>
</div>
)}

View File

@@ -161,7 +161,11 @@ export function MemberInvitationCard({
className={cn('w-full', emailError && 'border-red-500 focus-visible:ring-red-500')}
/>
<div className='h-4 pt-1'>
{emailError && <p className='text-red-500 text-xs'>{emailError}</p>}
{emailError && (
<p className='text-[#DC2626] text-[12px] leading-tight dark:text-[#F87171]'>
{emailError}
</p>
)}
</div>
</div>
</div>

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Modal, ModalContent } from '@/components/emcn'
import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
import { Modal, ModalContent, ModalDescription, ModalTitle } from '@/components/emcn'
import { getEnv, isTruthy } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -20,7 +21,7 @@ import {
Subscription,
TeamManagement,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components'
import { CreatorProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/components/creator-profile/creator-profile'
import { CreatorProfile } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile'
import { useOrganizationStore } from '@/stores/organization'
import { useGeneralStore } from '@/stores/settings/general/store'
@@ -126,6 +127,14 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
return (
<Modal open={open} onOpenChange={handleDialogOpenChange}>
<ModalContent className='flex h-[70vh] w-full max-w-[840px] flex-col gap-0 p-0'>
<VisuallyHidden.Root>
<ModalTitle>Settings</ModalTitle>
</VisuallyHidden.Root>
<VisuallyHidden.Root>
<ModalDescription>
Configure your workspace settings, environment variables, credentials, and preferences
</ModalDescription>
</VisuallyHidden.Root>
<div className='flex flex-col border-[var(--surface-11)] border-b px-[16px] py-[12px]'>
<h2 className='font-medium text-[14px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
Settings

View File

@@ -5,7 +5,7 @@ import {
generateEncryptedApiKey,
isEncryptedApiKeyFormat,
isLegacyApiKeyFormat,
} from '@/lib/api-key/service'
} from '@/lib/api-key/crypto'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'

View File

@@ -0,0 +1,131 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('ApiKeyCrypto')
/**
* Get the API encryption key from the environment
* @returns The API encryption key
*/
function getApiEncryptionKey(): Buffer | null {
const key = env.API_ENCRYPTION_KEY
if (!key) {
logger.warn(
'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
)
return null
}
if (key.length !== 64) {
throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
}
return Buffer.from(key, 'hex')
}
/**
* Encrypts an API key using the dedicated API encryption key
* @param apiKey - The API key to encrypt
* @returns A promise that resolves to an object containing the encrypted API key and IV
*/
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
const key = getApiEncryptionKey()
// If no API encryption key is set, return the key as-is for backward compatibility
if (!key) {
return { encrypted: apiKey, iv: '' }
}
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(apiKey, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
// Format: iv:encrypted:authTag
return {
encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`,
iv: iv.toString('hex'),
}
}
/**
* Decrypts an API key using the dedicated API encryption key
* @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text
* @returns A promise that resolves to an object containing the decrypted API key
*/
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
// Check if this is actually encrypted (contains colons)
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
// This is a plain text key, return as-is
return { decrypted: encryptedValue }
}
const key = getApiEncryptionKey()
// If no API encryption key is set, assume it's plain text
if (!key) {
return { decrypted: encryptedValue }
}
const parts = encryptedValue.split(':')
const ivHex = parts[0]
const authTagHex = parts[parts.length - 1]
const encrypted = parts.slice(1, -1).join(':')
if (!ivHex || !encrypted || !authTagHex) {
throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"')
}
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
try {
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return { decrypted }
} catch (error: unknown) {
logger.error('API key decryption error:', {
error: error instanceof Error ? error.message : 'Unknown error',
})
throw error
}
}
/**
* Generates a standardized API key with the 'sim_' prefix (legacy format)
* @returns A new API key string
*/
export function generateApiKey(): string {
return `sim_${randomBytes(24).toString('base64url')}`
}
/**
* Generates a new encrypted API key with the 'sk-sim-' prefix
* @returns A new encrypted API key string
*/
export function generateEncryptedApiKey(): string {
return `sk-sim-${randomBytes(24).toString('base64url')}`
}
/**
* Determines if an API key uses the new encrypted format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the new encrypted format (sk-sim- prefix)
*/
export function isEncryptedApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sk-sim-')
}
/**
* Determines if an API key uses the legacy format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the legacy format (sim_ prefix)
*/
export function isLegacyApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
}

View File

@@ -1,9 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
import { db } from '@sim/db'
import { apiKey as apiKeyTable } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { authenticateApiKey } from '@/lib/api-key/auth'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { getUserEntityPermissions } from '@/lib/permissions/utils'
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
@@ -167,129 +165,3 @@ export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
logger.error('Error updating API key last used:', error)
}
}
/**
* Get the API encryption key from the environment
* @returns The API encryption key
*/
function getApiEncryptionKey(): Buffer | null {
const key = env.API_ENCRYPTION_KEY
if (!key) {
logger.warn(
'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
)
return null
}
if (key.length !== 64) {
throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
}
return Buffer.from(key, 'hex')
}
/**
* Encrypts an API key using the dedicated API encryption key
* @param apiKey - The API key to encrypt
* @returns A promise that resolves to an object containing the encrypted API key and IV
*/
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
const key = getApiEncryptionKey()
// If no API encryption key is set, return the key as-is for backward compatibility
if (!key) {
return { encrypted: apiKey, iv: '' }
}
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', key, iv)
let encrypted = cipher.update(apiKey, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
// Format: iv:encrypted:authTag
return {
encrypted: `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`,
iv: iv.toString('hex'),
}
}
/**
* Decrypts an API key using the dedicated API encryption key
* @param encryptedValue - The encrypted value in format "iv:encrypted:authTag" or plain text
* @returns A promise that resolves to an object containing the decrypted API key
*/
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
// Check if this is actually encrypted (contains colons)
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
// This is a plain text key, return as-is
return { decrypted: encryptedValue }
}
const key = getApiEncryptionKey()
// If no API encryption key is set, assume it's plain text
if (!key) {
return { decrypted: encryptedValue }
}
const parts = encryptedValue.split(':')
const ivHex = parts[0]
const authTagHex = parts[parts.length - 1]
const encrypted = parts.slice(1, -1).join(':')
if (!ivHex || !encrypted || !authTagHex) {
throw new Error('Invalid encrypted API key format. Expected "iv:encrypted:authTag"')
}
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
try {
const decipher = createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return { decrypted }
} catch (error: unknown) {
logger.error('API key decryption error:', {
error: error instanceof Error ? error.message : 'Unknown error',
})
throw error
}
}
/**
* Generates a standardized API key with the 'sim_' prefix (legacy format)
* @returns A new API key string
*/
export function generateApiKey(): string {
return `sim_${randomBytes(24).toString('base64url')}`
}
/**
* Generates a new encrypted API key with the 'sk-sim-' prefix
* @returns A new encrypted API key string
*/
export function generateEncryptedApiKey(): string {
return `sk-sim-${randomBytes(24).toString('base64url')}`
}
/**
* Determines if an API key uses the new encrypted format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the new encrypted format (sk-sim- prefix)
*/
export function isEncryptedApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sk-sim-')
}
/**
* Determines if an API key uses the legacy format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the legacy format (sim_ prefix)
*/
export function isLegacyApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
}