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
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:
+33
@@ -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>
|
||||
)
|
||||
}
|
||||
+367
@@ -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>
|
||||
)
|
||||
}
|
||||
+110
@@ -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
|
||||
+517
@@ -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>
|
||||
)
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
ConnectServiceAccountModal,
|
||||
type ServiceAccountProviderId,
|
||||
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
|
||||
+463
@@ -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
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section'
|
||||
+24
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header'
|
||||
+31
@@ -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>
|
||||
)
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
IntegrationsShowcase,
|
||||
IntegrationTile,
|
||||
} from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase'
|
||||
+153
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore'
|
||||
+45
@@ -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
|
||||
+347
@@ -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
|
||||
Reference in New Issue
Block a user