chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,33 @@
'use client'
import { ChipLink } from '@sim/emcn'
import { ArrowLeft } from 'lucide-react'
interface IntegrationBlockDetailFallbackProps {
workspaceId: string
}
/**
* Suspense fallback for the integration detail page — the back-link chrome
* shown while {@link IntegrationBlockDetail} hydrates.
*
* This MUST be a client component. The lucide `ArrowLeft` passed as `ChipLink`'s
* `leftIcon` is a function, and functions cannot cross the server→client
* boundary as props. Rendering the fallback from the server `page.tsx` directly
* threw a React Server Components error ("Functions cannot be passed directly to
* Client Components") that surfaced as the integrations error boundary. Keeping
* the icon inside a client component avoids the boundary crossing entirely.
*/
export function IntegrationBlockDetailFallback({
workspaceId,
}: IntegrationBlockDetailFallbackProps) {
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
Integrations
</ChipLink>
</div>
</div>
)
}
@@ -0,0 +1,367 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Chip, ChipDropdown, ChipLink, cn } from '@sim/emcn'
import { ArrowLeft, ArrowRight, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQueryState } from 'nuqs'
import {
blockTypeToIconMap,
type Integration,
resolveOAuthServiceForIntegration,
} from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section'
import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params'
import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import { getBlock } from '@/blocks'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
import {
getSuggestedSkillsForBlock,
getTemplatesForBlock,
type ScopedBlockTemplate,
} from '@/blocks/registry'
import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
/** Maximum number of overlapping icon tiles rendered per template row. */
const TEMPLATE_CLUSTER_MAX = 3 as const
/**
* Z-index per cluster position so the primary tile reads on top and trailing
* tiles cascade behind it.
*/
const TEMPLATE_TILE_Z = ['z-30', 'z-20', 'z-10'] as const
interface IntegrationBlockDetailProps {
integration: Integration
workspaceId: string
}
export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null)
useOAuthReturnRouter()
const router = useRouter()
const [connectMode, setConnectMode] = useQueryState(connectParam.key, connectParam.parser)
const Icon = blockTypeToIconMap[integration.type]
const matchingTemplates = getTemplatesForBlock(integration.type)
const suggestedSkills = getSuggestedSkillsForBlock(integration.type)
const oauthService = resolveOAuthServiceForIntegration(integration)
const [oauthOpen, setOAuthOpen] = useState(false)
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
})
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
const connectedCredentials = useMemo(() => {
if (!oauthService) return []
return credentials.filter(
(c) =>
(c.type === 'oauth' || c.type === 'service_account') &&
c.providerId &&
getServiceConfigByProviderId(c.providerId)?.providerId === oauthService.providerId
)
}, [credentials, oauthService])
const [serviceAccountOpen, setServiceAccountOpen] = useState(false)
const isSlackBot = oauthService?.serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID
const blockOverlayVersion = useCustomBlockOverlayVersion()
// Custom Slack bots ride the slack_v2 preview flag: the setup surface stays
// hidden until that block is revealed for this viewer.
const slackBotPreviewHidden = useMemo(() => {
if (!isSlackBot) return false
const v2 = getBlock('slack_v2')
return !v2 || isHiddenUnder(overlayVisibility(), v2)
}, [isSlackBot, blockOverlayVersion])
const hasServiceAccount =
Boolean(oauthService?.serviceAccountProviderId) && !slackBotPreviewHidden
const hasHandledConnectQueryRef = useRef(false)
useEffect(() => {
if (hasHandledConnectQueryRef.current) return
if (!connectMode) return
let handled = false
if (connectMode === CONNECT_MODE.oauth && oauthService) {
setOAuthOpen(true)
handled = true
} else if (connectMode === CONNECT_MODE.serviceAccount && hasServiceAccount) {
setServiceAccountOpen(true)
handled = true
}
if (!handled) return
hasHandledConnectQueryRef.current = true
void setConnectMode(null, { history: 'replace', scroll: false })
}, [connectMode, oauthService, hasServiceAccount, setConnectMode])
const connectOptions = oauthService
? [
{
value: CONNECT_MODE.oauth,
label: 'Connect with OAuth',
icon: oauthService.serviceIcon,
},
{
value: CONNECT_MODE.serviceAccount,
label: isSlackBot ? 'Set up a custom bot' : 'Add service account',
icon: oauthService.serviceIcon,
},
]
: []
const handleSelectConnectOption = (value: string) => {
if (value === CONNECT_MODE.oauth) setOAuthOpen(true)
else if (value === CONNECT_MODE.serviceAccount) setServiceAccountOpen(true)
}
const handleAddInChat = () => {
storeCuratedPrompt(`Explore ${integration.name}. What can I do?`)
router.push(`/workspace/${workspaceId}/home`)
}
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<ChipLink href={`/workspace/${workspaceId}/integrations`} leftIcon={ArrowLeft}>
Integrations
</ChipLink>
<div className='ml-auto flex items-center'>
{oauthService ? (
hasServiceAccount ? (
<ChipDropdown
variant='primary'
leftIcon={Plus}
placeholder='Add to Sim'
showSelectedCheck={false}
options={connectOptions}
onChange={handleSelectConnectOption}
matchTriggerWidth={false}
/>
) : (
<Chip variant='primary' leftIcon={Plus} onClick={() => setOAuthOpen(true)}>
Add to Sim
</Chip>
)
) : (
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
Add to Sim
</Chip>
)}
</div>
</div>
{oauthService && (
<ConnectOAuthModal
mode='connect'
origin='integrations'
open={oauthOpen}
onOpenChange={setOAuthOpen}
workspaceId={workspaceId}
providerId={oauthService.providerId}
requiredScopes={oauthService.requiredScopes}
serviceName={oauthService.serviceName}
serviceIcon={oauthService.serviceIcon}
/>
)}
{hasServiceAccount && oauthService?.serviceAccountProviderId && (
<ConnectServiceAccountModal
open={serviceAccountOpen}
onOpenChange={setServiceAccountOpen}
workspaceId={workspaceId}
serviceAccountProviderId={oauthService.serviceAccountProviderId}
serviceName={oauthService.serviceName}
serviceIcon={oauthService.serviceIcon}
/>
)}
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<div className='flex flex-col gap-3'>
{Icon ? (
<IntegrationTile blockType={integration.type} icon={Icon} />
) : (
<div
className={cn(
'flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)]',
getTileIconColorClass(integration.bgColor)
)}
style={{ background: integration.bgColor }}
>
{integration.name.charAt(0)}
</div>
)}
<div className='flex flex-col gap-1'>
<h1 className='font-medium text-[var(--text-body)] text-lg'>{integration.name}</h1>
<p className='text-[var(--text-muted)] text-md'>{integration.description}</p>
</div>
</div>
{connectedCredentials.length > 0 && (
<IntegrationSection label='Connected'>
{connectedCredentials.map((credential) => (
<Link
key={credential.id}
href={`/workspace/${workspaceId}/integrations/connected/${credential.id}`}
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
{Icon && <IntegrationTile blockType={integration.type} icon={Icon} />}
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>
{credential.displayName}
</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>
{credential.description || oauthService?.serviceName}
</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</Link>
))}
</IntegrationSection>
)}
{suggestedSkills.length > 0 && (
<IntegrationSkillsSection
skills={suggestedSkills}
workspaceId={workspaceId}
integrationType={integration.type}
/>
)}
{matchingTemplates.length > 0 && (
<TemplatesSection
integration={integration}
templates={matchingTemplates}
workspaceId={workspaceId}
/>
)}
</div>
</div>
</div>
)
}
interface TemplatesSectionProps {
integration: Integration
templates: readonly ScopedBlockTemplate[]
workspaceId: string
}
function TemplatesSection({ integration, templates, workspaceId }: TemplatesSectionProps) {
const router = useRouter()
const handleSelect = (prompt: string) => {
storeCuratedPrompt(prompt)
router.push(`/workspace/${workspaceId}/home`)
}
return (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Templates</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className='-mx-2 flex flex-col gap-y-0.5'>
{templates.map((template) => {
const blockTypes = [integration.type, ...template.otherBlockTypes].slice(
0,
TEMPLATE_CLUSTER_MAX
)
return (
<TemplateRow
key={template.title}
blockTypes={blockTypes}
title={template.title}
prompt={template.prompt}
onSelect={handleSelect}
/>
)
})}
</div>
</section>
)
}
interface TemplateRowProps {
blockTypes: string[]
title: string
prompt: string
onSelect: (prompt: string) => void
}
/**
* Template row that mirrors `IntegrationItem` from the integrations index
* byte-for-byte (icon cluster · title · description · trailing `ArrowRight`).
* Renders as a `<button>` because click seeds the home page chat with `prompt`
* and navigates to the workspace home, matching the `ShowcaseWithExplore` flow.
*/
function TemplateRow({ blockTypes, title, prompt, onSelect }: TemplateRowProps) {
return (
<button
type='button'
onClick={() => onSelect(prompt)}
className='group flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
>
<TemplateIcons blockTypes={blockTypes} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{title}</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>{prompt}</span>
</div>
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />
</button>
)
}
interface TemplateIconsProps {
blockTypes: string[]
}
/**
* Horizontal overlapping icon cluster. Primary integration (idx === 0) sits
* left and on top, rendered identically to a bare `IntegrationTile` so it
* matches `IntegrationItem` outside the templates list with no halo. Trailing
* tiles cascade behind with negative margin and carry a non-layout-affecting
* outline whose color tracks the row background exactly — `--bg` at rest and
* `--surface-active` on row hover via the parent `group`. The outline is
* visually invisible in both states yet cleanly cuts each silhouette from the
* tile behind it. `outline` is preferred over `ring` here because it has no
* `box-shadow` specificity collisions and lets us transition just the
* `outline-color` token rather than the full shadow stack.
*/
function TemplateIcons({ blockTypes }: TemplateIconsProps) {
return (
<span aria-hidden className='flex items-center'>
{blockTypes.map((bt, idx) => {
const ToolIcon = blockTypeToIconMap[bt]
if (!ToolIcon) return null
const z = TEMPLATE_TILE_Z[idx]
if (!z) return null
const isTrailing = idx > 0
return (
<span
key={bt}
className={cn(
'[&:not(:first-child)]:-ml-2 relative rounded-xl first:ml-0',
z,
isTrailing &&
'outline outline-2 outline-[var(--bg)] transition-[outline-color] duration-150 group-hover:outline-[var(--surface-active)]'
)}
>
<IntegrationTile blockType={bt} icon={ToolIcon} />
</span>
)
})}
</span>
)
}
@@ -0,0 +1,110 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import { Chip, toast } from '@sim/emcn'
import { Check, Plus } from 'lucide-react'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
import type { SuggestedSkill } from '@/blocks/types'
import { useCreateSkill, useSkills } from '@/hooks/queries/skills'
interface IntegrationSkillsSectionProps {
skills: readonly SuggestedSkill[]
workspaceId: string
integrationType: string
}
interface SkillRowProps {
skill: SuggestedSkill
added: boolean
pending: boolean
disabled: boolean
onAdd: () => void
}
function SkillRow({ skill, added, pending, disabled, onAdd }: SkillRowProps) {
return (
<div className='flex items-center gap-2.5 rounded-lg p-2'>
<SkillTile />
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate text-[14px] text-[var(--text-body)]'>{skill.name}</span>
<span className='truncate text-[12px] text-[var(--text-muted)]'>{skill.description}</span>
</div>
{added ? (
<Chip leftIcon={Check} disabled flush>
Added
</Chip>
) : (
<Chip variant='primary' leftIcon={Plus} onClick={onAdd} disabled={disabled} flush>
{pending ? 'Adding...' : 'Add'}
</Chip>
)}
</div>
)
}
/**
* Curated, research-backed skills for an integration. Each row adds the skill
* to the workspace via the same `useCreateSkill` mutation the Skills page uses;
* `useSkills` is the single source of truth for the "Added" state, so a skill
* removed elsewhere correctly reverts to "Add".
*/
export function IntegrationSkillsSection({
skills,
workspaceId,
integrationType,
}: IntegrationSkillsSectionProps) {
const posthog = usePostHog()
const { data: existingSkills = [], isPending, isPlaceholderData } = useSkills(workspaceId)
const createSkill = useCreateSkill()
const skillsReady = !isPending && !isPlaceholderData
const [pendingNames, setPendingNames] = useState<ReadonlySet<string>>(new Set())
const inFlightRef = useRef<Set<string>>(new Set())
const existingNames = useMemo(() => new Set(existingSkills.map((s) => s.name)), [existingSkills])
const handleAdd = async (skill: SuggestedSkill, position: number) => {
if (inFlightRef.current.has(skill.name)) return
inFlightRef.current.add(skill.name)
setPendingNames((prev) => new Set(prev).add(skill.name))
try {
await createSkill.mutateAsync({ workspaceId, skill })
captureEvent(posthog, 'integration_skill_added', {
workspace_id: workspaceId,
integration_type: integrationType,
skill_name: skill.name,
position,
skill_count: skills.length,
})
} catch {
toast.error(`Failed to add "${skill.name}" — please try again`)
} finally {
inFlightRef.current.delete(skill.name)
setPendingNames((prev) => {
const next = new Set(prev)
next.delete(skill.name)
return next
})
}
}
return (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>Skills</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className='-mx-2 flex flex-col gap-y-0.5'>
{skills.map((skill, index) => (
<SkillRow
key={skill.name}
skill={skill}
added={skillsReady && existingNames.has(skill.name)}
pending={pendingNames.has(skill.name)}
disabled={pendingNames.has(skill.name) || !skillsReady}
onAdd={() => handleAdd(skill, index)}
/>
))}
</div>
</section>
)
}
@@ -0,0 +1,34 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { INTEGRATIONS } from '@/lib/integrations'
import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail'
import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback'
export async function generateMetadata({
params,
}: {
params: Promise<{ block: string }>
}): Promise<Metadata> {
const { block } = await params
const integration = INTEGRATIONS.find((i) => i.slug === block)
return {
title: integration ? `${integration.name} Integration` : 'Integration',
}
}
export default async function IntegrationBlockPage({
params,
}: {
params: Promise<{ workspaceId: string; block: string }>
}) {
const { workspaceId, block } = await params
const integration = INTEGRATIONS.find((i) => i.slug === block)
if (!integration) notFound()
return (
<Suspense fallback={<IntegrationBlockDetailFallback workspaceId={workspaceId} />}>
<IntegrationBlockDetail integration={integration} workspaceId={workspaceId} />
</Suspense>
)
}
@@ -0,0 +1,17 @@
import { parseAsStringLiteral } from 'nuqs/server'
import {
CONNECT_MODE,
CONNECT_QUERY_PARAM,
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
const CONNECT_MODE_VALUES = [CONNECT_MODE.oauth, CONNECT_MODE.serviceAccount] as const
/**
* Typed parser for the ephemeral `?connect=oauth|service-account` deep-link on
* the integration detail page. The param is read once to pre-open the matching
* connect modal, then stripped from the URL.
*/
export const connectParam = {
key: CONNECT_QUERY_PARAM,
parser: parseAsStringLiteral(CONNECT_MODE_VALUES),
} as const
@@ -0,0 +1,517 @@
'use client'
import { type ComponentType, useEffect, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
SecretInput,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isApiClientError } from '@/lib/api/client/errors'
import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
import {
useCreateWorkspaceCredential,
useUpdateWorkspaceCredential,
} from '@/hooks/queries/credentials'
const logger = createLogger('ConnectServiceAccountModal')
const GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID = 'google-service-account' as const
export type ServiceAccountProviderId =
| typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
| typeof SLACK_CUSTOM_BOT_PROVIDER_ID
/** Sim setup guides for each provider, docked bottom-left of each modal. */
const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account'
const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL =
'https://docs.sim.ai/integrations/atlassian-service-account'
function openDocs(url: string): void {
window.open(url, '_blank', 'noopener,noreferrer')
}
/**
* Atlassian site domain hint — surfaced inline when the user types something
* that doesn't look like `<tenant>.atlassian.net`.
*/
const ATLASSIAN_DOMAIN_HINT_REGEX = /^[a-z0-9-]+\.atlassian\.net$/i
/**
* Maps server `error.code` values returned by the Atlassian service-account
* route to user-facing messages. Falls back to {@link FALLBACK_ERROR_MESSAGE}
* when the error is unrecognized.
*/
const ATLASSIAN_ERROR_MESSAGES: Record<string, string> = {
invalid_credentials:
"We couldn't authenticate with that API token. Double-check the token and that the service account has access to this site.",
site_not_found:
"We couldn't find an Atlassian site at that domain. Check the spelling — it should look like your-team.atlassian.net.",
duplicate_display_name: 'A credential with that name already exists in this workspace.',
atlassian_unavailable:
"We couldn't reach Atlassian to verify these credentials. Try again in a moment.",
}
const FALLBACK_ERROR_MESSAGE = "We couldn't add this service account. Try again in a moment."
function normalizeAtlassianDomain(raw: string): string {
return raw
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/+$/, '')
}
function messageForAtlassianError(err: unknown): string {
if (isApiClientError(err) && err.code && ATLASSIAN_ERROR_MESSAGES[err.code]) {
return ATLASSIAN_ERROR_MESSAGES[err.code]
}
return FALLBACK_ERROR_MESSAGE
}
interface ConnectServiceAccountModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
serviceAccountProviderId: ServiceAccountProviderId
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
/**
* When set, the modal reconnects (rotates secrets on) this existing credential
* in place instead of creating a new one. The id is preserved, so shares and
* (for Slack) the ingest URL stay valid.
*/
credentialId?: string
/** Existing display name, used to seed reconnect-capable modals. */
credentialDisplayName?: string
/** Existing description, used to seed reconnect-capable modals. */
credentialDescription?: string
}
/**
* Connect-service-account modal mounted from the per-integration detail page.
* Self-contained: takes the resolved SA provider + service metadata from the
* caller and submits via `useCreateWorkspaceCredential`. Branches the body
* based on `serviceAccountProviderId`:
*
* - `google-service-account`: JSON-paste + drag/drop. Validated client-side
* against {@link serviceAccountJsonSchema} before submitting.
* - `atlassian-service-account`: API token + site domain. Validated by the
* server against the Atlassian API; user-facing errors are mapped from the
* route's `error.code`.
*/
export function ConnectServiceAccountModal({
open,
onOpenChange,
workspaceId,
serviceAccountProviderId,
serviceName,
serviceIcon,
credentialId,
credentialDisplayName,
credentialDescription,
}: ConnectServiceAccountModalProps) {
if (serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
return (
<ConnectSlackBotModal
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
/>
)
}
if (serviceAccountProviderId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
return (
<AtlassianServiceAccountModal
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
serviceName={serviceName}
serviceIcon={serviceIcon}
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
/>
)
}
return (
<GoogleServiceAccountModal
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
serviceName={serviceName}
serviceIcon={serviceIcon}
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
/>
)
}
interface ProviderModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
/** When set, reconnect (rotate secrets on) this credential in place. */
credentialId?: string
/** Existing name/description, seeded into the fields on reconnect. */
initialDisplayName?: string
initialDescription?: string
}
/**
* Google service-account flow. Accepts the raw JSON key (paste or drag/drop)
* and validates against the shared `serviceAccountJsonSchema` so the same
* shape errors render here as in the server route.
*/
function GoogleServiceAccountModal({
open,
onOpenChange,
workspaceId,
serviceName,
serviceIcon: ServiceIcon,
credentialId,
initialDisplayName,
initialDescription,
}: ProviderModalProps) {
const [jsonInput, setJsonInput] = useState('')
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null)
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState<string | null>(null)
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
useEffect(() => {
if (open) return
setJsonInput('')
setUploadedFileName(null)
setDisplayName(initialDisplayName ?? '')
setDescription(initialDescription ?? '')
setError(null)
}, [open, initialDisplayName, initialDescription])
/**
* Try to auto-populate display name from the JSON `client_email`. Silent on
* parse failure — the explicit submit-time validation surfaces invalid JSON
* via the inline error state.
*/
const maybeFillDisplayNameFromJson = (text: string) => {
if (displayName.trim()) return
try {
const parsed = JSON.parse(text) as { client_email?: unknown }
if (typeof parsed.client_email === 'string') setDisplayName(parsed.client_email)
} catch {
// surface validation on submit instead
}
}
const readJsonFile = (file: File) => {
if (!file.name.endsWith('.json')) {
setError('Only .json files are supported')
return
}
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result
if (typeof text !== 'string') return
setJsonInput(text)
setUploadedFileName(file.name)
setError(null)
maybeFillDisplayNameFromJson(text)
}
reader.readAsText(file)
}
const handleFileUpload = (files: File[]) => {
const file = files[0]
if (file) readJsonFile(file)
}
const handleSubmit = async () => {
setError(null)
const trimmed = jsonInput.trim()
if (!trimmed) {
setError('Paste the service account JSON key.')
return
}
const validation = serviceAccountJsonSchema.safeParse(trimmed)
if (!validation.success) {
setError(validation.error.issues[0]?.message ?? 'Invalid JSON')
return
}
try {
if (credentialId) {
await updateCredential.mutateAsync({
credentialId,
serviceAccountJson: trimmed,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
} else {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
serviceAccountJson: trimmed,
})
}
onOpenChange(false)
} catch (err: unknown) {
const message = getErrorMessage(err, 'Failed to add service account')
setError(message)
logger.error('Failed to add Google service account credential', err)
}
}
const isPending = createCredential.isPending || updateCredential.isPending
const isDisabled = !jsonInput.trim() || isPending
return (
<ChipModal
open={open}
onOpenChange={onOpenChange}
srTitle={`Add ${serviceName} service account`}
>
<ChipModalHeader icon={ServiceIcon} onClose={() => onOpenChange(false)}>
Add {serviceName} service account
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='textarea'
title='JSON key'
value={jsonInput}
onChange={(value) => {
setJsonInput(value)
if (uploadedFileName) setUploadedFileName(null)
if (error) setError(null)
maybeFillDisplayNameFromJson(value)
}}
placeholder='Paste your service account JSON key here'
minHeight={120}
required
/>
<ChipModalField
type='file'
title='Or upload a file'
accept='.json'
label={
uploadedFileName
? `Uploaded ${uploadedFileName} — click or drop to replace`
: 'Drag & drop a .json file, or click to browse'
}
onChange={handleFileUpload}
/>
<ChipModalField
type='input'
title='Display name'
value={displayName}
onChange={setDisplayName}
placeholder='Auto-populated from client_email'
autoComplete='off'
/>
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='Optional description'
maxLength={500}
minHeight={80}
/>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
label: 'Setup guide',
onClick: () => openDocs(GOOGLE_SERVICE_ACCOUNT_DOCS_URL),
},
]}
primaryAction={{
label: isPending ? 'Adding...' : 'Add service account',
onClick: handleSubmit,
disabled: isDisabled,
}}
/>
</ChipModal>
)
}
/**
* Atlassian service-account flow. Accepts an API token + site domain and
* validates server-side against the Atlassian API. Maps the route's
* `error.code` to descriptive copy so users know whether the token, domain,
* or upstream availability is at fault.
*/
function AtlassianServiceAccountModal({
open,
onOpenChange,
workspaceId,
serviceName,
serviceIcon: ServiceIcon,
credentialId,
initialDisplayName,
initialDescription,
}: ProviderModalProps) {
const [apiToken, setApiToken] = useState('')
const [domain, setDomain] = useState('')
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState<string | null>(null)
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
useEffect(() => {
if (open) return
setApiToken('')
setDomain('')
setDisplayName(initialDisplayName ?? '')
setDescription(initialDescription ?? '')
setError(null)
}, [open, initialDisplayName, initialDescription])
const trimmedToken = apiToken.trim()
const normalizedDomain = normalizeAtlassianDomain(domain)
const showDomainHint =
normalizedDomain.length > 0 && !ATLASSIAN_DOMAIN_HINT_REGEX.test(normalizedDomain)
const isPending = createCredential.isPending || updateCredential.isPending
const isDisabled = !trimmedToken || !normalizedDomain || isPending
const handleSubmit = async () => {
setError(null)
if (isDisabled) return
try {
if (credentialId) {
await updateCredential.mutateAsync({
credentialId,
apiToken: trimmedToken,
domain: normalizedDomain,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
} else {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
apiToken: trimmedToken,
domain: normalizedDomain,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
}
onOpenChange(false)
} catch (err: unknown) {
setError(messageForAtlassianError(err))
logger.error('Failed to add Atlassian service account credential', err)
}
}
return (
<ChipModal
open={open}
onOpenChange={onOpenChange}
srTitle={`Add ${serviceName} service account`}
>
<ChipModalHeader icon={ServiceIcon} onClose={() => onOpenChange(false)}>
Add {serviceName} service account
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='custom' title='API token' required>
<SecretInput
value={apiToken}
onChange={(value) => {
setApiToken(value)
if (error) setError(null)
}}
placeholder='Paste API token'
name='atlassian_service_account_api_token'
autoComplete='new-password'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</ChipModalField>
<ChipModalField
type='input'
title='Site domain'
value={domain}
onChange={(value) => {
setDomain(value)
if (error) setError(null)
}}
placeholder='your-team.atlassian.net'
autoComplete='off'
required
error={
showDomainHint
? 'Atlassian sites usually look like your-team.atlassian.net.'
: undefined
}
/>
<ChipModalField
type='input'
title='Display name'
value={displayName}
onChange={setDisplayName}
placeholder="Defaults to the account's Atlassian display name"
autoComplete='off'
/>
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='Optional description'
maxLength={500}
minHeight={80}
/>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
label: 'Setup guide',
onClick: () => openDocs(ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL),
},
]}
primaryAction={{
label: isPending ? 'Adding...' : 'Add service account',
onClick: handleSubmit,
disabled: isDisabled,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,4 @@
export {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
@@ -0,0 +1,463 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
ChipDropdown,
type ChipDropdownOption,
ChipInput,
Code,
CopyCodeButton,
Label,
SecretInput,
Wizard,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { Loader2 } from 'lucide-react'
import { SlackIcon } from '@/components/icons'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import {
useCreateWorkspaceCredential,
useUpdateWorkspaceCredential,
} from '@/hooks/queries/credentials'
import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities'
const logger = createLogger('ConnectSlackBotModal')
const DEFAULT_APP_NAME = 'Sim Bot'
const DONE_STEP = 4
/** Every capability is granted by default; trimming is an opt-in dropdown. */
const ALL_CAPABILITIES = new Set(SLACK_CAPABILITIES.map((c) => c.id))
const CAPABILITY_OPTIONS: ChipDropdownOption[] = SLACK_CAPABILITIES.map((c) => ({
value: c.id,
label: c.label,
}))
interface ConnectSlackBotModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
/**
* When set, the modal reconnects (rotates secrets on) this existing credential
* instead of creating a new one — the id is reused so the Slack ingest URL
* `/api/webhooks/slack/custom/{id}` stays valid, and saving updates the
* credential in place.
*/
credentialId?: string
/** Existing display name, seeded into the bot-name field on reconnect. */
initialDisplayName?: string
/** Existing description, seeded into the description field on reconnect. */
initialDescription?: string
/** Called with the credential id after a successful create or reconnect. */
onCreated?: (credentialId: string) => void
}
/**
* One-time setup for a reusable custom Slack bot credential — the same guided
* wizard as the legacy in-block setup, but it persists a workspace credential
* instead of writing sub-block values. The credential id is pre-generated so the
* ingest URL `/api/webhooks/slack/custom/{id}` (and the manifest that embeds it)
* can be shown up front; the credential is created on the final step once the
* signing secret + bot token are pasted.
*/
export function ConnectSlackBotModal({
open,
onOpenChange,
workspaceId,
credentialId: reconnectCredentialId,
initialDisplayName,
initialDescription,
onCreated,
}: ConnectSlackBotModalProps) {
const isReconnect = Boolean(reconnectCredentialId)
const [step, setStep] = useState(0)
const [credentialId, setCredentialId] = useState(() => reconnectCredentialId ?? generateId())
const [appName, setAppName] = useState(initialDisplayName ?? '')
const [appDescription, setAppDescription] = useState(initialDescription ?? '')
const [selected, setSelected] = useState<Set<string>>(() => new Set(ALL_CAPABILITIES))
const [signingSecret, setSigningSecret] = useState('')
const [botToken, setBotToken] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
const [created, setCreated] = useState(false)
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
useEffect(() => {
if (open) return
setStep(0)
setAppName(initialDisplayName ?? '')
setAppDescription(initialDescription ?? '')
setSelected(new Set(ALL_CAPABILITIES))
setSigningSecret('')
setBotToken('')
setCreateError(null)
// Mint a fresh ingest id only after a bot was actually saved, and never when
// reconnecting (that id belongs to an existing credential + Slack app).
// Otherwise keep it stable so a user who already pasted this Request URL into
// their Slack app can reopen and finish creating the credential under the
// same id — a regenerated id would leave Slack posting to a URL no credential
// resolves.
if (created) {
if (!isReconnect) setCredentialId(generateId())
setCreated(false)
}
}, [open, created, isReconnect, initialDisplayName, initialDescription])
// NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be
// able to reach this URL, so it has to be the app's public base (e.g. the
// tunnel host in dev), not whatever host the browser happens to be on.
const requestUrl = useMemo(
() => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`,
[credentialId]
)
const manifestJson = useMemo(() => {
const manifest = buildSlackManifest(selected, {
appName: appName.trim() || DEFAULT_APP_NAME,
webhookUrl: requestUrl,
description: appDescription,
})
return JSON.stringify(manifest, null, 2)
}, [selected, appName, appDescription, requestUrl])
const capabilityIds = useMemo(() => [...selected], [selected])
const setCapabilityIds = useCallback((next: string[]) => setSelected(new Set(next)), [])
const isPending = createCredential.isPending || updateCredential.isPending
const runCreate = useCallback(async () => {
setCreateError(null)
try {
if (isReconnect) {
// Rotate secrets on the existing credential in place — same id, so the
// Slack app's Request URL and any shares stay intact.
await updateCredential.mutateAsync({
credentialId,
signingSecret: signingSecret.trim(),
botToken: botToken.trim(),
displayName: appName.trim() || undefined,
description: appDescription.trim() || undefined,
})
} else {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
id: credentialId,
signingSecret: signingSecret.trim(),
botToken: botToken.trim(),
displayName: appName.trim() || undefined,
description: appDescription.trim() || undefined,
})
}
setCreated(true)
onCreated?.(credentialId)
} catch (err: unknown) {
setCreateError(getErrorMessage(err, 'Could not connect the Slack bot.'))
logger.error('Failed to add custom Slack bot credential', err)
}
}, [
isReconnect,
updateCredential,
createCredential,
workspaceId,
credentialId,
signingSecret,
botToken,
appName,
appDescription,
onCreated,
])
// Create the credential once when the final step is first reached (reachable
// only after both secrets are entered). A ref guards against re-firing on
// failure — retry is manual via the "Try again" button.
const attemptedRef = useRef(false)
useEffect(() => {
if (step !== DONE_STEP) {
attemptedRef.current = false
return
}
if (attemptedRef.current) return
attemptedRef.current = true
void runCreate()
}, [step, runCreate])
return (
<Wizard
open={open}
onOpenChange={onOpenChange}
currentStep={step}
onStepChange={setStep}
size='lg'
icon={SlackIcon}
title={isReconnect ? 'Reconnect a custom Slack bot' : 'Create a custom Slack bot'}
doneLabel='Done'
>
{/* Bot name is required so the credential name, the manifest app name, and
uniqueness all use the user's choice — never the shared Slack team name
fallback, which collides for a second bot in the same workspace. */}
<Wizard.Step title='Configure your bot' canAdvance={appName.trim().length > 0}>
<StepConfigure
appName={appName}
onAppNameChange={setAppName}
appDescription={appDescription}
onAppDescriptionChange={setAppDescription}
capabilityIds={capabilityIds}
onCapabilityIdsChange={setCapabilityIds}
/>
</Wizard.Step>
<Wizard.Step title='Create the app in Slack'>
<StepCreate manifestJson={manifestJson} />
</Wizard.Step>
<Wizard.Step title='Paste your Signing Secret' canAdvance={signingSecret.trim().length > 0}>
<StepSecret value={signingSecret} onChange={setSigningSecret} />
</Wizard.Step>
<Wizard.Step title='Install and paste your Bot Token' canAdvance={botToken.trim().length > 0}>
<StepToken value={botToken} onChange={setBotToken} />
</Wizard.Step>
<Wizard.Step title='All set'>
<StepDone pending={isPending} created={created} error={createError} onRetry={runCreate} />
</Wizard.Step>
</Wizard>
)
}
interface SubStepListProps {
children: ReactNode
}
function SubStepList({ children }: SubStepListProps) {
return <ol className='space-y-2.5'>{children}</ol>
}
interface SubStepProps {
n: number
children: ReactNode
}
function SubStep({ n, children }: SubStepProps) {
return (
<li className='flex gap-2.5'>
<span className='mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--surface-5)] font-medium text-[var(--text-secondary)] text-xs tabular-nums'>
{n}
</span>
<div className='min-w-0 flex-1 text-[var(--text-secondary)] text-sm leading-relaxed'>
{children}
</div>
</li>
)
}
interface StepConfigureProps {
appName: string
onAppNameChange: (next: string) => void
appDescription: string
onAppDescriptionChange: (next: string) => void
capabilityIds: string[]
onCapabilityIdsChange: (next: string[]) => void
}
function StepConfigure({
appName,
onAppNameChange,
appDescription,
onAppDescriptionChange,
capabilityIds,
onCapabilityIdsChange,
}: StepConfigureProps) {
const allSelected = capabilityIds.length === SLACK_CAPABILITIES.length
return (
<div className='space-y-4'>
<div className='flex flex-col gap-[9px]'>
<Label htmlFor='slack-bot-name' className='text-[var(--text-muted)] text-small'>
Bot name
</Label>
<ChipInput
id='slack-bot-name'
value={appName}
onChange={(e) => onAppNameChange(e.target.value)}
placeholder={DEFAULT_APP_NAME}
/>
</div>
<div className='flex flex-col gap-[9px]'>
<Label htmlFor='slack-bot-description' className='text-[var(--text-muted)] text-small'>
Description
</Label>
<ChipInput
id='slack-bot-description'
value={appDescription}
onChange={(e) => onAppDescriptionChange(e.target.value)}
placeholder="Optional — shown on the bot's Slack profile"
maxLength={140}
/>
</div>
<div className='flex flex-col gap-[9px]'>
<Label className='text-[var(--text-muted)] text-small'>Permissions</Label>
<ChipDropdown
multiple
fullWidth
value={capabilityIds}
onChange={onCapabilityIdsChange}
options={CAPABILITY_OPTIONS}
allLabel='No permissions'
showAllOption={false}
/>
{allSelected && (
<p className='text-[var(--text-muted)] text-caption'>
Full access the bot can read and send messages, react, upload files, and chat as an AI
assistant.
</p>
)}
</div>
</div>
)
}
interface StepCreateProps {
manifestJson: string
}
function StepCreate({ manifestJson }: StepCreateProps) {
return (
<div className='space-y-4'>
<SubStepList>
<SubStep n={1}>
<div>Copy your manifest:</div>
<div className='mt-2 overflow-hidden rounded-md border border-[var(--border-1)]'>
<div className='flex items-center justify-between border-[var(--border-1)] border-b bg-[var(--surface-4)] px-3 py-1'>
<span className='font-sans text-[var(--text-tertiary)] text-xs'>manifest.json</span>
<CopyCodeButton
code={manifestJson}
className='text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
/>
</div>
<Code.Viewer code={manifestJson} language='json' wrapText className='max-h-[180px]' />
</div>
</SubStep>
<SubStep n={2}>
Open the{' '}
<a
href='https://api.slack.com/apps'
target='_blank'
rel='noopener noreferrer'
className='text-[var(--brand-secondary)] underline underline-offset-2'
>
Slack Apps page
</a>
.
</SubStep>
<SubStep n={3}>
Click <strong>Create New App</strong> <strong>From a manifest</strong> and pick your
workspace.
</SubStep>
<SubStep n={4}>
Paste your manifest, then click <strong>Next</strong> <strong>Create</strong>.
</SubStep>
</SubStepList>
</div>
)
}
interface SecretStepProps {
value: string
onChange: (next: string) => void
}
function StepSecret({ value, onChange }: SecretStepProps) {
return (
<div className='space-y-4'>
<SubStepList>
<SubStep n={1}>
In your new Slack app, open <strong>Basic Information</strong>.
</SubStep>
<SubStep n={2}>
Find <strong>Signing Secret</strong> and click <strong>Show</strong>, then copy it.
</SubStep>
<SubStep n={3}>Paste it into the field below.</SubStep>
</SubStepList>
<SecretField
label='Signing Secret'
value={value}
onChange={onChange}
placeholder='Paste your signing secret'
/>
</div>
)
}
function StepToken({ value, onChange }: SecretStepProps) {
return (
<div className='space-y-4'>
<SubStepList>
<SubStep n={1}>
In Slack, open <strong>Install App</strong> <strong>Install to Workspace</strong> and
authorize.
</SubStep>
<SubStep n={2}>
Copy the <strong>Bot User OAuth Token</strong> (starts with <code>xoxb-</code>).
</SubStep>
<SubStep n={3}>Paste it into the field below, then click Next.</SubStep>
</SubStepList>
<SecretField label='Bot Token' value={value} onChange={onChange} placeholder='xoxb-...' />
</div>
)
}
interface SecretFieldProps {
label: string
value: string
onChange: (next: string) => void
placeholder?: string
}
function SecretField({ label, value, onChange, placeholder }: SecretFieldProps) {
return (
<div className='flex flex-col gap-[9px]'>
<Label className='text-[var(--text-muted)] text-small'>{label}</Label>
<SecretInput value={value} onChange={onChange} placeholder={placeholder} />
</div>
)
}
interface StepDoneProps {
pending: boolean
created: boolean
error: string | null
onRetry: () => void
}
function StepDone({ pending, created, error, onRetry }: StepDoneProps) {
if (pending) {
return (
<div className='flex flex-col items-center gap-3 py-10 text-center'>
<Loader2 className='size-6 animate-spin text-[var(--text-muted)]' />
<p className='text-[var(--text-secondary)] text-sm'>Verifying your bot and connecting</p>
</div>
)
}
if (error) {
return (
<div className='flex flex-col items-center gap-3 py-10 text-center'>
<p className='max-w-sm text-[var(--text-error)] text-sm leading-relaxed'>{error}</p>
<Button variant='default' onClick={onRetry}>
Try again
</Button>
</div>
)
}
if (created) {
return (
<div className='flex flex-col items-center gap-4 py-10 text-center'>
<div className='space-y-1'>
<p className='font-medium text-[var(--text-primary)] text-base'>Bot connected</p>
<p className='max-w-sm text-[var(--text-secondary)] text-sm leading-relaxed'>
It's now selectable in Slack triggers and actions across this workspace. Click Done to
finish.
</p>
</div>
</div>
)
}
return null
}
@@ -0,0 +1 @@
export { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section'
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react'
interface IntegrationSectionProps {
label: string
children: ReactNode
}
/**
* Labeled section used throughout the integrations surface. Renders a small
* caption, a divider, and a responsive auto-fit grid for its children so the
* vertical rhythm stays consistent across the integrations list, the connected
* credentials list, and the integration detail page templates.
*/
export function IntegrationSection({ label, children }: IntegrationSectionProps) {
return (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>{label}</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className='-mx-2 grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-x-2 gap-y-0.5'>
{children}
</div>
</section>
)
}
@@ -0,0 +1 @@
export { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header'
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { ChipLink } from '@sim/emcn'
interface IntegrationTabsHeaderProps {
active: 'integrations' | 'skills'
workspaceId: string
rightSlot?: ReactNode
}
/**
* Top-of-page chip header shared by the Integrations and Skills pages.
* Highlights the active tab and links to the sibling tab; `rightSlot` lets
* each page render its own trailing actions (e.g. an "Add skill" button).
*/
export function IntegrationTabsHeader({
active,
workspaceId,
rightSlot,
}: IntegrationTabsHeaderProps) {
return (
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<ChipLink href={`/workspace/${workspaceId}/integrations`} active={active === 'integrations'}>
Integrations
</ChipLink>
<ChipLink href={`/workspace/${workspaceId}/skills`} active={active === 'skills'}>
Skills
</ChipLink>
{rightSlot && <div className='ml-auto flex items-center'>{rightSlot}</div>}
</div>
)
}
@@ -0,0 +1,4 @@
export {
IntegrationsShowcase,
IntegrationTile,
} from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase'
@@ -0,0 +1,153 @@
import type { ComponentType } from 'react'
import { cn } from '@sim/emcn'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
/**
* URL-encoded SVG used as a mask to carve the bottom-right notch out of the
* showcase's grid background, exposing the "Explore in chat" CTA underneath.
*/
const SHOWCASE_MASK_SVG = encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 144"><path d="M0 0L192 0L192 92A12 12 0 0 1 180 104L60 104A12 12 0 0 0 48 116L48 132A12 12 0 0 1 36 144L0 144Z" fill="white"/></svg>'
)
/**
* Composite `mask-image` value: a full-opacity gradient for the main area and
* the encoded SVG for the right-edge notch.
*/
const SHOWCASE_MASK_IMAGE = `linear-gradient(white, white), url("data:image/svg+xml,${SHOWCASE_MASK_SVG}")`
/**
* Hand-tuned SVG path that traces the showcase's outer border around the
* bottom-right notch, half-pixel offset so it renders crisply at 1px stroke.
*/
const SHOWCASE_OUTLINE_PATH =
'M 0 0.5 L 180 0.5 A 11.5 11.5 0 0 1 191.5 12 L 191.5 92 A 12 12 0 0 1 180 104 L 60 104 A 12 12 0 0 0 48 116 L 48 132 A 12 12 0 0 1 36 143.5 L 0 143.5'
/**
* Fixed grid coordinates for the brand tiles displayed inside the showcase.
* Coordinates are 1-based and match the 48px CSS grid.
*/
const SHOWCASE_TILES = [
{ id: 'slack', col: 2, row: 1 },
{ id: 'outlook', col: 5, row: 1 },
{ id: 'notion', col: 8, row: 1 },
{ id: 'linear', col: 10, row: 1 },
{ id: 'jira', col: 13, row: 1 },
{ id: 'google_calendar', col: 15, row: 1 },
{ id: 'airtable', col: 3, row: 2 },
{ id: 'hubspot', col: 7, row: 2 },
{ id: 'salesforce', col: 11, row: 2 },
{ id: 'microsoft_teams', col: 14, row: 2 },
{ id: 'google_sheets', col: 4, row: 3 },
{ id: 'asana', col: 6, row: 3 },
{ id: 'confluence', col: 8, row: 3 },
{ id: 'dropbox', col: 12, row: 3 },
] as const
/**
* Resolves the brand background color for a block type from the block registry.
* Returns `null` when the block is unknown or has no brand color configured.
*/
function resolveBrandTileBg(blockType: string): string | null {
return getBlock(blockType)?.bgColor || null
}
interface IntegrationTileProps {
blockType: string
icon: ComponentType<{ className?: string }>
framed?: boolean
}
/**
* Brand-colored square tile that renders a block's icon. The unframed variant
* is a 36px tile used in list rows and headers; the framed variant adds an
* outer 44px halo used inside the showcase grid.
*/
export function IntegrationTile({ blockType, icon: Icon, framed = false }: IntegrationTileProps) {
const brandBg = resolveBrandTileBg(blockType)
if (!framed) {
return (
<div className='size-9 flex-shrink-0'>
<div
className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className={cn('size-5', getTileIconColorClass(brandBg))} />
</div>
</div>
)
}
return (
<div className='size-11 flex-shrink-0 rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-sm dark:bg-[var(--surface-5)]'>
<div
className='flex size-full items-center justify-center rounded-[9px] border border-[var(--border-1)] bg-[var(--bg)]'
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className={cn('size-6', getTileIconColorClass(brandBg))} />
</div>
</div>
)
}
/**
* Decorative integrations grid: a notched, masked grid background populated
* with a curated set of brand tiles. The bottom-right notch leaves room for an
* "Explore in chat" CTA rendered by `ShowcaseWithExplore`.
*/
export function IntegrationsShowcase() {
return (
<div
aria-hidden
className='relative h-[144px] w-full overflow-hidden rounded-xl shadow-[var(--shadow-overlay)]'
>
<div
className='absolute inset-0 bg-[var(--surface-4)] dark:bg-[var(--surface-5)]'
style={{
backgroundImage:
'linear-gradient(to right, var(--border-1) 1px, transparent 1px), linear-gradient(to bottom, var(--border-1) 1px, transparent 1px)',
backgroundSize: '48px 48px',
WebkitMaskImage: SHOWCASE_MASK_IMAGE,
maskImage: SHOWCASE_MASK_IMAGE,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskSize: 'calc(100% - 192px) 100%, 192px 144px',
maskSize: 'calc(100% - 192px) 100%, 192px 144px',
WebkitMaskPosition: 'top left, top right',
maskPosition: 'top left, top right',
}}
>
<div className='absolute inset-0 grid translate-x-[0.5px] translate-y-[0.5px] grid-cols-[repeat(auto-fill,48px)] grid-rows-[repeat(auto-fill,48px)]'>
{SHOWCASE_TILES.map((tile) => {
const block = getBlock(tile.id)
if (!block) return null
return (
<div
key={tile.id}
style={{ gridColumnStart: tile.col, gridRowStart: tile.row }}
className='m-0.5'
>
<IntegrationTile blockType={tile.id} icon={block.icon} framed />
</div>
)
})}
</div>
</div>
<div
className='pointer-events-none absolute top-0 bottom-0 left-0 rounded-l-xl border border-[var(--border-muted)] border-r-0'
style={{ width: 'calc(100% - 192px)' }}
/>
<svg
className='pointer-events-none absolute top-0 right-0'
width='192'
height='144'
viewBox='0 0 192 144'
fill='none'
>
<path d={SHOWCASE_OUTLINE_PATH} stroke='var(--border-muted)' strokeWidth='1' />
</svg>
</div>
)
}
@@ -0,0 +1 @@
export { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore'
@@ -0,0 +1,45 @@
'use client'
import { Chip } from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
interface ShowcaseWithExploreProps {
/**
* Prompt stored for the home page chat to consume after navigation. Bare
* integration names are rewritten to `@`-mention form on store so they chip
* in the chat input (mention treatment is opt-in there).
*/
prompt: string
}
/**
* Renders the integrations showcase with an "Explore in chat" CTA pinned into
* the showcase's bottom-right mask notch. Clicking the CTA seeds the home page
* chat with `prompt` (via {@link storeCuratedPrompt} so integration names chip)
* and navigates to the workspace home.
*/
export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
const params = useParams()
const router = useRouter()
const workspaceId = (params?.workspaceId as string) || ''
return (
<div className='relative'>
<IntegrationsShowcase />
<Chip
active
rightIcon={ArrowRight}
onClick={() => {
storeCuratedPrompt(prompt)
router.push(`/workspace/${workspaceId}/home`)
}}
className='absolute right-0 bottom-0 mx-0'
>
Explore in chat
</Chip>
</div>
)
}
@@ -0,0 +1,12 @@
/**
* Shared protocol for deep-linking to an integration detail page with a
* pre-opened connect modal. Owned by the integrations route; consumed by
* the detail page's `?connect=oauth|service-account` query handler.
*/
export const CONNECT_QUERY_PARAM = 'connect' as const
export const CONNECT_MODE = {
oauth: 'oauth',
serviceAccount: 'service-account',
} as const
@@ -0,0 +1,347 @@
'use client'
import { type ComponentType, useCallback, useMemo, useState } from 'react'
import {
Chip,
ChipConfirmModal,
ChipCopyInput,
ChipInput,
ChipLink,
ChipTextarea,
Send,
toast,
} from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
import { INTEGRATIONS, resolveOAuthServiceForIntegration } from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import {
AddPeopleModal,
CredentialDetailHeading,
CredentialDetailLayout,
CredentialMembersSection,
DetailSection,
UnsavedChangesModal,
useCredentialDetailForm,
} from '@/app/workspace/[workspaceId]/components/credential-detail'
import {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import {
useCreateCredentialDraft,
useDeleteWorkspaceCredential,
useWorkspaceCredentials,
type WorkspaceCredential,
} from '@/hooks/queries/credentials'
import {
useConnectOAuthService,
useDisconnectOAuthService,
useOAuthConnections,
} from '@/hooks/queries/oauth/oauth-connections'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
const logger = createLogger('ConnectedCredentialDetail')
interface ConnectedCredentialDetailProps {
workspaceId: string
credentialId: string
}
export function ConnectedCredentialDetail({
workspaceId,
credentialId,
}: ConnectedCredentialDetailProps) {
const router = useRouter()
const integrationsHref = `/workspace/${workspaceId}/integrations`
useOAuthReturnRouter()
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
})
const { data: oauthConnections = [] } = useOAuthConnections()
const connectOAuthService = useConnectOAuthService()
const disconnectOAuthService = useDisconnectOAuthService()
const createDraft = useCreateCredentialDraft()
const deleteCredential = useDeleteWorkspaceCredential()
const credential = useMemo<WorkspaceCredential | null>(
() => credentials.find((c) => c.id === credentialId) ?? null,
[credentials, credentialId]
)
const isAdmin = credential?.role === 'admin'
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
const [reconnectOpen, setReconnectOpen] = useState(false)
const form = useCredentialDetailForm({ credential, isAdmin, backHref: integrationsHref })
const oauthServiceNameByProviderId = useMemo(
() => new Map(oauthConnections.map((service) => [service.providerId, service.name])),
[oauthConnections]
)
const resolveProviderLabel = useCallback(
(providerId?: string | null): string => {
if (!providerId) return ''
return oauthServiceNameByProviderId.get(providerId) || providerId
},
[oauthServiceNameByProviderId]
)
const serviceConfig = useMemo(() => {
if (!credential?.providerId) return null
return getServiceConfigByProviderId(credential.providerId)
}, [credential])
/**
* Resolve the integration block type from the credential's OAuth service so
* the header tile can render with the same brand background used by the rows
* on the integrations list page. Several integrations can share one service
* (e.g. Jira and Jira Service Management); the one named after the service
* is preferred since it is the service's canonical integration.
*/
const integrationBlockType = useMemo(() => {
if (!serviceConfig) return ''
const candidates = INTEGRATIONS.filter(
(i) => resolveOAuthServiceForIntegration(i)?.providerId === serviceConfig.providerId
)
const serviceName = serviceConfig.name.toLowerCase()
const canonical = candidates.find((i) => i.name.toLowerCase() === serviceName)
return (canonical ?? candidates[0])?.type ?? ''
}, [serviceConfig])
const handleReconnectOAuth = async () => {
if (!credential || credential.type !== 'oauth' || !credential.providerId || !workspaceId) return
try {
await createDraft.mutateAsync({
workspaceId,
providerId: credential.providerId,
displayName: credential.displayName,
description: credential.description || undefined,
credentialId: credential.id,
})
const oauthPreCount = credentials.filter(
(c) => c.type === 'oauth' && c.providerId === credential.providerId
).length
writeOAuthReturnContext({
origin: 'integrations',
displayName: credential.displayName,
providerId: credential.providerId,
preCount: oauthPreCount,
workspaceId,
reconnect: true,
requestedAt: Date.now(),
})
await connectOAuthService.mutateAsync({
providerId: credential.providerId,
callbackURL: window.location.href,
})
} catch (error: unknown) {
toast.error("Couldn't start reconnect", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
logger.error('Failed to reconnect OAuth credential', error)
}
}
const handleConfirmDelete = async () => {
if (!credential) return
try {
if (credential.type === 'service_account') {
await deleteCredential.mutateAsync(credential.id)
} else {
if (!credential.accountId || !credential.providerId) {
toast.error("Can't disconnect", {
description: 'Missing account information. Try reconnecting this credential first.',
})
return
}
await disconnectOAuthService.mutateAsync({
provider: credential.providerId.split('-')[0] || credential.providerId,
providerId: credential.providerId,
serviceId: credential.providerId,
accountId: credential.accountId,
})
window.dispatchEvent(
new CustomEvent('oauth-credentials-updated', {
detail: { providerId: credential.providerId, workspaceId },
})
)
}
setShowDeleteConfirmDialog(false)
router.push(integrationsHref)
} catch (error) {
toast.error("Couldn't disconnect", {
description: getErrorMessage(error, 'Please try again in a moment.'),
})
logger.error('Failed to disconnect integration', error)
}
}
const back = (
<ChipLink href={integrationsHref} onClick={form.handleBackClick} leftIcon={ArrowLeft}>
Integrations
</ChipLink>
)
const actions =
credential && isAdmin ? (
<>
{(credential.type === 'oauth' || credential.type === 'service_account') && (
<Chip
onClick={
credential.type === 'service_account'
? () => setReconnectOpen(true)
: handleReconnectOAuth
}
disabled={connectOAuthService.isPending}
leftIcon={serviceConfig?.icon}
>
Reconnect
</Chip>
)}
<Chip leftIcon={Send} onClick={() => setIsShareModalOpen(true)}>
Share
</Chip>
<Chip
onClick={() => setShowDeleteConfirmDialog(true)}
disabled={disconnectOAuthService.isPending || deleteCredential.isPending}
>
Disconnect
</Chip>
<Chip onClick={form.save} disabled={!form.isDirty || form.isSaving}>
{form.isSaving ? 'Saving...' : 'Save'}
</Chip>
</>
) : null
if (credentialsLoading && !credential) {
return (
<CredentialDetailLayout back={back} actions={actions}>
<p className='py-12 text-center text-[var(--text-muted)] text-sm'>Loading</p>
</CredentialDetailLayout>
)
}
if (!credential) {
return (
<CredentialDetailLayout back={back} actions={actions}>
<p className='py-12 text-center text-[var(--text-muted)] text-sm'>Credential not found.</p>
</CredentialDetailLayout>
)
}
const serviceLabel =
serviceConfig?.name || resolveProviderLabel(credential.providerId) || 'Unknown service'
return (
<>
<CredentialDetailLayout back={back} actions={actions}>
<CredentialDetailHeading
leading={
serviceConfig ? (
<IntegrationTile
blockType={integrationBlockType}
icon={serviceConfig.icon as ComponentType<{ className?: string }>}
/>
) : (
<div className='flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'>
<span className='font-medium text-[var(--text-tertiary)] text-small'>
{resolveProviderLabel(credential.providerId).slice(0, 1) || '?'}
</span>
</div>
)
}
title={serviceLabel}
subtitle={serviceConfig?.description || 'Connected service'}
/>
<DetailSection title='Credential ID'>
<ChipCopyInput id='credential-id' value={credential.id} copyLabel='Copy credential ID' />
</DetailSection>
<DetailSection title='Display Name'>
<ChipInput
id='credential-display-name'
value={form.displayNameDraft}
onChange={(event) => form.setDisplayNameDraft(event.target.value)}
autoComplete='off'
data-lpignore='true'
disabled={!isAdmin}
/>
</DetailSection>
<DetailSection title='Description'>
<ChipTextarea
id='credential-description'
rows={4}
value={form.descriptionDraft}
onChange={(event) => form.setDescriptionDraft(event.target.value)}
placeholder='Add a description...'
maxLength={500}
autoComplete='off'
data-lpignore='true'
disabled={!isAdmin}
/>
</DetailSection>
<CredentialMembersSection credentialId={credential.id} isAdmin={isAdmin} />
</CredentialDetailLayout>
<ChipConfirmModal
open={showDeleteConfirmDialog}
onOpenChange={setShowDeleteConfirmDialog}
srTitle='Disconnect Integration'
title='Disconnect Integration'
text={[
'Are you sure you want to disconnect ',
{ text: credential.displayName, bold: true },
'? This action cannot be undone.',
]}
confirm={{
label: 'Disconnect',
onClick: handleConfirmDelete,
pending: disconnectOAuthService.isPending || deleteCredential.isPending,
pendingLabel: 'Disconnecting...',
}}
/>
<AddPeopleModal
credentialId={credential.id}
open={isShareModalOpen}
onOpenChange={setIsShareModalOpen}
/>
<UnsavedChangesModal
open={form.showUnsavedAlert}
onOpenChange={form.setShowUnsavedAlert}
onDiscard={form.confirmDiscard}
/>
{credential.type === 'service_account' && credential.providerId && (
<ConnectServiceAccountModal
open={reconnectOpen}
onOpenChange={setReconnectOpen}
workspaceId={workspaceId}
serviceAccountProviderId={credential.providerId as ServiceAccountProviderId}
serviceName={serviceConfig?.name || credential.displayName}
serviceIcon={serviceConfig?.icon as ComponentType<{ className?: string }>}
credentialId={credential.id}
credentialDisplayName={credential.displayName}
credentialDescription={credential.description ?? undefined}
/>
)}
</>
)
}
@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
import { ConnectedCredentialDetail } from '@/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail'
export const metadata: Metadata = {
title: 'Connected Integration',
}
export default async function ConnectedCredentialPage({
params,
}: {
params: Promise<{ workspaceId: string; credentialId: string }>
}) {
const { workspaceId, credentialId } = await params
return <ConnectedCredentialDetail workspaceId={workspaceId} credentialId={credentialId} />
}
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function IntegrationsError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load integrations'
description='Something went wrong while loading your integrations. Please try again.'
loggerName='IntegrationsError'
/>
)
}
@@ -0,0 +1,147 @@
'use client'
import { type RefObject, useEffect, useLayoutEffect, useRef } from 'react'
import { usePathname } from 'next/navigation'
/** Namespace prefix so restoration keys never collide with other tab state. */
const STORAGE_PREFIX = 'integrations-scroll:' as const
/**
* True when the most recent navigation was a Back/Forward history traversal.
*
* A single module-level `popstate` listener flips this before the destination
* page mounts; each restore reads it once and clears it. Fresh push navigations
* (a sidebar link, a typed URL) never fire `popstate`, so the flag stays false
* and those visits open at the top instead of jumping to a stale position.
* Registered at module load — a per-hook listener would attach too late to see
* the `popstate` that triggered its own mount.
*/
let lastNavWasTraversal = false
if (typeof window !== 'undefined') {
window.addEventListener('popstate', () => {
lastNavWasTraversal = true
})
}
interface UseScrollRestorationOptions {
/**
* Flag that flips to `true` once the async content the scroll height depends
* on has settled (e.g. the credentials query is no longer pending). A late
* restore is re-attempted when this transitions so we do not clamp against a
* too-short container while data is still loading.
*/
ready?: boolean
}
/**
* Restores the scroll position of an inner scroll container across browser
* Back/Forward navigation within the same tab.
*
* Next.js App Router only restores WINDOW scroll, so a page whose content
* scrolls inside a nested `overflow-y-auto` element loses its position on Back.
* This hook persists `scrollTop` in `sessionStorage` (per-pathname, tab-scoped)
* and re-applies it — only on history traversals — once the container has laid
* out.
*
* Programmatic vs. user scrolls are told apart by VALUE, not a one-shot event
* flag: a restore assigns `scrollTop === lastAppliedRef`, so the echoed scroll
* event compares equal and is ignored (it is never persisted, so a clamped
* value cannot overwrite the saved target). This avoids the race where the
* programmatic scroll event fires before the listener attaches and a stuck flag
* drops the user's first real scroll. The restore itself latches only on a full
* (non-clamped) apply, so a late `ready` retry can complete a position that was
* clamped against still-loading content, and it stops the moment the user
* scrolls away from the last applied value so it never fights them.
*
* @param containerRef Ref to the scrollable container element.
* @param options `ready` marks async content as settled for a late retry.
*/
export function useScrollRestoration(
containerRef: RefObject<HTMLDivElement | null>,
{ ready = true }: UseScrollRestorationOptions = {}
): void {
const pathname = usePathname()
const storageKey = `${STORAGE_PREFIX}${pathname}`
const storageKeyRef = useRef(storageKey)
const hasRestoredRef = useRef(false)
/** Last `scrollTop` this hook assigned, so its echo scroll event is ignored. */
const lastAppliedRef = useRef(-1)
/** Latest user-initiated `scrollTop`, flushed to storage on unmount. */
const latestUserScrollRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
/** Captured once per mount: did we arrive here via Back/Forward? */
const shouldRestoreRef = useRef<boolean | null>(null)
useEffect(() => {
storageKeyRef.current = storageKey
}, [storageKey])
useEffect(() => {
const el = containerRef.current
if (!el) return
const persist = (value: number) => {
try {
sessionStorage.setItem(storageKeyRef.current, String(value))
} catch {}
}
const onScroll = () => {
if (el.scrollTop === lastAppliedRef.current) return
latestUserScrollRef.current = el.scrollTop
if (rafRef.current !== null) return
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null
persist(el.scrollTop)
})
}
el.addEventListener('scroll', onScroll, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
if (latestUserScrollRef.current !== null) persist(latestUserScrollRef.current)
}
}, [containerRef])
useLayoutEffect(() => {
const el = containerRef.current
if (!el || hasRestoredRef.current) return
if (shouldRestoreRef.current === null) {
shouldRestoreRef.current = lastNavWasTraversal
lastNavWasTraversal = false
}
if (!shouldRestoreRef.current) {
hasRestoredRef.current = true
return
}
if (lastAppliedRef.current !== -1 && el.scrollTop !== lastAppliedRef.current) {
hasRestoredRef.current = true
return
}
let target = 0
try {
const raw = sessionStorage.getItem(storageKeyRef.current)
target = raw ? Number(raw) : 0
} catch {}
if (!Number.isFinite(target) || target <= 0) {
hasRestoredRef.current = true
return
}
const maxScroll = el.scrollHeight - el.clientHeight
if (maxScroll <= 0) return
lastAppliedRef.current = Math.min(target, maxScroll)
el.scrollTop = lastAppliedRef.current
if (maxScroll >= target) hasRestoredRef.current = true
}, [containerRef, ready])
}
@@ -0,0 +1,380 @@
'use client'
import { type ComponentType, useCallback, useMemo, useRef } from 'react'
import {
ArrowRight,
ChevronDown,
ChipInput,
chipVariants,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Search,
} from '@sim/emcn'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import {
blockTypeToIconMap,
formatIntegrationType,
INTEGRATIONS,
type Integration,
} from '@/lib/integrations'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import {
ALL_CATEGORY,
CONNECTED_LABEL,
FEATURED_LABEL,
integrationsParsers,
integrationsUrlKeys,
} from '@/app/workspace/[workspaceId]/integrations/search-params'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const
/** Slugs surfaced in the pinned Featured section, in display order. */
const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const
const LINK_ROW_CLASSES =
'flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
const LINK_ROW_TITLE_CLASSES = 'truncate text-[14px] text-[var(--text-body)]'
const LINK_ROW_SUBTITLE_CLASSES = 'truncate text-[12px] text-[var(--text-muted)]'
const LINK_ROW_ARROW_CLASSES = 'size-4 flex-shrink-0 text-[var(--text-icon)]'
const FEATURED_INTEGRATIONS: readonly Integration[] = (() => {
const bySlug = new Map(INTEGRATIONS.map((i) => [i.slug, i]))
return FEATURED_SLUGS.map((slug) => bySlug.get(slug)).filter(
(i): i is Integration => i !== undefined
)
})()
/** Lookup integration metadata by OAuth service display name (case-insensitive). */
const INTEGRATION_BY_LOWER_NAME: ReadonlyMap<string, Integration> = new Map(
INTEGRATIONS.map((i) => [i.name.toLowerCase(), i])
)
const ALL_CATEGORY_SECTIONS: readonly { label: string; integrations: Integration[] }[] = (() => {
const grouped = new Map<string, Integration[]>()
for (const integration of INTEGRATIONS) {
if (!integration.integrationType) continue
const bucket = grouped.get(integration.integrationType)
if (bucket) bucket.push(integration)
else grouped.set(integration.integrationType, [integration])
}
return Array.from(grouped, ([label, items]) => ({
label,
integrations: [...items].sort((a, b) => a.name.localeCompare(b.name)),
})).sort((a, b) => a.label.localeCompare(b.label))
})()
interface IntegrationItemProps {
blockType: string
slug: string
workspaceId: string
name: string
description?: string | null
icon: ComponentType<{ className?: string }>
}
function IntegrationItem({
blockType,
slug,
workspaceId,
name,
description,
icon: Icon,
}: IntegrationItemProps) {
return (
<Link href={`/workspace/${workspaceId}/integrations/${slug}`} className={LINK_ROW_CLASSES}>
<IntegrationTile blockType={blockType} icon={Icon} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className={LINK_ROW_TITLE_CLASSES}>{name}</span>
{description && <span className={LINK_ROW_SUBTITLE_CLASSES}>{description}</span>}
</div>
<ArrowRight className={LINK_ROW_ARROW_CLASSES} />
</Link>
)
}
interface ConnectedDisplayItem {
credential: WorkspaceCredential
name: string
description: string
serviceName: string
integrationType: string | null
blockType: string
slug: string
icon: ComponentType<{ className?: string }>
}
interface ConnectedItemProps {
href: string
blockType: string
name: string
description: string
icon: ComponentType<{ className?: string }>
}
function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) {
return (
<Link href={href} className={LINK_ROW_CLASSES}>
<IntegrationTile blockType={blockType} icon={Icon} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className={LINK_ROW_TITLE_CLASSES}>{name}</span>
<span className={LINK_ROW_SUBTITLE_CLASSES}>{description}</span>
</div>
<ArrowRight className={LINK_ROW_ARROW_CLASSES} />
</Link>
)
}
export function Integrations() {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] =
useQueryStates(integrationsParsers, integrationsUrlKeys)
/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. Filtering below is cheap in-memory over a static list,
* so it reads the instant value too.
*/
const setSearchTerm = useCallback(
(value: string) => {
const trimmed = value.trim()
const next = trimmed.length > 0 ? trimmed : null
setIntegrationFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setIntegrationFilters]
)
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
workspaceId,
enabled: Boolean(workspaceId),
})
useScrollRestoration(scrollContainerRef, { ready: !credentialsLoading })
const oauthCredentials = useMemo(
() => credentials.filter((c) => c.type === 'oauth' || c.type === 'service_account'),
[credentials]
)
const connectedItems = useMemo<ConnectedDisplayItem[]>(() => {
return oauthCredentials.flatMap((credential) => {
if (!credential.providerId) return []
const service = getServiceConfigByProviderId(credential.providerId)
if (!service) return []
const integration = INTEGRATION_BY_LOWER_NAME.get(service.name.toLowerCase())
return [
{
credential,
name: credential.displayName,
description: credential.description || `${service.name} integration`,
serviceName: service.name,
integrationType: integration?.integrationType ?? null,
blockType: integration?.type ?? '',
slug: integration?.slug ?? '',
icon: service.icon as ComponentType<{ className?: string }>,
},
]
})
}, [oauthCredentials])
const setSelectedCategory = useCallback(
(category: string) => {
setIntegrationFilters({ category })
},
[setIntegrationFilters]
)
const categoryOptions = [
ALL_CATEGORY,
...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []),
FEATURED_LABEL,
...ALL_CATEGORY_SECTIONS.map((section) => section.label),
]
const isAllCategorySelected = selectedCategory === ALL_CATEGORY
const isFeaturedSelected = selectedCategory === FEATURED_LABEL
const isConnectedSelected = selectedCategory === CONNECTED_LABEL
const filteredCategorySections = useMemo(() => {
// Connected-only view: integration sections are suppressed entirely.
if (isConnectedSelected) return []
const normalizedSearch = urlSearchTerm.trim().toLowerCase()
const matchesSearch = (integration: Integration) =>
!normalizedSearch ||
integration.name.toLowerCase().includes(normalizedSearch) ||
integration.description.toLowerCase().includes(normalizedSearch)
if (isFeaturedSelected) {
const items = FEATURED_INTEGRATIONS.filter(matchesSearch)
return items.length > 0 ? [{ label: FEATURED_LABEL, integrations: items }] : []
}
const matchesCategory = (integration: Integration) =>
isAllCategorySelected || integration.integrationType === selectedCategory
// Featured is a curated home-row pin: hide it during search so results
// are not duplicated between the Featured section and the category list.
const featured = normalizedSearch ? [] : FEATURED_INTEGRATIONS.filter(matchesCategory)
const featuredSection =
featured.length > 0 ? [{ label: FEATURED_LABEL, integrations: featured }] : []
if (isAllCategorySelected) {
const rest = ALL_CATEGORY_SECTIONS.map((section) => ({
label: section.label,
integrations: section.integrations.filter(matchesSearch),
})).filter((section) => section.integrations.length > 0)
return [...featuredSection, ...rest]
}
const integrations = INTEGRATIONS.filter(matchesCategory)
.filter(matchesSearch)
.sort((a, b) => a.name.localeCompare(b.name))
return [
...featuredSection,
...(integrations.length > 0 ? [{ label: selectedCategory, integrations }] : []),
]
}, [
isAllCategorySelected,
isConnectedSelected,
isFeaturedSelected,
urlSearchTerm,
selectedCategory,
])
const visibleConnectedItems = useMemo(() => {
// Featured-only view: Connected is suppressed (mirror behavior of the
// Featured-only branch above, which renders only the Featured section).
if (isFeaturedSelected) return []
const normalizedSearch = urlSearchTerm.trim().toLowerCase()
return connectedItems.filter((item) => {
const matchesCategory =
isAllCategorySelected || isConnectedSelected || item.integrationType === selectedCategory
if (!matchesCategory) return false
if (!normalizedSearch) return true
return (
item.name.toLowerCase().includes(normalizedSearch) ||
item.description.toLowerCase().includes(normalizedSearch) ||
item.serviceName.toLowerCase().includes(normalizedSearch)
)
})
}, [
connectedItems,
isAllCategorySelected,
isConnectedSelected,
isFeaturedSelected,
urlSearchTerm,
selectedCategory,
])
const showNoResults =
Boolean(urlSearchTerm.trim() || !isAllCategorySelected) &&
filteredCategorySections.length === 0 &&
visibleConnectedItems.length === 0
return (
<div className='flex h-full flex-col bg-[var(--bg)]'>
<IntegrationTabsHeader active='integrations' workspaceId={workspaceId} />
<div
ref={scrollContainerRef}
className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'
>
<div className='mx-auto flex max-w-[48rem] flex-col gap-7 pb-3'>
<ShowcaseWithExplore prompt='Explain the integrations in Sim and what I should connect.' />
<div className='flex items-center gap-2'>
<ChipInput
icon={Search}
className='min-w-0 flex-1'
placeholder='Search integrations...'
value={urlSearchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={credentialsLoading}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type='button' className={chipVariants({ variant: 'filled', flush: true })}>
<span className='text-[var(--text-body)]'>
{selectedCategory === ALL_CATEGORY
? selectedCategory
: formatIntegrationType(selectedCategory)}
</span>
<ChevronDown className='h-[7px] w-[9px] text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='min-w-[160px]'>
{categoryOptions.map((category) => (
<DropdownMenuItem key={category} onSelect={() => setSelectedCategory(category)}>
{category === ALL_CATEGORY ? category : formatIntegrationType(category)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className='flex flex-col gap-7'>
{visibleConnectedItems.length > 0 && (
<IntegrationSection label={CONNECTED_LABEL}>
{visibleConnectedItems.map((item) => (
<ConnectedItem
key={item.credential.id}
href={`/workspace/${workspaceId}/integrations/connected/${item.credential.id}`}
blockType={item.blockType}
name={item.name}
description={item.description}
icon={item.icon}
/>
))}
</IntegrationSection>
)}
{filteredCategorySections.map((section) => (
<IntegrationSection key={section.label} label={formatIntegrationType(section.label)}>
{section.integrations.map((integration) => {
const Icon = blockTypeToIconMap[integration.type]
if (!Icon) return null
return (
<IntegrationItem
key={integration.type}
blockType={integration.type}
slug={integration.slug}
workspaceId={workspaceId}
name={integration.name}
description={integration.description}
icon={Icon}
/>
)
})}
</IntegrationSection>
))}
{showNoResults && (
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
{urlSearchTerm.trim()
? `No integrations found matching “${urlSearchTerm}`
: 'No integrations in this category'}
</div>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,34 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
import { Integrations } from '@/app/workspace/[workspaceId]/integrations/integrations'
export const metadata: Metadata = {
title: 'Integrations',
}
/**
* Integrations page entry. `Integrations` reads URL query params via nuqs (which
* uses `useSearchParams` internally), so it must sit under a Suspense boundary.
* The fallback renders the real page chrome (background + tab header) so a
* suspend never shows a blank frame.
*/
export default async function IntegrationsPage({
params,
}: {
params: Promise<{ workspaceId: string }>
}) {
const { workspaceId } = await params
return (
<Suspense
fallback={
<div className='flex h-full flex-col bg-[var(--bg)]'>
<IntegrationTabsHeader active='integrations' workspaceId={workspaceId} />
</div>
}
>
<Integrations />
</Suspense>
)
}
@@ -0,0 +1,30 @@
import { parseAsString } from 'nuqs/server'
/** Default category — the unfiltered "All" view. */
export const ALL_CATEGORY = 'All'
/** Pinned, curated home-row section. */
export const FEATURED_LABEL = 'Featured'
/** Connected-credentials section (only shown when the user has connections). */
export const CONNECTED_LABEL = 'Connected'
/**
* Co-located, typed URL query-param definitions for the Integrations gallery.
*
* - `category` selects the active integration category tab. Categories mix the
* `IntegrationType` enum values with the `All`/`Featured`/`Connected`
* pseudo-categories and are derived from the data set, so a plain string is
* used; the `All` default clears from the URL.
* - `search` is the integration search term. The input is controlled directly by
* the nuqs value; only its URL write is debounced via `limitUrlUpdates`
* (`debounce`) on the setter — never written on every keystroke.
*/
export const integrationsParsers = {
category: parseAsString.withDefault(ALL_CATEGORY),
search: parseAsString.withDefault(''),
} as const
/** Filter/search view-state: clean URLs, no back-stack churn. */
export const integrationsUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const