mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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'
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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' />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]'>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -114,7 +114,7 @@ const allNavigationItems: NavigationItem[] = [
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'File Uploads',
|
||||
label: 'Files',
|
||||
icon: Files,
|
||||
},
|
||||
// {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
131
apps/sim/lib/api-key/crypto.ts
Normal file
131
apps/sim/lib/api-key/crypto.ts
Normal 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-')
|
||||
}
|
||||
@@ -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-')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user