-
- Secret value
-
+
-
-
+
-
{isSelectedAdmin && (
<>
{selectedCredential.type === 'oauth' && (
@@ -1726,8 +1827,9 @@ export function CredentialsManager() {
onClick={handleReconnectOAuth}
disabled={connectOAuthService.isPending}
>
-
- Reconnect
+ {`Reconnect to ${
+ resolveProviderLabel(selectedCredential.providerId) || 'service'
+ }`}
)}
{selectedCredential.type === 'env_personal' && (
@@ -1737,7 +1839,7 @@ export function CredentialsManager() {
disabled={isPromoting || deleteCredential.isPending}
>
- Promote
+ Promote to workspace
)}
{selectedCredential.type === 'oauth' &&
@@ -1763,21 +1865,27 @@ export function CredentialsManager() {
>
)}
- {isSelectedAdmin && (
-
{createModalJsx}
{oauthRequiredModalJsx}
{deleteConfirmDialogJsx}
+ {unsavedChangesAlertJsx}
>
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx
index 14c83662ab..e4e9e5d4bc 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/credentials/credentials.tsx
@@ -6,10 +6,10 @@ interface CredentialsProps {
onOpenChange?: (open: boolean) => void
}
-export function Credentials(_props: CredentialsProps) {
+export function Credentials({ onOpenChange }: CredentialsProps) {
return (
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx
index aca6d69756..7f49bfc618 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/workflow-mcp-servers/workflow-mcp-servers.tsx
@@ -484,12 +484,12 @@ function ServerDetailView({ workspaceId, serverId, onBack }: ServerDetailViewPro
{activeConfigTab === 'cursor' && (
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
index 0956f6a6d0..086385ba07 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/settings-modal.tsx
@@ -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)
}
diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts
index 4852ae49fe..c3af384351 100644
--- a/apps/sim/lib/webhooks/processor.ts
+++ b/apps/sim/lib/webhooks/processor.ts
@@ -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' })
diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts
index 7bb100a09e..9ca0ecb66e 100644
--- a/apps/sim/lib/webhooks/provider-subscriptions.ts
+++ b/apps/sim/lib/webhooks/provider-subscriptions.ts
@@ -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
()
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 || '' }
diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts
index 7d9abfcef7..8c8e15381e 100644
--- a/apps/sim/lib/webhooks/utils.server.ts
+++ b/apps/sim/lib/webhooks/utils.server.ts
@@ -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) || {}
+ 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
}
diff --git a/apps/sim/stores/modals/settings/store.ts b/apps/sim/stores/modals/settings/store.ts
index f75855271c..dedeea136d 100644
--- a/apps/sim/stores/modals/settings/store.ts
+++ b/apps/sim/stores/modals/settings/store.ts
@@ -7,6 +7,8 @@ export const useSettingsModalStore = create((set) => ({
isOpen: false,
initialSection: null,
mcpServerId: null,
+ hasUnsavedChanges: false,
+ onCloseAttempt: null,
openModal: (options) =>
set({
@@ -18,6 +20,8 @@ export const useSettingsModalStore = create((set) => ({
closeModal: () =>
set({
isOpen: false,
+ hasUnsavedChanges: false,
+ onCloseAttempt: null,
}),
clearInitialState: () =>
@@ -25,4 +29,14 @@ export const useSettingsModalStore = create((set) => ({
initialSection: null,
mcpServerId: null,
}),
+
+ setHasUnsavedChanges: (hasChanges) =>
+ set({
+ hasUnsavedChanges: hasChanges,
+ }),
+
+ setOnCloseAttempt: (callback) =>
+ set({
+ onCloseAttempt: callback,
+ }),
}))
diff --git a/apps/sim/stores/modals/settings/types.ts b/apps/sim/stores/modals/settings/types.ts
index 247e2c099e..484f968dec 100644
--- a/apps/sim/stores/modals/settings/types.ts
+++ b/apps/sim/stores/modals/settings/types.ts
@@ -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
}
diff --git a/apps/sim/triggers/attio/utils.ts b/apps/sim/triggers/attio/utils.ts
index 6c60759b42..11dfa89c30 100644
--- a/apps/sim/triggers/attio/utils.ts
+++ b/apps/sim/triggers/attio/utils.ts
@@ -290,6 +290,15 @@ export const TRIGGER_EVENT_MAP: Record = {
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): Record | undefined {
+ const events = body.events as Array> | undefined
+ return events?.[0]
+}
+
/**
* Checks if an Attio webhook payload matches a trigger.
*/
@@ -298,7 +307,8 @@ export function isAttioPayloadMatch(triggerId: string, body: Record): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ 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
+): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ 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
+): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ 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): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ 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): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ 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): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ 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): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ 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
+): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ 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): Record {
+ const event = getAttioEvent(body) ?? {}
+ const id = (event.id as Record) ?? {}
+ return {
+ eventType: event.event_type ?? null,
+ id,
+ parentObjectId: (event.parent_object_id as string) ?? null,
+ parentRecordId: (event.parent_record_id as string) ?? null,
+ }
+}