fix(ashby): add secretToken to webhook creation and fix trigger UX (#3580)

* fix(ashby): add secretToken to webhook creation and fix trigger UX

* fix(toast): restore useMemo for context value to prevent unnecessary re-renders

* fix(notifications): track exit animation timeout so pauseAll can cancel it

* fix(notifications): use isPausedRef to guard exit timeout instead of synthetic timer keys

* fix(notifications): clear pending timers when notification stack empties
This commit is contained in:
Waleed
2026-03-14 06:26:06 -07:00
committed by GitHub
parent b2d146ca0a
commit 8f15be23a0
15 changed files with 505 additions and 403 deletions
+53
View File
@@ -790,6 +790,59 @@ input[type="search"]::-ms-clear {
}
}
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
}
@keyframes notification-countdown {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 34.56;
}
}
@keyframes notification-exit {
from {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
to {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97);
}
}
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes toast-exit {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(8px) scale(0.97);
}
}
/* WandPromptBar status indicator */
@keyframes smoke-pulse {
0%,
@@ -1,6 +1,5 @@
'use client'
import { ToastProvider } from '@/components/emcn'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -9,7 +8,7 @@ import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/side
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<ToastProvider>
<>
<SettingsLoader />
<ProviderModelsLoader />
<GlobalCommandsProvider>
@@ -26,6 +25,6 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</ToastProvider>
</>
)
}
@@ -1,8 +1,10 @@
import { memo, useCallback, useEffect, useRef } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toast, useToast } from '@/components/emcn'
import { X } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
type Notification,
type NotificationAction,
@@ -12,6 +14,13 @@ import {
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Notifications')
const MAX_VISIBLE_NOTIFICATIONS = 4
const STACK_OFFSET_PX = 3
const AUTO_DISMISS_MS = 10000
const EXIT_ANIMATION_MS = 200
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const ACTION_LABELS: Record<NotificationAction['type'], string> = {
copilot: 'Fix in Copilot',
@@ -19,99 +28,120 @@ const ACTION_LABELS: Record<NotificationAction['type'], string> = {
'unlock-workflow': 'Unlock Workflow',
} as const
function executeNotificationAction(action: NotificationAction) {
switch (action.type) {
case 'copilot':
openCopilotWithMessage(action.message)
break
case 'refresh':
window.location.reload()
break
case 'unlock-workflow':
window.dispatchEvent(new CustomEvent('unlock-workflow'))
break
default:
logger.warn('Unknown action type', { actionType: action.type })
}
function isAutoDismissable(n: Notification): boolean {
return n.level === 'error' && !!n.workflowId
}
function notificationToToast(n: Notification, removeNotification: (id: string) => void) {
const toastAction = n.action
? {
label: ACTION_LABELS[n.action.type] ?? 'Take action',
onClick: () => {
executeNotificationAction(n.action!)
removeNotification(n.id)
},
}
: undefined
return {
message: n.message,
variant: n.level === 'error' ? ('error' as const) : ('default' as const),
action: toastAction,
duration: n.level === 'error' && n.workflowId ? 10_000 : 0,
}
function CountdownRing({ onPause }: { onPause: () => void }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onPause}
aria-label='Keep notifications visible'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='var(--text-icon)'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${AUTO_DISMISS_MS}ms linear forwards`,
}}
/>
</svg>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Keep visible</p>
</Tooltip.Content>
</Tooltip.Root>
)
}
/**
* Headless bridge that syncs the notification Zustand store into the toast system.
* Notifications display component.
* Positioned in the bottom-right workspace area, reactive to panel width and terminal height.
* Shows both global notifications and workflow-specific notifications.
*
* Watches for new notifications scoped to the active workflow and shows them as toasts.
* When a toast is dismissed, the corresponding notification is removed from the store.
* Workflow error notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown
* ring. Clicking the ring pauses all timers until the notification stack clears.
*/
export const Notifications = memo(function Notifications() {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
const allNotifications = useNotificationStore((state) => state.notifications)
const removeNotification = useNotificationStore((state) => state.removeNotification)
const clearNotifications = useNotificationStore((state) => state.clearNotifications)
const { dismissAll } = useToast()
const shownIdsRef = useRef(new Set<string>())
const visibleNotifications = useMemo(() => {
if (!activeWorkflowId) return []
return allNotifications
.filter((n) => !n.workflowId || n.workflowId === activeWorkflowId)
.slice(0, MAX_VISIBLE_NOTIFICATIONS)
}, [allNotifications, activeWorkflowId])
const showNotification = useCallback(
(n: Notification) => {
if (shownIdsRef.current.has(n.id)) return
shownIdsRef.current.add(n.id)
/**
* Executes a notification action and handles side effects.
*
* @param notificationId - The ID of the notification whose action is executed.
* @param action - The action configuration to execute.
*/
const executeAction = useCallback(
(notificationId: string, action: NotificationAction) => {
try {
logger.info('Executing notification action', {
notificationId,
actionType: action.type,
messageLength: action.message.length,
})
const input = notificationToToast(n, removeNotification)
toast(input)
switch (action.type) {
case 'copilot':
openCopilotWithMessage(action.message)
break
case 'refresh':
window.location.reload()
break
case 'unlock-workflow':
window.dispatchEvent(new CustomEvent('unlock-workflow'))
break
default:
logger.warn('Unknown action type', { notificationId, actionType: action.type })
}
logger.info('Notification shown as toast', {
id: n.id,
level: n.level,
workflowId: n.workflowId,
})
removeNotification(notificationId)
} catch (error) {
logger.error('Failed to execute notification action', {
notificationId,
actionType: action.type,
error,
})
}
},
[removeNotification]
)
useEffect(() => {
if (!activeWorkflowId) return
const visible = allNotifications.filter(
(n) => !n.workflowId || n.workflowId === activeWorkflowId
)
for (const n of visible) {
showNotification(n)
}
const currentIds = new Set(allNotifications.map((n) => n.id))
for (const id of shownIdsRef.current) {
if (!currentIds.has(id)) {
shownIdsRef.current.delete(id)
}
}
}, [allNotifications, activeWorkflowId, showNotification])
useRegisterGlobalCommands(() =>
createCommands([
{
id: 'clear-notifications',
handler: () => {
clearNotifications(activeWorkflowId ?? undefined)
dismissAll()
},
overrides: {
allowInEditable: false,
@@ -120,5 +150,144 @@ export const Notifications = memo(function Notifications() {
])
)
return null
const preventZoomRef = usePreventZoom()
const [isPaused, setIsPaused] = useState(false)
const isPausedRef = useRef(false)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
const pauseAll = useCallback(() => {
setIsPaused(true)
isPausedRef.current = true
setExitingIds(new Set())
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
}, [])
/**
* Manages per-notification dismiss timers.
* Resets pause state when the notification stack empties so new arrivals get fresh timers.
*/
useEffect(() => {
isPausedRef.current = isPaused
}, [isPaused])
useEffect(() => {
if (visibleNotifications.length === 0) {
if (isPaused) setIsPaused(false)
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
return
}
if (isPaused) return
const timers = timersRef.current
const activeIds = new Set<string>()
for (const n of visibleNotifications) {
if (!isAutoDismissable(n) || timers.has(n.id)) continue
activeIds.add(n.id)
timers.set(
n.id,
setTimeout(() => {
timers.delete(n.id)
setExitingIds((prev) => new Set(prev).add(n.id))
setTimeout(() => {
if (isPausedRef.current) return
removeNotification(n.id)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(n.id)
return next
})
}, EXIT_ANIMATION_MS)
}, AUTO_DISMISS_MS)
)
}
for (const [id, timer] of timers) {
if (!activeIds.has(id) && !visibleNotifications.some((n) => n.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
}, [visibleNotifications, removeNotification, isPaused])
useEffect(() => {
const timers = timersRef.current
return () => {
for (const timer of timers.values()) clearTimeout(timer)
}
}, [])
if (visibleNotifications.length === 0) {
return null
}
return (
<div ref={preventZoomRef} className='absolute right-[16px] bottom-[16px] z-30 grid'>
{[...visibleNotifications].reverse().map((notification, index, stacked) => {
const depth = stacked.length - index - 1
const xOffset = depth * STACK_OFFSET_PX
const hasAction = Boolean(notification.action)
const showCountdown = !isPaused && isAutoDismissable(notification)
return (
<div
key={notification.id}
style={
{
'--stack-offset': `${xOffset}px`,
animation: exitingIds.has(notification.id)
? `notification-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
: 'notification-enter 200ms ease-out forwards',
gridArea: '1 / 1',
} as React.CSSProperties
}
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
>
<div className='flex flex-col gap-[8px] p-[8px]'>
<div className='flex items-start gap-[8px]'>
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
{notification.level === 'error' && (
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{notification.message}
</div>
<div className='flex shrink-0 items-start gap-[2px]'>
{showCountdown && <CountdownRing onPause={pauseAll} />}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={() => removeNotification(notification.id)}
aria-label='Dismiss notification'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Shortcut keys='⌘E'>Clear all</Tooltip.Shortcut>
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
{hasAction && (
<Button
variant='active'
onClick={() => executeAction(notification.id, notification.action!)}
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
>
{ACTION_LABELS[notification.action!.type] ?? 'Take action'}
</Button>
)}
</div>
</div>
)
})}
</div>
)
})
@@ -2,8 +2,6 @@
import {
createContext,
memo,
type ReactElement,
type ReactNode,
useCallback,
useContext,
@@ -14,30 +12,11 @@ import {
} from 'react'
import { X } from 'lucide-react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/emcn/components/button/button'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { cn } from '@/lib/core/utils/cn'
const AUTO_DISMISS_MS = 10_000
const AUTO_DISMISS_MS = 0
const EXIT_ANIMATION_MS = 200
const MAX_VISIBLE = 4
const STACK_OFFSET_PX = 3
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const TOAST_KEYFRAMES = `
@keyframes toast-enter {
from { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97); }
to { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); }
}
@keyframes toast-exit {
from { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); }
to { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97); }
}
@keyframes toast-countdown {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: ${RING_CIRCUMFERENCE.toFixed(2)}; }
}`
const MAX_VISIBLE = 20
type ToastVariant = 'default' | 'success' | 'error'
@@ -49,17 +28,16 @@ interface ToastAction {
interface ToastData {
id: string
message: string
description?: string
variant: ToastVariant
icon?: ReactElement
action?: ToastAction
duration: number
createdAt: number
}
type ToastInput = {
message: string
description?: string
variant?: ToastVariant
icon?: ReactElement
action?: ToastAction
duration?: number
}
@@ -73,7 +51,6 @@ type ToastFn = {
interface ToastContextValue {
toast: ToastFn
dismiss: (id: string) => void
dismissAll: () => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
@@ -113,133 +90,81 @@ export function useToast() {
return ctx
}
function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: () => void }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onPause}
aria-label='Keep visible'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='var(--text-icon)'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `toast-countdown ${durationMs}ms linear forwards`,
}}
/>
</svg>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Keep visible</p>
</Tooltip.Content>
</Tooltip.Root>
)
const VARIANT_STYLES: Record<ToastVariant, string> = {
default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]',
success:
'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200',
error:
'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200',
}
const ToastItem = memo(function ToastItem({
data,
depth,
isExiting,
showCountdown,
onDismiss,
onPauseCountdown,
onAction,
}: {
data: ToastData
depth: number
isExiting: boolean
showCountdown: boolean
onDismiss: (id: string) => void
onPauseCountdown: () => void
onAction: (id: string) => void
}) {
const xOffset = depth * STACK_OFFSET_PX
function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) {
const [exiting, setExiting] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const dismiss = useCallback(() => {
setExiting(true)
setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS)
}, [onDismiss, t.id])
useEffect(() => {
if (t.duration > 0) {
timerRef.current = setTimeout(dismiss, t.duration)
return () => clearTimeout(timerRef.current)
}
}, [dismiss, t.duration])
return (
<div
style={
{
'--stack-offset': `${xOffset}px`,
animation: isExiting
? `toast-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
: 'toast-enter 200ms ease-out forwards',
gridArea: '1 / 1',
} as React.CSSProperties
}
className='w-[240px] self-end overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm'
className={cn(
'pointer-events-auto flex w-[320px] items-start gap-[8px] rounded-[8px] border px-[12px] py-[10px] shadow-md transition-all',
VARIANT_STYLES[t.variant],
exiting
? 'animate-[toast-exit_200ms_ease-in_forwards]'
: 'animate-[toast-enter_200ms_ease-out_forwards]'
)}
>
<div className='flex flex-col gap-[8px] p-[8px]'>
<div className='flex items-start gap-[8px]'>
{data.icon && (
<span className='flex h-[16px] shrink-0 items-center text-[var(--text-icon)]'>
{data.icon}
</span>
)}
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
{data.variant === 'error' && (
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
)}
{data.message}
</div>
<div className='flex shrink-0 items-start gap-[2px]'>
{showCountdown && (
<CountdownRing durationMs={data.duration} onPause={onPauseCountdown} />
)}
<Button
variant='ghost'
onClick={() => onDismiss(data.id)}
aria-label='Dismiss'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
>
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
</Button>
</div>
</div>
{data.action && (
<Button
variant='active'
onClick={() => onAction(data.id)}
className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
>
{data.action.label}
</Button>
<div className='min-w-0 flex-1'>
<p className='font-medium text-[13px] leading-[18px]'>{t.message}</p>
{t.description && (
<p className='mt-[2px] text-[12px] leading-[16px] opacity-80'>{t.description}</p>
)}
</div>
{t.action && (
<button
type='button'
onClick={() => {
t.action!.onClick()
dismiss()
}}
className='shrink-0 font-medium text-[13px] underline underline-offset-2 opacity-90 hover:opacity-100'
>
{t.action.label}
</button>
)}
<button
type='button'
onClick={dismiss}
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
>
<X className='h-[14px] w-[14px]' />
</button>
</div>
)
})
}
/**
* Toast container that renders toasts via portal.
* Mount once where you want toasts to appear. Renders stacked cards in the bottom-right.
* Mount once in your root layout.
*
* Visual design matches the workflow notification component: 240px cards, stacked with
* offset, countdown ring on auto-dismissing items, enter/exit animations.
* @example
* ```tsx
* <ToastProvider />
* ```
*/
export function ToastProvider({ children }: { children?: ReactNode }) {
const [toasts, setToasts] = useState<ToastData[]>([])
const [mounted, setMounted] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set())
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
useEffect(() => {
setMounted(true)
@@ -250,87 +175,17 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const data: ToastData = {
id,
message: input.message,
description: input.description,
variant: input.variant ?? 'default',
icon: input.icon,
action: input.action,
duration: input.duration ?? AUTO_DISMISS_MS,
createdAt: Date.now(),
}
setToasts((prev) => [data, ...prev].slice(0, MAX_VISIBLE))
setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE))
return id
}, [])
const dismissToast = useCallback((id: string) => {
setExitingIds((prev) => new Set(prev).add(id))
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}, EXIT_ANIMATION_MS)
}, [])
const dismissAll = useCallback(() => {
setToasts([])
setExitingIds(new Set())
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
}, [])
const pauseAll = useCallback(() => {
setIsPaused(true)
setExitingIds(new Set())
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
}, [])
const handleAction = useCallback(
(id: string) => {
const t = toasts.find((toast) => toast.id === id)
if (t?.action) {
t.action.onClick()
dismissToast(id)
}
},
[toasts, dismissToast]
)
useEffect(() => {
if (toasts.length === 0) {
if (isPaused) setIsPaused(false)
return
}
if (isPaused) return
const timers = timersRef.current
for (const t of toasts) {
if (t.duration <= 0 || timers.has(t.id)) continue
timers.set(
t.id,
setTimeout(() => {
timers.delete(t.id)
dismissToast(t.id)
}, t.duration)
)
}
for (const [id, timer] of timers) {
if (!toasts.some((t) => t.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
}, [toasts, isPaused, dismissToast])
useEffect(() => {
const timers = timersRef.current
return () => {
for (const timer of timers.values()) clearTimeout(timer)
}
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const toastFn = useRef<ToastFn>(createToastFn(addToast))
@@ -344,44 +199,24 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}, [addToast])
const ctx = useMemo<ToastContextValue>(
() => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll }),
[dismissToast, dismissAll]
() => ({ toast: toastFn.current, dismiss: dismissToast }),
[dismissToast]
)
const visibleToasts = toasts.slice(0, MAX_VISIBLE)
return (
<ToastContext.Provider value={ctx}>
{children}
{mounted &&
visibleToasts.length > 0 &&
createPortal(
<>
<style>{TOAST_KEYFRAMES}</style>
<div
aria-live='polite'
aria-label='Toasts'
className='fixed right-[16px] bottom-[16px] z-[10000400] grid'
>
{[...visibleToasts].reverse().map((t, index, stacked) => {
const depth = stacked.length - index - 1
const showCountdown = !isPaused && t.duration > 0
return (
<ToastItem
key={t.id}
data={t}
depth={depth}
isExiting={exitingIds.has(t.id)}
showCountdown={showCountdown}
onDismiss={dismissToast}
onPauseCountdown={pauseAll}
onAction={handleAction}
/>
)
})}
</div>
</>,
<div
aria-live='polite'
aria-label='Notifications'
className='pointer-events-none fixed right-[16px] bottom-[16px] z-[10000400] flex flex-col-reverse items-end gap-[8px]'
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismissToast} />
))}
</div>,
document.body
)}
</ToastContext.Provider>
@@ -44,6 +44,7 @@ const pendingWebhookVerificationRegistrationMatchers: Record<
string,
PendingWebhookVerificationRegistrationMatcher
> = {
ashby: () => true,
grain: () => true,
generic: (registration) => registration.metadata?.verifyTestEvents === true,
}
@@ -52,6 +53,7 @@ const pendingWebhookVerificationProbeMatchers: Record<
string,
PendingWebhookVerificationProbeMatcher
> = {
ashby: ({ method, body }) => method === 'POST' && body?.action === 'ping',
grain: ({ method, body }) =>
method === 'GET' ||
method === 'HEAD' ||
+24
View File
@@ -20,6 +20,7 @@ import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils'
import {
handleSlackChallenge,
handleWhatsAppVerification,
validateAshbySignature,
validateAttioSignature,
validateCalcomSignature,
validateCirclebackSignature,
@@ -555,6 +556,29 @@ export async function verifyProviderAuth(
}
}
// Ashby webhook signature verification (HMAC-SHA256 via Ashby-Signature header)
if (foundWebhook.provider === 'ashby') {
const secretToken = providerConfig.secretToken as string | undefined
if (secretToken) {
const signature = request.headers.get('ashby-signature')
if (!signature) {
logger.warn(`[${requestId}] Ashby webhook missing Ashby-Signature header`)
return new NextResponse('Unauthorized - Missing Ashby signature', {
status: 401,
})
}
if (!validateAshbySignature(secretToken, signature, rawBody)) {
logger.warn(`[${requestId}] Ashby webhook signature verification failed`)
return new NextResponse('Unauthorized - Invalid Ashby signature', {
status: 401,
})
}
}
}
// Provider-specific verification (utils may return a response for some providers)
const providerVerification = verifyProviderWebhook(foundWebhook, request, requestId)
if (providerVerification) {
@@ -2060,7 +2060,11 @@ export async function createExternalWebhookSubscription(
if (provider === 'ashby') {
const result = await createAshbyWebhookSubscription(webhookData, requestId)
if (result) {
updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id }
updatedProviderConfig = {
...updatedProviderConfig,
externalId: result.id,
secretToken: result.secretToken,
}
externalSubscriptionCreated = true
}
} else if (provider === 'airtable') {
@@ -2175,7 +2179,7 @@ export async function cleanupExternalWebhook(
export async function createAshbyWebhookSubscription(
webhookData: any,
requestId: string
): Promise<{ id: string } | undefined> {
): Promise<{ id: string; secretToken: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, triggerId } = providerConfig || {}
@@ -2213,9 +2217,12 @@ export async function createAshbyWebhookSubscription(
webhookId: webhookData.id,
})
const secretToken = crypto.randomUUID()
const requestBody: Record<string, unknown> = {
requestUrl: notificationUrl,
webhookType,
secretToken,
}
const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', {
@@ -2255,7 +2262,7 @@ export async function createAshbyWebhookSubscription(
ashbyLogger.info(
`[${requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${webhookData.id}`
)
return { id: externalId }
return { id: externalId, secretToken }
} catch (error: any) {
ashbyLogger.error(
`[${requestId}] Exception during Ashby webhook creation for webhook ${webhookData.id}.`,
+32
View File
@@ -1614,6 +1614,38 @@ export function validateFirefliesSignature(
}
}
/**
* Validates an Ashby webhook signature using HMAC-SHA256.
* Ashby signs payloads with the secretToken and sends the digest in the Ashby-Signature header.
* @param secretToken - The secret token configured when creating the webhook
* @param signature - Ashby-Signature header value (format: 'sha256=<hex>')
* @param body - Raw request body string
* @returns Whether the signature is valid
*/
export function validateAshbySignature(
secretToken: string,
signature: string,
body: string
): boolean {
try {
if (!secretToken || !signature || !body) {
return false
}
if (!signature.startsWith('sha256=')) {
return false
}
const providedSignature = signature.substring(7)
const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex')
return safeCompare(computedHash, providedSignature)
} catch (error) {
logger.error('Error validating Ashby signature:', error)
return false
}
}
/**
* Validates a GitHub webhook request signature using HMAC SHA-256 or SHA-1
* @param secret - GitHub webhook secret (plain text)
+3 -11
View File
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
ashbySetupInstructions,
ashbyTriggerOptions,
buildApplicationSubmitOutputs,
buildAshbyExtraFields,
} from '@/triggers/ashby/utils'
import { buildApplicationSubmitOutputs, buildAshbySubBlocks } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -22,12 +16,10 @@ export const ashbyApplicationSubmitTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
subBlocks: buildTriggerSubBlocks({
subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_application_submit',
triggerOptions: ashbyTriggerOptions,
eventType: 'Application Submitted',
includeDropdown: true,
setupInstructions: ashbySetupInstructions('Application Submitted'),
extraFields: buildAshbyExtraFields('ashby_application_submit'),
}),
outputs: buildApplicationSubmitOutputs(),
+3 -11
View File
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
ashbySetupInstructions,
ashbyTriggerOptions,
buildAshbyExtraFields,
buildCandidateDeleteOutputs,
} from '@/triggers/ashby/utils'
import { buildAshbySubBlocks, buildCandidateDeleteOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -21,11 +15,9 @@ export const ashbyCandidateDeleteTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
subBlocks: buildTriggerSubBlocks({
subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_candidate_delete',
triggerOptions: ashbyTriggerOptions,
setupInstructions: ashbySetupInstructions('Candidate Deleted'),
extraFields: buildAshbyExtraFields('ashby_candidate_delete'),
eventType: 'Candidate Deleted',
}),
outputs: buildCandidateDeleteOutputs(),
+3 -11
View File
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
ashbySetupInstructions,
ashbyTriggerOptions,
buildAshbyExtraFields,
buildCandidateHireOutputs,
} from '@/triggers/ashby/utils'
import { buildAshbySubBlocks, buildCandidateHireOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -22,11 +16,9 @@ export const ashbyCandidateHireTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
subBlocks: buildTriggerSubBlocks({
subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_candidate_hire',
triggerOptions: ashbyTriggerOptions,
setupInstructions: ashbySetupInstructions('Candidate Hired'),
extraFields: buildAshbyExtraFields('ashby_candidate_hire'),
eventType: 'Candidate Hired',
}),
outputs: buildCandidateHireOutputs(),
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
ashbySetupInstructions,
ashbyTriggerOptions,
buildAshbyExtraFields,
buildCandidateStageChangeOutputs,
} from '@/triggers/ashby/utils'
import { buildAshbySubBlocks, buildCandidateStageChangeOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -22,11 +16,9 @@ export const ashbyCandidateStageChangeTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
subBlocks: buildTriggerSubBlocks({
subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_candidate_stage_change',
triggerOptions: ashbyTriggerOptions,
setupInstructions: ashbySetupInstructions('Candidate Stage Change'),
extraFields: buildAshbyExtraFields('ashby_candidate_stage_change'),
eventType: 'Candidate Stage Change',
}),
outputs: buildCandidateStageChangeOutputs(),
+3 -11
View File
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
ashbySetupInstructions,
ashbyTriggerOptions,
buildAshbyExtraFields,
buildJobCreateOutputs,
} from '@/triggers/ashby/utils'
import { buildAshbySubBlocks, buildJobCreateOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -21,11 +15,9 @@ export const ashbyJobCreateTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
subBlocks: buildTriggerSubBlocks({
subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_job_create',
triggerOptions: ashbyTriggerOptions,
setupInstructions: ashbySetupInstructions('Job Created'),
extraFields: buildAshbyExtraFields('ashby_job_create'),
eventType: 'Job Created',
}),
outputs: buildJobCreateOutputs(),
+3 -11
View File
@@ -1,11 +1,5 @@
import { AshbyIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
ashbySetupInstructions,
ashbyTriggerOptions,
buildAshbyExtraFields,
buildOfferCreateOutputs,
} from '@/triggers/ashby/utils'
import { buildAshbySubBlocks, buildOfferCreateOutputs } from '@/triggers/ashby/utils'
import type { TriggerConfig } from '@/triggers/types'
/**
@@ -21,11 +15,9 @@ export const ashbyOfferCreateTrigger: TriggerConfig = {
version: '1.0.0',
icon: AshbyIcon,
subBlocks: buildTriggerSubBlocks({
subBlocks: buildAshbySubBlocks({
triggerId: 'ashby_offer_create',
triggerOptions: ashbyTriggerOptions,
setupInstructions: ashbySetupInstructions('Offer Created'),
extraFields: buildAshbyExtraFields('ashby_offer_create'),
eventType: 'Offer Created',
}),
outputs: buildOfferCreateOutputs(),
+49 -20
View File
@@ -19,10 +19,9 @@ export const ashbyTriggerOptions = [
*/
export function ashbySetupInstructions(eventType: string): string {
const instructions = [
'Enter your Ashby API Key above.',
'You can find your API key in Ashby at <strong>Settings > API Keys</strong>. The key must have the <strong>apiKeysWrite</strong> permission.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Ashby for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
'Enter your Ashby API Key above. You can find your API key in Ashby at <strong>Settings &gt; API Keys</strong>.',
`The webhook for <strong>${eventType}</strong> events will be automatically created in Ashby when you save the trigger.`,
'The webhook will be automatically deleted if you remove this trigger.',
]
return instructions
@@ -34,24 +33,54 @@ export function ashbySetupInstructions(eventType: string): string {
}
/**
* Ashby-specific extra fields for triggers.
* Includes API key (required for automatic webhook creation).
* Builds the complete subBlocks array for an Ashby trigger.
* Ashby webhooks are managed via API, so no webhook URL is displayed.
*
* Structure: [dropdown?] -> apiKey -> instructions
*/
export function buildAshbyExtraFields(triggerId: string): SubBlockConfig[] {
return [
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Ashby API key',
description: 'Required to create the webhook in Ashby. Must have apiKeysWrite permission.',
password: true,
required: true,
paramVisibility: 'user-only',
export function buildAshbySubBlocks(options: {
triggerId: string
eventType: string
includeDropdown?: boolean
}): SubBlockConfig[] {
const { triggerId, eventType, includeDropdown = false } = options
const blocks: SubBlockConfig[] = []
if (includeDropdown) {
blocks.push({
id: 'selectedTriggerId',
title: 'Trigger Type',
type: 'dropdown',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
options: ashbyTriggerOptions,
value: () => triggerId,
required: true,
})
}
blocks.push({
id: 'apiKey',
title: 'API Key',
type: 'short-input',
placeholder: 'Enter your Ashby API key',
password: true,
required: true,
paramVisibility: 'user-only',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
})
blocks.push({
id: 'triggerInstructions',
title: 'Setup Instructions',
hideFromPreview: true,
type: 'text',
defaultValue: ashbySetupInstructions(eventType),
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
})
return blocks
}
/**