improvement(creds): bulk paste functionality, save notification, error notif (#3328)

* improvement(creds): bulk paste functionality, save notification, error notif

* use effect anti patterns

* fix add to cursor button

* fix(attio): wrap webhook body in data object and include required filter field

* fixed and tested attio webhook lifecycle
This commit is contained in:
Waleed
2026-02-24 19:12:10 -08:00
committed by GitHub
parent d06459f489
commit ecdb133d1b
12 changed files with 591 additions and 262 deletions

View File

@@ -3,8 +3,8 @@
import type React from 'react'
import { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { AlertCircle, Paperclip, Send, Square, X } from 'lucide-react'
import { Tooltip } from '@/components/emcn'
import { Paperclip, Send, Square, X } from 'lucide-react'
import { Badge, Tooltip } from '@/components/emcn'
import { VoiceInput } from '@/app/chat/components/input/voice-input'
const logger = createLogger('ChatInput')
@@ -218,24 +218,12 @@ export const ChatInput: React.FC<{
<div ref={wrapperRef} className='w-full max-w-3xl md:max-w-[748px]'>
{/* Error Messages */}
{uploadErrors.length > 0 && (
<div className='mb-3'>
<div className='rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800/50 dark:bg-red-950/20'>
<div className='flex items-start gap-2'>
<AlertCircle className='mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400' />
<div className='flex-1'>
<div className='mb-1 font-medium text-red-800 text-sm dark:text-red-300'>
File upload error
</div>
<div className='space-y-1'>
{uploadErrors.map((error, idx) => (
<div key={idx} className='text-red-700 text-sm dark:text-red-400'>
{error}
</div>
))}
</div>
</div>
</div>
</div>
<div className='mb-3 flex flex-col gap-2'>
{uploadErrors.map((error, idx) => (
<Badge key={idx} variant='red' size='lg' dot className='max-w-full'>
{error}
</Badge>
))}
</div>
)}

View File

@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import { createLogger } from '@sim/logger'
import {
Badge,
Button,
Combobox,
DatePicker,
@@ -706,12 +707,10 @@ export function DocumentTagsModal({
(def) =>
def.displayName.toLowerCase() === editTagForm.displayName.toLowerCase()
) && (
<div className='rounded-[4px] border border-amber-500/50 bg-amber-500/10 p-[8px]'>
<p className='text-[11px] text-amber-600 dark:text-amber-400'>
Maximum tag definitions reached. You can still use existing tag
definitions, but cannot create new ones.
</p>
</div>
<Badge variant='amber' size='lg' dot className='max-w-full'>
Maximum tag definitions reached. You can still use existing tag definitions,
but cannot create new ones.
</Badge>
)}
<div className='flex gap-[8px]'>

View File

@@ -2,7 +2,7 @@
import { createElement, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { AlertTriangle, Check, Copy, Plus, RefreshCw, Search, Share2, X } from 'lucide-react'
import { AlertTriangle, Check, Clipboard, Plus, Search, Share2, X } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Badge,
@@ -58,6 +58,7 @@ import {
useOAuthConnections,
} from '@/hooks/queries/oauth-connections'
import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace'
import { useSettingsModalStore } from '@/stores/modals/settings/store'
const logger = createLogger('CredentialsManager')
@@ -143,9 +144,7 @@ function getSecretCredentialType(
return scope === 'workspace' ? 'env_workspace' : 'env_personal'
}
function typeBadgeVariant(type: WorkspaceCredential['type']): 'blue' | 'amber' | 'gray-secondary' {
if (type === 'oauth') return 'blue'
if (type === 'env_workspace') return 'amber'
function typeBadgeVariant(_type: WorkspaceCredential['type']): 'gray-secondary' {
return 'gray-secondary'
}
@@ -176,7 +175,11 @@ function CredentialSkeleton() {
)
}
export function CredentialsManager() {
interface CredentialsManagerProps {
onOpenChange?: (open: boolean) => void
}
export function CredentialsManager({ onOpenChange }: CredentialsManagerProps) {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
@@ -191,6 +194,7 @@ export function CredentialsManager() {
const [createDescription, setCreateDescription] = useState('')
const [createEnvKey, setCreateEnvKey] = useState('')
const [createEnvValue, setCreateEnvValue] = useState('')
const [isCreateEnvValueFocused, setIsCreateEnvValueFocused] = useState(false)
const [createOAuthProviderId, setCreateOAuthProviderId] = useState('')
const [createSecretInputMode, setCreateSecretInputMode] = useState<SecretInputMode>('single')
const [createBulkEntries, setCreateBulkEntries] = useState<ParsedEnvEntry[]>([])
@@ -205,6 +209,10 @@ export function CredentialsManager() {
const [credentialToDelete, setCredentialToDelete] = useState<WorkspaceCredential | null>(null)
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
const [unsavedChangesAlertSource, setUnsavedChangesAlertSource] = useState<
'back' | 'modal-close'
>('back')
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
@@ -434,15 +442,55 @@ export function CredentialsManager() {
}
}
useEffect(() => {
if (createType !== 'oauth') return
if (createOAuthProviderId || oauthConnections.length === 0) return
setCreateOAuthProviderId(oauthConnections[0]?.providerId || '')
}, [createType, createOAuthProviderId, oauthConnections])
const handleBackAttempt = useCallback(() => {
if (isDetailsDirty && !isSavingDetails) {
setUnsavedChangesAlertSource('back')
setShowUnsavedChangesAlert(true)
} else {
setSelectedCredentialId(null)
}
}, [isDetailsDirty, isSavingDetails])
const handleDiscardChanges = useCallback(() => {
setShowUnsavedChangesAlert(false)
setSelectedEnvValueDraft(selectedEnvCurrentValue)
setSelectedDescriptionDraft(selectedCredential?.description || '')
setSelectedDisplayNameDraft(selectedCredential?.displayName || '')
setSelectedCredentialId(null)
}, [selectedEnvCurrentValue, selectedCredential])
const handleDiscardAndClose = useCallback(() => {
setShowUnsavedChangesAlert(false)
useSettingsModalStore.getState().setHasUnsavedChanges(false)
useSettingsModalStore.getState().setOnCloseAttempt(null)
onOpenChange?.(false)
}, [onOpenChange])
const handleCloseAttemptFromModal = useCallback(() => {
if (selectedCredentialId && isDetailsDirty && !isSavingDetails) {
setUnsavedChangesAlertSource('modal-close')
setShowUnsavedChangesAlert(true)
}
}, [selectedCredentialId, isDetailsDirty, isSavingDetails])
useEffect(() => {
setCreateError(null)
}, [createOAuthProviderId])
const store = useSettingsModalStore.getState()
if (selectedCredentialId && isDetailsDirty) {
store.setHasUnsavedChanges(true)
store.setOnCloseAttempt(handleCloseAttemptFromModal)
} else {
store.setHasUnsavedChanges(false)
store.setOnCloseAttempt(null)
}
}, [selectedCredentialId, isDetailsDirty, handleCloseAttemptFromModal])
useEffect(() => {
return () => {
const store = useSettingsModalStore.getState()
store.setHasUnsavedChanges(false)
store.setOnCloseAttempt(null)
}
}, [])
const applyPendingCredentialCreateRequest = useCallback(
(request: PendingCredentialCreateRequest) => {
@@ -1030,6 +1078,34 @@ export function CredentialsManager() {
<ModalContent size='lg'>
<ModalHeader>Create Secret</ModalHeader>
<ModalBody>
{(createError ||
existingOAuthDisplayName ||
selectedExistingEnvCredential ||
crossScopeEnvConflict) && (
<div className='mb-3 flex flex-col gap-2'>
{createError && (
<Badge variant='red' size='lg' dot className='max-w-full'>
{createError}
</Badge>
)}
{existingOAuthDisplayName && (
<Badge variant='red' size='lg' dot className='max-w-full'>
A secret named "{existingOAuthDisplayName.displayName}" already exists.
</Badge>
)}
{selectedExistingEnvCredential && (
<Badge variant='red' size='lg' dot className='max-w-full'>
A secret with key "{selectedExistingEnvCredential.displayName}" already exists.
</Badge>
)}
{!selectedExistingEnvCredential && crossScopeEnvConflict && (
<Badge variant='amber' size='lg' dot className='max-w-full'>
A workspace secret with key "{crossScopeEnvConflict.envKey}" already exists.
Workspace secrets take precedence at runtime.
</Badge>
)}
</div>
)}
<div className='flex flex-col gap-[12px]'>
<div>
<Label>Type</Label>
@@ -1044,8 +1120,16 @@ export function CredentialsManager() {
}
selectedValue={createType}
onChange={(value) => {
setCreateType(value as CreateCredentialType)
const newType = value as CreateCredentialType
setCreateType(newType)
setCreateError(null)
if (
newType === 'oauth' &&
!createOAuthProviderId &&
oauthConnections.length > 0
) {
setCreateOAuthProviderId(oauthConnections[0]?.providerId || '')
}
}}
placeholder='Select type'
/>
@@ -1063,6 +1147,7 @@ export function CredentialsManager() {
onChange={(event) => setCreateDisplayName(event.target.value)}
placeholder='Secret name'
autoComplete='off'
data-lpignore='true'
className='mt-[6px]'
/>
</div>
@@ -1074,6 +1159,7 @@ export function CredentialsManager() {
placeholder='Optional description'
maxLength={500}
autoComplete='off'
data-lpignore='true'
className='mt-[6px] min-h-[80px] resize-none'
/>
</div>
@@ -1087,7 +1173,10 @@ export function CredentialsManager() {
?.label || ''
}
selectedValue={createOAuthProviderId}
onChange={setCreateOAuthProviderId}
onChange={(value) => {
setCreateOAuthProviderId(value)
setCreateError(null)
}}
placeholder='Select OAuth service'
searchable
searchPlaceholder='Search services...'
@@ -1113,18 +1202,6 @@ export function CredentialsManager() {
/>
</div>
</div>
{existingOAuthDisplayName && (
<div className='rounded-[8px] border border-red-500/50 bg-red-50 p-[12px] dark:bg-red-950/30'>
<div className='flex items-start gap-[10px]'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400' />
<p className='text-[12px] text-red-700 dark:text-red-300'>
A secret named{' '}
<span className='font-medium'>{existingOAuthDisplayName.displayName}</span>{' '}
already exists.
</p>
</div>
</div>
)}
</div>
) : (
<div className='flex flex-col gap-[10px]'>
@@ -1148,15 +1225,22 @@ export function CredentialsManager() {
}}
onPaste={(event) => {
const pasted = event.clipboardData.getData('text')
if (pasted.includes('=') && pasted.includes('\n')) {
event.preventDefault()
const { entries } = parseEnvText(pasted)
if (entries.length > 0) {
setCreateSecretInputMode('bulk')
setCreateBulkEntries(entries)
setCreateError(null)
}
const { entries } = parseEnvText(pasted)
if (entries.length === 0) {
return
}
event.preventDefault()
if (entries.length === 1) {
setCreateEnvKey(entries[0].key)
setCreateEnvValue(entries[0].value)
setCreateError(null)
return
}
setCreateSecretInputMode('bulk')
setCreateBulkEntries(entries)
setCreateError(null)
}}
placeholder='API_KEY'
autoComplete='off'
@@ -1168,9 +1252,11 @@ export function CredentialsManager() {
/>
<div />
<Input
type='password'
type='text'
value={createEnvValue}
onChange={(event) => setCreateEnvValue(event.target.value)}
onFocus={() => setIsCreateEnvValueFocused(true)}
onBlur={() => setIsCreateEnvValueFocused(false)}
placeholder='Value'
autoComplete='new-password'
autoCapitalize='none'
@@ -1178,6 +1264,11 @@ export function CredentialsManager() {
spellCheck={false}
data-lpignore='true'
data-1p-ignore='true'
style={
isCreateEnvValueFocused
? undefined
: ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)
}
/>
</div>
</div>
@@ -1225,7 +1316,7 @@ export function CredentialsManager() {
/>
<div />
<Input
type='password'
type='text'
value={entry.value}
onChange={(event) => {
const updated = [...createBulkEntries]
@@ -1239,6 +1330,7 @@ export function CredentialsManager() {
spellCheck={false}
data-lpignore='true'
data-1p-ignore='true'
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
/>
<Button
variant='ghost'
@@ -1280,44 +1372,6 @@ export function CredentialsManager() {
</ButtonGroup>
</div>
</div>
{selectedExistingEnvCredential && (
<div className='rounded-[8px] border border-red-500/50 bg-red-50 p-[12px] dark:bg-red-950/30'>
<div className='flex items-start gap-[10px]'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400' />
<p className='text-[12px] text-red-700 dark:text-red-300'>
A secret with key{' '}
<span className='font-medium'>
{selectedExistingEnvCredential.displayName}
</span>{' '}
already exists.
</p>
</div>
</div>
)}
{!selectedExistingEnvCredential && crossScopeEnvConflict && (
<div className='rounded-[8px] border border-amber-500/50 bg-amber-50 p-[12px] dark:bg-amber-950/30'>
<div className='flex items-start gap-[10px]'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400' />
<p className='text-[12px] text-amber-700 dark:text-amber-300'>
A workspace secret with key{' '}
<span className='font-medium'>{crossScopeEnvConflict.envKey}</span> already
exists. Workspace secrets take precedence at runtime.
</p>
</div>
</div>
)}
</div>
)}
{createError && (
<div className='rounded-[8px] border border-red-500/50 bg-red-50 p-[12px] dark:bg-red-950/30'>
<div className='flex items-start gap-[10px]'>
<AlertTriangle className='mt-[1px] h-4 w-4 flex-shrink-0 text-red-600 dark:text-red-400' />
<p className='whitespace-pre-wrap text-[12px] text-red-700 dark:text-red-300'>
{createError}
</p>
</div>
</div>
)}
</div>
@@ -1431,86 +1485,44 @@ export function CredentialsManager() {
</Modal>
)
const unsavedChangesAlertJsx = (
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<ModalContent size='sm'>
<ModalHeader>Unsaved Changes</ModalHeader>
<ModalBody>
<p className='text-[12px] text-[var(--text-secondary)]'>
You have unsaved changes. Are you sure you want to discard them?
</p>
</ModalBody>
<ModalFooter>
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
Keep Editing
</Button>
<Button
variant='destructive'
onClick={
unsavedChangesAlertSource === 'modal-close'
? handleDiscardAndClose
: handleDiscardChanges
}
>
Discard Changes
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
if (selectedCredential) {
return (
<>
<div className='flex h-full flex-col gap-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-[16px]'>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>Type</span>
<div className='flex items-center gap-[8px]'>
<Badge variant={typeBadgeVariant(selectedCredential.type)}>
{typeLabel(selectedCredential.type)}
</Badge>
{selectedCredential.role && (
<Badge
variant={selectedCredential.role === 'admin' ? 'blue' : 'gray-secondary'}
>
{selectedCredential.role}
</Badge>
)}
</div>
</div>
{selectedCredential.type === 'oauth' ? (
<>
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center gap-[6px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Display Name
</span>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
className='h-[20px] w-[20px] p-0'
onClick={() => {
navigator.clipboard.writeText(selectedCredential.id)
setCopyIdSuccess(true)
setTimeout(() => setCopyIdSuccess(false), 2000)
}}
>
{copyIdSuccess ? (
<Check className='h-[11px] w-[11px]' />
) : (
<Copy className='h-[11px] w-[11px]' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Copy secret ID</Tooltip.Content>
</Tooltip.Root>
</div>
<Input
id='credential-display-name'
value={selectedDisplayNameDraft}
onChange={(event) => setSelectedDisplayNameDraft(event.target.value)}
autoComplete='off'
disabled={!isSelectedAdmin}
/>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Description
</span>
<Textarea
id='credential-description'
value={selectedDescriptionDraft}
onChange={(event) => setSelectedDescriptionDraft(event.target.value)}
placeholder='Add a description...'
maxLength={500}
autoComplete='off'
disabled={!isSelectedAdmin}
className='min-h-[60px] resize-none'
/>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Connected service
</span>
<div className='flex items-center gap-[10px] rounded-[8px] border border-[var(--border-1)] px-[10px] py-[8px]'>
<div className='rounded-[8px] border border-[var(--border-1)] p-[10px]'>
<div className='flex items-center justify-between gap-[12px]'>
<div className='flex min-w-0 items-center gap-[10px]'>
<div className='flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-[6px] bg-[var(--surface-5)]'>
{selectedOAuthServiceConfig ? (
createElement(selectedOAuthServiceConfig.icon, { className: 'h-4 w-4' })
@@ -1520,18 +1532,113 @@ export function CredentialsManager() {
</span>
)}
</div>
<span className='text-[12px] text-[var(--text-primary)]'>
{resolveProviderLabel(selectedCredential.providerId) || 'Unknown service'}
</span>
<div className='min-w-0'>
<p className='text-[11px] text-[var(--text-tertiary)]'>Connected service</p>
<p className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{resolveProviderLabel(selectedCredential.providerId) || 'Unknown service'}
</p>
</div>
</div>
<div className='flex flex-shrink-0 items-center gap-[8px]'>
<Badge variant={typeBadgeVariant(selectedCredential.type)}>
{typeLabel(selectedCredential.type)}
</Badge>
{selectedCredential.role && (
<Badge
variant={
selectedCredential.role === 'admin' ? 'purple' : 'gray-secondary'
}
>
{selectedCredential.role}
</Badge>
)}
</div>
</div>
</div>
) : (
<div className='flex flex-col gap-[10px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label>Type</Label>
</div>
<div className='flex items-center gap-[8px]'>
<Badge variant={typeBadgeVariant(selectedCredential.type)}>
{typeLabel(selectedCredential.type)}
</Badge>
{selectedCredential.role && (
<Badge
variant={selectedCredential.role === 'admin' ? 'purple' : 'gray-secondary'}
>
{selectedCredential.role}
</Badge>
)}
</div>
</div>
)}
{selectedCredential.type === 'oauth' ? (
<>
<div className='flex flex-col gap-[10px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label className='flex items-center gap-[6px]'>
Display Name
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button
type='button'
className='-my-1 flex h-5 w-5 items-center justify-center'
onClick={() => {
navigator.clipboard.writeText(selectedCredential.id)
setCopyIdSuccess(true)
setTimeout(() => setCopyIdSuccess(false), 2000)
}}
aria-label='Copy value'
>
{copyIdSuccess ? (
<Check className='h-3 w-3 text-green-500' />
) : (
<Clipboard className='h-3 w-3 text-muted-foreground' />
)}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
{copyIdSuccess ? 'Copied!' : 'Copy secret ID'}
</Tooltip.Content>
</Tooltip.Root>
</Label>
</div>
<Input
id='credential-display-name'
value={selectedDisplayNameDraft}
onChange={(event) => setSelectedDisplayNameDraft(event.target.value)}
autoComplete='off'
data-lpignore='true'
disabled={!isSelectedAdmin}
/>
</div>
<div className='flex flex-col gap-[10px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label>Description</Label>
</div>
<Textarea
id='credential-description'
value={selectedDescriptionDraft}
onChange={(event) => setSelectedDescriptionDraft(event.target.value)}
placeholder='Add a description...'
maxLength={500}
autoComplete='off'
data-lpignore='true'
disabled={!isSelectedAdmin}
className='min-h-[60px] resize-none'
/>
</div>
</>
) : (
<>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Secret key
</span>
<div className='flex flex-col gap-[10px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label>Secret key</Label>
</div>
<Input
id='credential-env-key'
value={selectedCredential.envKey || ''}
@@ -1541,18 +1648,17 @@ export function CredentialsManager() {
/>
</div>
<div className='flex flex-col gap-[8px]'>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Secret value
</span>
<div className='flex flex-col gap-[10px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label>Secret value</Label>
{canEditSelectedEnvValue && (
<Button
variant='ghost'
<button
type='button'
className='-my-1 h-5 px-2 py-0 text-[11px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
onClick={() => setIsEditingEnvValue((value) => !value)}
>
{isEditingEnvValue ? 'Hide' : 'Edit'}
</Button>
</button>
)}
</div>
<Input
@@ -1576,10 +1682,10 @@ export function CredentialsManager() {
/>
</div>
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Description
</span>
<div className='flex flex-col gap-[10px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label>Description</Label>
</div>
<Textarea
id='credential-description'
value={selectedDescriptionDraft}
@@ -1600,10 +1706,10 @@ export function CredentialsManager() {
</div>
)}
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
Members ({activeMembers.length})
</span>
<div className='flex flex-col gap-[10px]'>
<div className='flex items-center justify-between gap-[6px] pl-[2px]'>
<Label>Members ({activeMembers.length})</Label>
</div>
{membersLoading ? (
<div className='flex flex-col gap-[8px]'>
@@ -1662,7 +1768,7 @@ export function CredentialsManager() {
</>
) : (
<>
<Badge variant={member.role === 'admin' ? 'blue' : 'gray-secondary'}>
<Badge variant={member.role === 'admin' ? 'purple' : 'gray-secondary'}>
{member.role}
</Badge>
<div />
@@ -1670,54 +1776,49 @@ export function CredentialsManager() {
)}
</div>
))}
</div>
)}
{isSelectedAdmin && selectedCredential.type !== 'env_workspace' && (
<div className='rounded-[8px] border border-[var(--border-1)] p-[10px]'>
<Label>Add member</Label>
<div className='mt-[6px] grid grid-cols-[1fr_120px_auto] gap-[8px]'>
<Combobox
options={workspaceUserOptions}
value={
workspaceUserOptions.find((option) => option.value === memberUserId)
?.label || ''
}
selectedValue={memberUserId}
onChange={setMemberUserId}
placeholder='Select user'
/>
<Combobox
options={roleOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
value={
roleOptions.find((option) => option.value === memberRole)?.label || ''
}
selectedValue={memberRole}
onChange={(value) => setMemberRole(value as WorkspaceCredentialRole)}
placeholder='Role'
/>
<Button
variant='active'
onClick={handleAddMember}
disabled={!memberUserId || upsertMember.isPending}
>
Add
</Button>
</div>
{isSelectedAdmin && selectedCredential.type !== 'env_workspace' && (
<div className='grid grid-cols-[1fr_120px_auto] items-center gap-[8px] border-[var(--border)] border-t border-dashed pt-[8px]'>
<Combobox
options={workspaceUserOptions}
value={
workspaceUserOptions.find((option) => option.value === memberUserId)
?.label || ''
}
selectedValue={memberUserId}
onChange={setMemberUserId}
placeholder='Add member...'
size='sm'
/>
<Combobox
options={roleOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
value={
roleOptions.find((option) => option.value === memberRole)?.label || ''
}
selectedValue={memberRole}
onChange={(value) => setMemberRole(value as WorkspaceCredentialRole)}
placeholder='Role'
size='sm'
/>
<Button
variant='ghost'
onClick={handleAddMember}
disabled={!memberUserId || upsertMember.isPending}
>
Add
</Button>
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className='mt-auto flex items-center justify-between'>
<div className='mt-auto flex items-center justify-between border-[var(--border)] border-t pt-[10px]'>
<div className='flex items-center gap-[8px]'>
<Button onClick={() => setSelectedCredentialId(null)} variant='active'>
Back
</Button>
{isSelectedAdmin && (
<>
{selectedCredential.type === 'oauth' && (
@@ -1726,8 +1827,9 @@ export function CredentialsManager() {
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
<RefreshCw className='mr-[6px] h-[13px] w-[13px]' />
Reconnect
{`Reconnect to ${
resolveProviderLabel(selectedCredential.providerId) || 'service'
}`}
</Button>
)}
{selectedCredential.type === 'env_personal' && (
@@ -1737,7 +1839,7 @@ export function CredentialsManager() {
disabled={isPromoting || deleteCredential.isPending}
>
<Share2 className='mr-[6px] h-[13px] w-[13px]' />
Promote
Promote to workspace
</Button>
)}
{selectedCredential.type === 'oauth' &&
@@ -1763,21 +1865,27 @@ export function CredentialsManager() {
</>
)}
</div>
{isSelectedAdmin && (
<Button
variant='tertiary'
onClick={handleSaveDetails}
disabled={!isDetailsDirty || isSavingDetails}
>
{isSavingDetails ? 'Saving...' : 'Save'}
<div className='flex items-center gap-[8px]'>
<Button onClick={handleBackAttempt} variant='default'>
Back
</Button>
)}
{isSelectedAdmin && (
<Button
variant='tertiary'
onClick={handleSaveDetails}
disabled={!isDetailsDirty || isSavingDetails}
>
{isSavingDetails ? 'Saving...' : 'Save'}
</Button>
)}
</div>
</div>
</div>
{createModalJsx}
{oauthRequiredModalJsx}
{deleteConfirmDialogJsx}
{unsavedChangesAlertJsx}
</>
)
}

View File

@@ -6,10 +6,10 @@ interface CredentialsProps {
onOpenChange?: (open: boolean) => void
}
export function Credentials(_props: CredentialsProps) {
export function Credentials({ onOpenChange }: CredentialsProps) {
return (
<div className='h-full min-h-0'>
<CredentialsManager />
<CredentialsManager onOpenChange={onOpenChange} />
</div>
)
}

View File

@@ -484,12 +484,12 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
{activeConfigTab === 'cursor' && (
<a
href={getCursorInstallUrl(server.isPublic, server.name)}
className='absolute top-[6px] right-2'
className='absolute top-[6px] right-2 inline-flex rounded-[6px] bg-[var(--surface-5)] ring-1 ring-[var(--border-1)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface-2)]'
>
<img
src='https://cursor.com/deeplink/mcp-install-dark.svg'
alt='Add to Cursor'
className='h-[26px]'
className='h-[26px] rounded-[6px] align-middle'
/>
</a>
)}

View File

@@ -449,7 +449,18 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
}
}
const { hasUnsavedChanges, onCloseAttempt, setHasUnsavedChanges, setOnCloseAttempt } =
useSettingsModalStore()
const handleDialogOpenChange = (newOpen: boolean) => {
if (!newOpen && hasUnsavedChanges && onCloseAttempt) {
onCloseAttempt()
return
}
if (!newOpen) {
setHasUnsavedChanges(false)
setOnCloseAttempt(null)
}
onOpenChange(newOpen)
}

View File

@@ -979,9 +979,10 @@ export async function queueWebhookExecution(
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId && triggerId !== 'attio_webhook') {
const { isAttioPayloadMatch } = await import('@/triggers/attio/utils')
const { isAttioPayloadMatch, getAttioEvent } = await import('@/triggers/attio/utils')
if (!isAttioPayloadMatch(triggerId, body)) {
const eventType = body?.event_type as string | undefined
const event = getAttioEvent(body)
const eventType = event?.event_type as string | undefined
logger.debug(
`[${options.requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`,
{
@@ -989,6 +990,7 @@ export async function queueWebhookExecution(
workflowId: foundWorkflow.id,
triggerId,
receivedEvent: eventType,
bodyKeys: Object.keys(body),
}
)
return NextResponse.json({ status: 'skipped', reason: 'event_type_mismatch' })

View File

@@ -1017,7 +1017,7 @@ export async function createAttioWebhookSubscription(
const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils')
let subscriptions: Array<{ event_type: string }> = []
let subscriptions: Array<{ event_type: string; filter: null }> = []
if (triggerId === 'attio_webhook') {
const allEvents = new Set<string>()
for (const events of Object.values(TRIGGER_EVENT_MAP)) {
@@ -1025,7 +1025,7 @@ export async function createAttioWebhookSubscription(
allEvents.add(event)
}
}
subscriptions = Array.from(allEvents).map((event_type) => ({ event_type }))
subscriptions = Array.from(allEvents).map((event_type) => ({ event_type, filter: null }))
} else {
const events = TRIGGER_EVENT_MAP[triggerId]
if (!events || events.length === 0) {
@@ -1034,12 +1034,14 @@ export async function createAttioWebhookSubscription(
})
throw new Error(`Unknown Attio trigger type: ${triggerId}`)
}
subscriptions = events.map((event_type) => ({ event_type }))
subscriptions = events.map((event_type) => ({ event_type, filter: null }))
}
const requestBody = {
target_url: notificationUrl,
subscriptions,
data: {
target_url: notificationUrl,
subscriptions,
},
}
const attioResponse = await fetch('https://api.attio.com/v2/webhooks', {
@@ -1091,7 +1093,12 @@ export async function createAttioWebhookSubscription(
attioLogger.info(
`[${requestId}] Successfully created webhook in Attio for webhook ${webhookData.id}.`,
{ attioWebhookId: webhookId }
{
attioWebhookId: webhookId,
targetUrl: notificationUrl,
subscriptionCount: subscriptions.length,
status: data.status,
}
)
return { externalId: webhookId, webhookSecret: secret || '' }

View File

@@ -1291,6 +1291,49 @@ export async function formatWebhookInput(
}
}
if (foundWebhook.provider === 'attio') {
const {
extractAttioRecordData,
extractAttioRecordUpdatedData,
extractAttioRecordMergedData,
extractAttioNoteData,
extractAttioTaskData,
extractAttioCommentData,
extractAttioListEntryData,
extractAttioListEntryUpdatedData,
extractAttioGenericData,
} = await import('@/triggers/attio/utils')
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const triggerId = providerConfig.triggerId as string | undefined
if (triggerId === 'attio_record_updated') {
return extractAttioRecordUpdatedData(body)
}
if (triggerId === 'attio_record_merged') {
return extractAttioRecordMergedData(body)
}
if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') {
return extractAttioRecordData(body)
}
if (triggerId?.startsWith('attio_note_')) {
return extractAttioNoteData(body)
}
if (triggerId?.startsWith('attio_task_')) {
return extractAttioTaskData(body)
}
if (triggerId?.startsWith('attio_comment_')) {
return extractAttioCommentData(body)
}
if (triggerId === 'attio_list_entry_updated') {
return extractAttioListEntryUpdatedData(body)
}
if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') {
return extractAttioListEntryData(body)
}
return extractAttioGenericData(body)
}
return body
}

View File

@@ -7,6 +7,8 @@ export const useSettingsModalStore = create<SettingsModalState>((set) => ({
isOpen: false,
initialSection: null,
mcpServerId: null,
hasUnsavedChanges: false,
onCloseAttempt: null,
openModal: (options) =>
set({
@@ -18,6 +20,8 @@ export const useSettingsModalStore = create<SettingsModalState>((set) => ({
closeModal: () =>
set({
isOpen: false,
hasUnsavedChanges: false,
onCloseAttempt: null,
}),
clearInitialState: () =>
@@ -25,4 +29,14 @@ export const useSettingsModalStore = create<SettingsModalState>((set) => ({
initialSection: null,
mcpServerId: null,
}),
setHasUnsavedChanges: (hasChanges) =>
set({
hasUnsavedChanges: hasChanges,
}),
setOnCloseAttempt: (callback) =>
set({
onCloseAttempt: callback,
}),
}))

View File

@@ -16,8 +16,12 @@ export interface SettingsModalState {
isOpen: boolean
initialSection: SettingsSection | null
mcpServerId: string | null
hasUnsavedChanges: boolean
onCloseAttempt: (() => void) | null
openModal: (options?: { section?: SettingsSection; mcpServerId?: string }) => void
closeModal: () => void
clearInitialState: () => void
setHasUnsavedChanges: (hasChanges: boolean) => void
setOnCloseAttempt: (callback: (() => void) | null) => void
}

View File

@@ -290,6 +290,15 @@ export const TRIGGER_EVENT_MAP: Record<string, string[]> = {
attio_list_entry_deleted: ['list-entry.deleted'],
}
/**
* Extracts the first event from an Attio webhook payload.
* Attio wraps events in an `events` array: `{ webhook_id, events: [{ event_type, id, ... }] }`.
*/
export function getAttioEvent(body: Record<string, unknown>): Record<string, unknown> | undefined {
const events = body.events as Array<Record<string, unknown>> | undefined
return events?.[0]
}
/**
* Checks if an Attio webhook payload matches a trigger.
*/
@@ -298,7 +307,8 @@ export function isAttioPayloadMatch(triggerId: string, body: Record<string, unkn
return true
}
const eventType = body.event_type as string | undefined
const event = getAttioEvent(body)
const eventType = event?.event_type as string | undefined
if (!eventType) {
return false
}
@@ -306,3 +316,146 @@ export function isAttioPayloadMatch(triggerId: string, body: Record<string, unkn
const acceptedEvents = TRIGGER_EVENT_MAP[triggerId]
return acceptedEvents ? acceptedEvents.includes(eventType) : false
}
/**
* Extracts formatted data from an Attio record event payload.
* Used for record.created, record.deleted triggers.
*/
export function extractAttioRecordData(body: Record<string, unknown>): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
workspaceId: id.workspace_id ?? null,
objectId: id.object_id ?? null,
recordId: id.record_id ?? null,
}
}
/**
* Extracts formatted data from an Attio record.updated event payload.
*/
export function extractAttioRecordUpdatedData(
body: Record<string, unknown>
): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
workspaceId: id.workspace_id ?? null,
objectId: id.object_id ?? null,
recordId: id.record_id ?? null,
attributeId: id.attribute_id ?? null,
}
}
/**
* Extracts formatted data from an Attio record.merged event payload.
*/
export function extractAttioRecordMergedData(
body: Record<string, unknown>
): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
workspaceId: id.workspace_id ?? null,
objectId: id.object_id ?? null,
recordId: id.record_id ?? null,
duplicateObjectId: (event.duplicate_object_id as string) ?? null,
duplicateRecordId: (event.duplicate_record_id as string) ?? null,
}
}
/**
* Extracts formatted data from an Attio note event payload.
*/
export function extractAttioNoteData(body: Record<string, unknown>): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
workspaceId: id.workspace_id ?? null,
noteId: id.note_id ?? null,
parentObjectId: (event.parent_object_id as string) ?? null,
parentRecordId: (event.parent_record_id as string) ?? null,
}
}
/**
* Extracts formatted data from an Attio task event payload.
*/
export function extractAttioTaskData(body: Record<string, unknown>): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
workspaceId: id.workspace_id ?? null,
taskId: id.task_id ?? null,
}
}
/**
* Extracts formatted data from an Attio comment event payload.
*/
export function extractAttioCommentData(body: Record<string, unknown>): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
workspaceId: id.workspace_id ?? null,
threadId: (event.thread_id as string) ?? null,
commentId: id.comment_id ?? null,
objectId: (event.object_id as string) ?? null,
recordId: (event.record_id as string) ?? null,
listId: (event.list_id as string) ?? null,
entryId: (event.entry_id as string) ?? null,
}
}
/**
* Extracts formatted data from an Attio list-entry event payload.
* Used for list-entry.created, list-entry.deleted triggers.
*/
export function extractAttioListEntryData(body: Record<string, unknown>): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
workspaceId: id.workspace_id ?? null,
listId: id.list_id ?? null,
entryId: id.entry_id ?? null,
}
}
/**
* Extracts formatted data from an Attio list-entry.updated event payload.
*/
export function extractAttioListEntryUpdatedData(
body: Record<string, unknown>
): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
workspaceId: id.workspace_id ?? null,
listId: id.list_id ?? null,
entryId: id.entry_id ?? null,
attributeId: id.attribute_id ?? null,
}
}
/**
* Extracts formatted data from a generic Attio webhook payload.
* Passes through the first event with camelCase field mapping.
*/
export function extractAttioGenericData(body: Record<string, unknown>): Record<string, unknown> {
const event = getAttioEvent(body) ?? {}
const id = (event.id as Record<string, unknown>) ?? {}
return {
eventType: event.event_type ?? null,
id,
parentObjectId: (event.parent_object_id as string) ?? null,
parentRecordId: (event.parent_record_id as string) ?? null,
}
}