mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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]'>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 || '' }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user