improvement(emcn): modal padding, api, chat, form

This commit is contained in:
Emir Karabeg
2026-01-08 21:58:04 -08:00
parent 51f9380cc7
commit bb4c4703f9
18 changed files with 380 additions and 263 deletions

View File

@@ -123,7 +123,7 @@ export function CreateChunkModal({
<ModalHeader>Create Chunk</ModalHeader>
<form>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='flex flex-col gap-[8px]'>
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}

View File

@@ -399,7 +399,7 @@ export function DocumentTagsModal({
</div>
</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[8px]'>
<Label>Tags</Label>

View File

@@ -260,7 +260,7 @@ export function EditChunkModal({
</ModalHeader>
<form>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='flex flex-col gap-[8px]'>
{error && <p className='text-[12px] text-[var(--text-error)]'>{error}</p>}

View File

@@ -224,7 +224,7 @@ export function AddDocumentsModal({
<ModalContent>
<ModalHeader>Add Documents</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
{fileError && (
@@ -242,8 +242,8 @@ export function AddDocumentsModal({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
isDragging && 'border-[var(--brand-primary-hex)]'
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--border-1)] border-dashed py-[10px]',
isDragging && 'border-[var(--surface-7)]'
)}
>
<input

View File

@@ -313,7 +313,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
</div>
</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[8px]'>
<Label>
@@ -458,7 +458,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
<ModalContent size='sm'>
<ModalHeader>Delete Tag</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-secondary)]'>
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
@@ -497,7 +497,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
<ModalContent size='sm'>
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='space-y-[8px]'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{selectedTagUsage?.documentCount || 0} document

View File

@@ -336,7 +336,7 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
<ModalHeader>Create Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
@@ -436,8 +436,8 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
isDragging && 'border-[var(--brand-primary-hex)]'
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--border-1)] border-dashed py-[10px]',
isDragging && 'border-[var(--surface-7)]'
)}
>
<input

View File

@@ -102,7 +102,7 @@ export function EditKnowledgeBaseModal({
<ModalHeader>Edit Knowledge Base</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='kb-name'>Name</Label>

View File

@@ -510,26 +510,40 @@ function IdentifierInput({
error && 'border-[var(--text-error)]'
)}
>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] px-[8px] font-medium text-[var(--text-secondary)] text-sm dark:bg-[var(--surface-5)]'>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] pr-[6px] pl-[8px] font-medium text-[var(--text-secondary)] text-sm dark:bg-[var(--surface-5)]'>
{getDomainPrefix()}
</div>
<div className='relative flex-1'>
<Input
id='chat-url'
placeholder='company-name'
placeholder='my-chat'
value={value}
onChange={(e) => handleChange(e.target.value)}
required
disabled={disabled}
className={cn(
'rounded-none border-0 pl-0 shadow-none disabled:bg-transparent disabled:opacity-100',
isChecking && 'pr-[32px]'
(isChecking || (isValid && value)) && 'pr-[32px]'
)}
/>
{isChecking && (
{isChecking ? (
<div className='-translate-y-1/2 absolute top-1/2 right-2'>
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-tertiary)]' />
</div>
) : (
isValid &&
value && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='-translate-y-1/2 absolute top-1/2 right-2'>
<Check className='h-4 w-4 text-[var(--brand-tertiary-2)]' />
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Name is available</span>
</Tooltip.Content>
</Tooltip.Root>
)
)}
</div>
</div>

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ChevronDown, ChevronRight, Eye, EyeOff, Loader2 } from 'lucide-react'
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Loader2 } from 'lucide-react'
import {
Badge,
ButtonGroup,
@@ -12,6 +12,7 @@ import {
TagInput,
type TagItem,
Textarea,
Tooltip,
} from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { getEnv } from '@/lib/core/config/env'
@@ -412,11 +413,7 @@ export function FormDeploy({
const displayUrl = fullUrl.replace(/^https?:\/\//, '')
return (
<form
id='form-deploy-form'
onSubmit={handleSubmit}
className='-mx-1 space-y-4 overflow-y-auto px-1'
>
<form id='form-deploy-form' onSubmit={handleSubmit} className='-mx-1 space-y-4 px-1'>
<div className='space-y-[12px]'>
{/* URL Input - matching chat style */}
<div>
@@ -429,7 +426,7 @@ export function FormDeploy({
(identifierError || errors.identifier) && 'border-[var(--text-error)]'
)}
>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] px-[8px] font-medium text-[var(--text-secondary)] text-sm'>
<div className='flex items-center whitespace-nowrap bg-[var(--surface-5)] pr-[6px] pl-[8px] font-medium text-[var(--text-secondary)] text-sm'>
{getDomainPrefix()}
</div>
<div className='relative flex-1'>
@@ -442,13 +439,28 @@ export function FormDeploy({
placeholder='my-form'
className={cn(
'rounded-none border-0 pl-0 shadow-none',
isCheckingIdentifier && 'pr-[32px]'
(isCheckingIdentifier || (identifierValidationPassed && identifier)) &&
'pr-[32px]'
)}
/>
{isCheckingIdentifier && (
{isCheckingIdentifier ? (
<div className='-translate-y-1/2 absolute top-1/2 right-2'>
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-tertiary)]' />
</div>
) : (
identifierValidationPassed &&
identifier && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<div className='-translate-y-1/2 absolute top-1/2 right-2'>
<Check className='h-4 w-4 text-[var(--brand-tertiary-2)]' />
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<span>Name is available</span>
</Tooltip.Content>
</Tooltip.Root>
)
)}
</div>
</div>

View File

@@ -443,7 +443,7 @@ export function McpDeploy({
return (
<form
id='mcp-deploy-form'
className='-mx-1 space-y-[12px] overflow-y-auto px-1'
className='-mx-1 space-y-[12px] px-1'
onSubmit={(e) => {
e.preventDefault()
handleSave()

View File

@@ -18,7 +18,11 @@ import {
import { getEnv } from '@/lib/core/config/env'
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
import type { WorkflowDeploymentVersionResponse } from '@/lib/workflows/persistence/utils'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components'
import { startsWithUuid } from '@/executor/constants'
import { useApiKeys } from '@/hooks/queries/api-keys'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -107,6 +111,22 @@ export function DeployModal({
const [chatSuccess, setChatSuccess] = useState(false)
const [formSuccess, setFormSuccess] = useState(false)
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
const userPermissions = useUserPermissionsContext()
const canManageWorkspaceKeys = userPermissions.canAdmin
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
workflowWorkspaceId || ''
)
const apiKeyWorkspaceKeys = apiKeysData?.workspaceKeys || []
const apiKeyPersonalKeys = apiKeysData?.personalKeys || []
const allowPersonalApiKeys =
workspaceSettingsData?.settings?.workspace?.allowPersonalApiKeys ?? true
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const isApiKeysLoading = isLoadingKeys || isLoadingSettings
const createButtonDisabled =
isApiKeysLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
const getApiKeyLabel = (value?: string | null) => {
if (value && value.trim().length > 0) {
return value
@@ -577,7 +597,7 @@ export function DeployModal({
<>
<Modal open={open} onOpenChange={handleCloseModal}>
<ModalContent size='lg' className='h-[76vh]'>
<ModalHeader>Deploy Workflow</ModalHeader>
<ModalHeader>Workflow Deployment</ModalHeader>
<ModalTabs
value={activeTab}
@@ -650,7 +670,7 @@ export function DeployModal({
)}
</ModalTabsContent>
<ModalTabsContent value='form' className='h-full'>
<ModalTabsContent value='form'>
{workflowId && (
<FormDeploy
workflowId={workflowId}
@@ -665,7 +685,7 @@ export function DeployModal({
)}
</ModalTabsContent>
<ModalTabsContent value='mcp' className='h-full'>
<ModalTabsContent value='mcp'>
{workflowId && (
<McpDeploy
workflowId={workflowId}
@@ -691,6 +711,17 @@ export function DeployModal({
onUndeploy={() => setShowUndeployConfirm(true)}
/>
)}
{activeTab === 'api' && (
<ModalFooter className='items-center justify-end'>
<Button
variant='tertiary'
onClick={() => setIsCreateKeyModalOpen(true)}
disabled={createButtonDisabled}
>
Generate API Key
</Button>
</ModalFooter>
)}
{activeTab === 'chat' && (
<ModalFooter className='items-center'>
<div className='flex gap-2'>
@@ -838,6 +869,16 @@ export function DeployModal({
</ModalFooter>
</ModalContent>
</Modal>
<CreateApiKeyModal
open={isCreateKeyModalOpen}
onOpenChange={setIsCreateKeyModalOpen}
workspaceId={workflowWorkspaceId || ''}
existingKeyNames={[...apiKeyWorkspaceKeys, ...apiKeyPersonalKeys].map((k) => k.name)}
allowPersonalApiKeys={allowPersonalApiKeys}
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
</>
)
}
@@ -911,7 +952,12 @@ function GeneralFooter({
<ModalFooter className='items-center justify-between'>
<StatusBadge isWarning={needsRedeployment} />
<div className='flex items-center gap-2'>
<Button variant='default' onClick={onUndeploy} disabled={isUndeploying || isSubmitting}>
<Button
variant='default'
onClick={onUndeploy}
disabled={isUndeploying || isSubmitting}
className='px-[7px] py-[5px]'
>
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
</Button>
{needsRedeployment && (

View File

@@ -422,7 +422,7 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
<ModalHeader>Help &amp; Support</ModalHeader>
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
<ModalBody className='!pb-[16px]'>
<ModalBody>
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[12px]'>
<div className='flex flex-col gap-[8px]'>
@@ -472,9 +472,9 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--border-1)] border-dashed py-[10px]',
{
'border-[var(--brand-primary-hex)]': isDragging,
'border-[var(--surface-7)]': isDragging,
}
)}
>

View File

@@ -2,11 +2,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, Copy, Info, Plus, Search } from 'lucide-react'
import { Info, Plus, Search } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
Input as EmcnInput,
Modal,
ModalBody,
ModalContent,
@@ -21,11 +20,11 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
import {
type ApiKey,
useApiKeys,
useCreateApiKey,
useDeleteApiKey,
useUpdateWorkspaceApiKeySettings,
} from '@/hooks/queries/api-keys'
import { useWorkspaceSettings } from '@/hooks/queries/workspace'
import { CreateApiKeyModal } from './components'
const logger = createLogger('ApiKeys')
@@ -50,7 +49,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
} = useApiKeys(workspaceId)
const { data: workspaceSettingsData, isLoading: isLoadingSettings } =
useWorkspaceSettings(workspaceId)
const createApiKeyMutation = useCreateApiKey()
const deleteApiKeyMutation = useDeleteApiKey()
const updateSettingsMutation = useUpdateWorkspaceApiKeySettings()
@@ -65,16 +63,10 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
// Local UI state
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [deleteKey, setDeleteKey] = useState<ApiKey | null>(null)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>('personal')
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const defaultKeyType = allowPersonalApiKeys ? 'personal' : 'workspace'
const createButtonDisabled = isLoading || (!allowPersonalApiKeys && !canManageWorkspaceKeys)
@@ -99,48 +91,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
.filter(({ key }) => key.name.toLowerCase().includes(searchTerm.toLowerCase()))
}, [personalKeys, searchTerm])
const handleCreateKey = async () => {
if (!userId || !newKeyName.trim()) return
const trimmedName = newKeyName.trim()
const isDuplicate =
keyType === 'workspace'
? workspaceKeys.some((k) => k.name === trimmedName)
: personalKeys.some((k) => k.name === trimmedName)
if (isDuplicate) {
setCreateError(
keyType === 'workspace'
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`
)
return
}
setCreateError(null)
try {
const data = await createApiKeyMutation.mutateAsync({
workspaceId,
name: trimmedName,
keyType,
})
setNewKey(data.key)
setShowNewKeyDialog(true)
setNewKeyName('')
setKeyType('personal')
setCreateError(null)
setIsCreateDialogOpen(false)
} catch (error: any) {
logger.error('API key creation failed:', { error })
const errorMessage = error.message || 'Failed to create API key. Please try again.'
if (errorMessage.toLowerCase().includes('already exists')) {
setCreateError(errorMessage)
} else {
setCreateError('Failed to create API key. Please check your connection and try again.')
}
}
}
const handleDeleteKey = async () => {
if (!userId || !deleteKey) return
@@ -163,12 +113,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
const handleModalClose = (open: boolean) => {
onOpenChange?.(open)
}
@@ -179,12 +123,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
}, [registerCloseHandler])
useEffect(() => {
if (!allowPersonalApiKeys && keyType === 'personal') {
setKeyType('workspace')
}
}, [allowPersonalApiKeys, keyType])
useEffect(() => {
if (shouldScrollToBottom && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
@@ -227,8 +165,6 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
}
e.currentTarget.blur()
setIsCreateDialogOpen(true)
setKeyType(defaultKeyType)
setCreateError(null)
}}
variant='tertiary'
disabled={createButtonDisabled}
@@ -443,164 +379,16 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
</Tooltip.Provider>
)}
{/* Create API Key Dialog */}
<Modal open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<ModalContent className='w-[400px]'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{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."}
</p>
<div className='mt-[16px] flex flex-col gap-[16px]'>
{canManageWorkspaceKeys && (
<div className='flex flex-col gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
API Key Type
</p>
<div className='flex gap-[8px]'>
<Button
type='button'
variant={keyType === 'personal' ? 'active' : 'default'}
onClick={() => {
setKeyType('personal')
if (createError) setCreateError(null)
}}
disabled={!allowPersonalApiKeys}
className='disabled:cursor-not-allowed disabled:opacity-60'
>
Personal
</Button>
<Button
type='button'
variant={keyType === 'workspace' ? 'active' : 'default'}
onClick={() => {
setKeyType('workspace')
if (createError) setCreateError(null)
}}
>
Workspace
</Button>
</div>
</div>
)}
<div className='flex flex-col gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
Enter a name for your API key to help you identify it later.
</p>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<EmcnInput
value={newKeyName}
onChange={(e) => {
setNewKeyName(e.target.value)
if (createError) setCreateError(null)
}}
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
name='api_key_label'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
{createError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
{createError}
</p>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button
variant='default'
onClick={() => {
setIsCreateDialogOpen(false)
setNewKeyName('')
setKeyType(defaultKeyType)
}}
>
Cancel
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleCreateKey}
disabled={
!newKeyName.trim() ||
createApiKeyMutation.isPending ||
(keyType === 'workspace' && !canManageWorkspaceKeys)
}
>
{createApiKeyMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* New API Key Dialog */}
<Modal
open={showNewKeyDialog}
onOpenChange={(open: boolean) => {
setShowNewKeyDialog(open)
if (!open) {
setNewKey(null)
setCopySuccess(false)
}
}}
>
<ModalContent className='w-[400px]'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This is the only time you will see your API key.{' '}
<span className='font-semibold text-[var(--text-primary)]'>
Copy it now and store it securely.
</span>
</p>
{newKey && (
<div className='relative mt-[10px]'>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
{/* Create API Key Modal */}
<CreateApiKeyModal
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
workspaceId={workspaceId}
existingKeyNames={[...workspaceKeys, ...personalKeys].map((k) => k.name)}
allowPersonalApiKeys={allowPersonalApiKeys}
canManageWorkspaceKeys={canManageWorkspaceKeys}
defaultKeyType={defaultKeyType}
/>
{/* Delete Confirmation Dialog */}
<Modal open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>

View File

@@ -0,0 +1,254 @@
'use client'
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, Copy } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
Input as EmcnInput,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
} from '@/components/emcn'
import { type ApiKey, useCreateApiKey } from '@/hooks/queries/api-keys'
const logger = createLogger('CreateApiKeyModal')
interface CreateApiKeyModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
existingKeyNames?: string[]
allowPersonalApiKeys?: boolean
canManageWorkspaceKeys?: boolean
defaultKeyType?: 'personal' | 'workspace'
onKeyCreated?: (key: ApiKey) => void
}
/**
* Reusable modal for creating API keys.
* Used in both the API keys settings page and the deploy modal.
*/
export function CreateApiKeyModal({
open,
onOpenChange,
workspaceId,
existingKeyNames = [],
allowPersonalApiKeys = true,
canManageWorkspaceKeys = false,
defaultKeyType = 'personal',
onKeyCreated,
}: CreateApiKeyModalProps) {
const [keyName, setKeyName] = useState('')
const [keyType, setKeyType] = useState<'personal' | 'workspace'>(defaultKeyType)
const [createError, setCreateError] = useState<string | null>(null)
const [newKey, setNewKey] = useState<ApiKey | null>(null)
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
const [copySuccess, setCopySuccess] = useState(false)
const createApiKeyMutation = useCreateApiKey()
const handleCreateKey = async () => {
const trimmedName = keyName.trim()
if (!trimmedName) return
const isDuplicate = existingKeyNames.some(
(name) => name.toLowerCase() === trimmedName.toLowerCase()
)
if (isDuplicate) {
setCreateError(
keyType === 'workspace'
? `A workspace API key named "${trimmedName}" already exists. Please choose a different name.`
: `A personal API key named "${trimmedName}" already exists. Please choose a different name.`
)
return
}
setCreateError(null)
try {
const data = await createApiKeyMutation.mutateAsync({
workspaceId,
name: trimmedName,
keyType,
})
setNewKey(data.key)
setShowNewKeyDialog(true)
setKeyName('')
setKeyType(defaultKeyType)
setCreateError(null)
onOpenChange(false)
onKeyCreated?.(data.key)
} catch (error: unknown) {
logger.error('API key creation failed:', { error })
const errorMessage =
error instanceof Error ? error.message : 'Failed to create API key. Please try again.'
if (errorMessage.toLowerCase().includes('already exists')) {
setCreateError(errorMessage)
} else {
setCreateError('Failed to create API key. Please check your connection and try again.')
}
}
}
const handleClose = () => {
onOpenChange(false)
setKeyName('')
setKeyType(defaultKeyType)
setCreateError(null)
}
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
}
return (
<>
{/* Create API Key Dialog */}
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent className='w-[400px]'>
<ModalHeader>Create new API key</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
{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."}
</p>
<div className='mt-[16px] flex flex-col gap-[16px]'>
{canManageWorkspaceKeys && (
<div className='flex flex-col gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
API Key Type
</p>
<ButtonGroup
value={keyType}
onValueChange={(value) => {
setKeyType(value as 'personal' | 'workspace')
if (createError) setCreateError(null)
}}
>
<ButtonGroupItem value='personal' disabled={!allowPersonalApiKeys}>
Personal
</ButtonGroupItem>
<ButtonGroupItem value='workspace'>Workspace</ButtonGroupItem>
</ButtonGroup>
</div>
)}
<div className='flex flex-col gap-[8px]'>
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
Enter a name for your API key to help you identify it later.
</p>
{/* Hidden decoy fields to prevent browser autofill */}
<input
type='text'
name='fakeusernameremembered'
autoComplete='username'
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none',
}}
tabIndex={-1}
readOnly
/>
<EmcnInput
value={keyName}
onChange={(e) => {
setKeyName(e.target.value)
if (createError) setCreateError(null)
}}
placeholder='e.g., Development, Production'
className='h-9'
autoFocus
name='api_key_label'
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
{createError && (
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
{createError}
</p>
)}
</div>
</div>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={handleClose}>
Cancel
</Button>
<Button
type='button'
variant='tertiary'
onClick={handleCreateKey}
disabled={
!keyName.trim() ||
createApiKeyMutation.isPending ||
(keyType === 'workspace' && !canManageWorkspaceKeys)
}
>
{createApiKeyMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
{/* New API Key Dialog - shows the created key */}
<Modal
open={showNewKeyDialog}
onOpenChange={(dialogOpen: boolean) => {
setShowNewKeyDialog(dialogOpen)
if (!dialogOpen) {
setNewKey(null)
setCopySuccess(false)
}
}}
>
<ModalContent className='w-[400px]'>
<ModalHeader>Your API key has been created</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-tertiary)]'>
This is the only time you will see your API key.{' '}
<span className='font-semibold text-[var(--text-primary)]'>
Copy it now and store it securely.
</span>
</p>
{newKey && (
<div className='relative mt-[10px]'>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{newKey.key}
</code>
</div>
<Button
variant='ghost'
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
onClick={() => copyToClipboard(newKey.key)}
>
{copySuccess ? (
<Check className='h-[14px] w-[14px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
)}
<span className='sr-only'>Copy to clipboard</span>
</Button>
</div>
)}
</ModalBody>
</ModalContent>
</Modal>
</>
)
}

View File

@@ -0,0 +1 @@
export { CreateApiKeyModal } from './create-api-key-modal'

View File

@@ -0,0 +1 @@
export { CreateApiKeyModal } from './create-api-key-modal'

View File

@@ -122,7 +122,7 @@ export function CreditBalance({
</ModalTrigger>
<ModalContent size='sm'>
<ModalHeader>Add Credits</ModalHeader>
<ModalBody className='!pb-[16px]'>
<ModalBody>
{success ? (
<p className='text-center text-[12px] text-[var(--text-primary)]'>
Credits added successfully!

View File

@@ -159,7 +159,7 @@ const ModalContent = React.forwardRef<
className={cn(
ANIMATION_CLASSES,
CONTENT_ANIMATION_CLASSES,
'fixed top-[50%] left-[50%] z-[500] flex max-h-[84vh] translate-x-[-50%] translate-y-[-50%] flex-col rounded-[8px] border bg-[var(--bg)] shadow-sm duration-200',
'fixed top-[50%] left-[50%] z-[500] flex max-h-[84vh] translate-x-[-50%] translate-y-[-50%] flex-col overflow-hidden rounded-[8px] border bg-[var(--bg)] shadow-sm duration-200',
MODAL_SIZES[size],
className
)}
@@ -340,12 +340,13 @@ ModalTabsTrigger.displayName = 'ModalTabsTrigger'
/**
* Modal tab content component. Content panel for each tab.
* Includes bottom padding for consistent spacing across all tabbed modals.
*/
const ModalTabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn('', className)} {...props} />
<TabsPrimitive.Content ref={ref} className={cn('pb-[10px]', className)} {...props} />
))
ModalTabsContent.displayName = 'ModalTabsContent'
@@ -358,7 +359,7 @@ const ModalBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivE
<div
ref={ref}
className={cn(
'flex-1 overflow-y-auto rounded-t-[8px] border-t bg-[var(--surface-2)] px-[14px] py-[10px]',
'flex-1 overflow-y-auto border-t bg-[var(--surface-2)] px-[14px] py-[10px]',
className
)}
{...props}
@@ -376,7 +377,7 @@ const ModalFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDi
<div
ref={ref}
className={cn(
'flex justify-end gap-[8px] rounded-b-[8px] border-t bg-[var(--surface-2)] px-[16px] py-[10px]',
'flex justify-end gap-[8px] border-t bg-[var(--surface-2)] px-[16px] py-[10px]',
className
)}
{...props}