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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,517 @@
'use client'
import { type ComponentType, useEffect, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
SecretInput,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { isApiClientError } from '@/lib/api/client/errors'
import { serviceAccountJsonSchema } from '@/lib/api/contracts/credentials'
import {
ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
SLACK_CUSTOM_BOT_PROVIDER_ID,
} from '@/lib/oauth/types'
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
import {
useCreateWorkspaceCredential,
useUpdateWorkspaceCredential,
} from '@/hooks/queries/credentials'
const logger = createLogger('ConnectServiceAccountModal')
const GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID = 'google-service-account' as const
export type ServiceAccountProviderId =
| typeof GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID
| typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
| typeof SLACK_CUSTOM_BOT_PROVIDER_ID
/** Sim setup guides for each provider, docked bottom-left of each modal. */
const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account'
const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL =
'https://docs.sim.ai/integrations/atlassian-service-account'
function openDocs(url: string): void {
window.open(url, '_blank', 'noopener,noreferrer')
}
/**
* Atlassian site domain hint — surfaced inline when the user types something
* that doesn't look like `<tenant>.atlassian.net`.
*/
const ATLASSIAN_DOMAIN_HINT_REGEX = /^[a-z0-9-]+\.atlassian\.net$/i
/**
* Maps server `error.code` values returned by the Atlassian service-account
* route to user-facing messages. Falls back to {@link FALLBACK_ERROR_MESSAGE}
* when the error is unrecognized.
*/
const ATLASSIAN_ERROR_MESSAGES: Record<string, string> = {
invalid_credentials:
"We couldn't authenticate with that API token. Double-check the token and that the service account has access to this site.",
site_not_found:
"We couldn't find an Atlassian site at that domain. Check the spelling — it should look like your-team.atlassian.net.",
duplicate_display_name: 'A credential with that name already exists in this workspace.',
atlassian_unavailable:
"We couldn't reach Atlassian to verify these credentials. Try again in a moment.",
}
const FALLBACK_ERROR_MESSAGE = "We couldn't add this service account. Try again in a moment."
function normalizeAtlassianDomain(raw: string): string {
return raw
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/+$/, '')
}
function messageForAtlassianError(err: unknown): string {
if (isApiClientError(err) && err.code && ATLASSIAN_ERROR_MESSAGES[err.code]) {
return ATLASSIAN_ERROR_MESSAGES[err.code]
}
return FALLBACK_ERROR_MESSAGE
}
interface ConnectServiceAccountModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
serviceAccountProviderId: ServiceAccountProviderId
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
/**
* When set, the modal reconnects (rotates secrets on) this existing credential
* in place instead of creating a new one. The id is preserved, so shares and
* (for Slack) the ingest URL stay valid.
*/
credentialId?: string
/** Existing display name, used to seed reconnect-capable modals. */
credentialDisplayName?: string
/** Existing description, used to seed reconnect-capable modals. */
credentialDescription?: string
}
/**
* Connect-service-account modal mounted from the per-integration detail page.
* Self-contained: takes the resolved SA provider + service metadata from the
* caller and submits via `useCreateWorkspaceCredential`. Branches the body
* based on `serviceAccountProviderId`:
*
* - `google-service-account`: JSON-paste + drag/drop. Validated client-side
* against {@link serviceAccountJsonSchema} before submitting.
* - `atlassian-service-account`: API token + site domain. Validated by the
* server against the Atlassian API; user-facing errors are mapped from the
* route's `error.code`.
*/
export function ConnectServiceAccountModal({
open,
onOpenChange,
workspaceId,
serviceAccountProviderId,
serviceName,
serviceIcon,
credentialId,
credentialDisplayName,
credentialDescription,
}: ConnectServiceAccountModalProps) {
if (serviceAccountProviderId === SLACK_CUSTOM_BOT_PROVIDER_ID) {
return (
<ConnectSlackBotModal
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
/>
)
}
if (serviceAccountProviderId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
return (
<AtlassianServiceAccountModal
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
serviceName={serviceName}
serviceIcon={serviceIcon}
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
/>
)
}
return (
<GoogleServiceAccountModal
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
serviceName={serviceName}
serviceIcon={serviceIcon}
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
/>
)
}
interface ProviderModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
serviceName: string
serviceIcon: ComponentType<{ className?: string }>
/** When set, reconnect (rotate secrets on) this credential in place. */
credentialId?: string
/** Existing name/description, seeded into the fields on reconnect. */
initialDisplayName?: string
initialDescription?: string
}
/**
* Google service-account flow. Accepts the raw JSON key (paste or drag/drop)
* and validates against the shared `serviceAccountJsonSchema` so the same
* shape errors render here as in the server route.
*/
function GoogleServiceAccountModal({
open,
onOpenChange,
workspaceId,
serviceName,
serviceIcon: ServiceIcon,
credentialId,
initialDisplayName,
initialDescription,
}: ProviderModalProps) {
const [jsonInput, setJsonInput] = useState('')
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null)
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState<string | null>(null)
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
useEffect(() => {
if (open) return
setJsonInput('')
setUploadedFileName(null)
setDisplayName(initialDisplayName ?? '')
setDescription(initialDescription ?? '')
setError(null)
}, [open, initialDisplayName, initialDescription])
/**
* Try to auto-populate display name from the JSON `client_email`. Silent on
* parse failure — the explicit submit-time validation surfaces invalid JSON
* via the inline error state.
*/
const maybeFillDisplayNameFromJson = (text: string) => {
if (displayName.trim()) return
try {
const parsed = JSON.parse(text) as { client_email?: unknown }
if (typeof parsed.client_email === 'string') setDisplayName(parsed.client_email)
} catch {
// surface validation on submit instead
}
}
const readJsonFile = (file: File) => {
if (!file.name.endsWith('.json')) {
setError('Only .json files are supported')
return
}
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result
if (typeof text !== 'string') return
setJsonInput(text)
setUploadedFileName(file.name)
setError(null)
maybeFillDisplayNameFromJson(text)
}
reader.readAsText(file)
}
const handleFileUpload = (files: File[]) => {
const file = files[0]
if (file) readJsonFile(file)
}
const handleSubmit = async () => {
setError(null)
const trimmed = jsonInput.trim()
if (!trimmed) {
setError('Paste the service account JSON key.')
return
}
const validation = serviceAccountJsonSchema.safeParse(trimmed)
if (!validation.success) {
setError(validation.error.issues[0]?.message ?? 'Invalid JSON')
return
}
try {
if (credentialId) {
await updateCredential.mutateAsync({
credentialId,
serviceAccountJson: trimmed,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
} else {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
serviceAccountJson: trimmed,
})
}
onOpenChange(false)
} catch (err: unknown) {
const message = getErrorMessage(err, 'Failed to add service account')
setError(message)
logger.error('Failed to add Google service account credential', err)
}
}
const isPending = createCredential.isPending || updateCredential.isPending
const isDisabled = !jsonInput.trim() || isPending
return (
<ChipModal
open={open}
onOpenChange={onOpenChange}
srTitle={`Add ${serviceName} service account`}
>
<ChipModalHeader icon={ServiceIcon} onClose={() => onOpenChange(false)}>
Add {serviceName} service account
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='textarea'
title='JSON key'
value={jsonInput}
onChange={(value) => {
setJsonInput(value)
if (uploadedFileName) setUploadedFileName(null)
if (error) setError(null)
maybeFillDisplayNameFromJson(value)
}}
placeholder='Paste your service account JSON key here'
minHeight={120}
required
/>
<ChipModalField
type='file'
title='Or upload a file'
accept='.json'
label={
uploadedFileName
? `Uploaded ${uploadedFileName} — click or drop to replace`
: 'Drag & drop a .json file, or click to browse'
}
onChange={handleFileUpload}
/>
<ChipModalField
type='input'
title='Display name'
value={displayName}
onChange={setDisplayName}
placeholder='Auto-populated from client_email'
autoComplete='off'
/>
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='Optional description'
maxLength={500}
minHeight={80}
/>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
label: 'Setup guide',
onClick: () => openDocs(GOOGLE_SERVICE_ACCOUNT_DOCS_URL),
},
]}
primaryAction={{
label: isPending ? 'Adding...' : 'Add service account',
onClick: handleSubmit,
disabled: isDisabled,
}}
/>
</ChipModal>
)
}
/**
* Atlassian service-account flow. Accepts an API token + site domain and
* validates server-side against the Atlassian API. Maps the route's
* `error.code` to descriptive copy so users know whether the token, domain,
* or upstream availability is at fault.
*/
function AtlassianServiceAccountModal({
open,
onOpenChange,
workspaceId,
serviceName,
serviceIcon: ServiceIcon,
credentialId,
initialDisplayName,
initialDescription,
}: ProviderModalProps) {
const [apiToken, setApiToken] = useState('')
const [domain, setDomain] = useState('')
const [displayName, setDisplayName] = useState(initialDisplayName ?? '')
const [description, setDescription] = useState(initialDescription ?? '')
const [error, setError] = useState<string | null>(null)
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
useEffect(() => {
if (open) return
setApiToken('')
setDomain('')
setDisplayName(initialDisplayName ?? '')
setDescription(initialDescription ?? '')
setError(null)
}, [open, initialDisplayName, initialDescription])
const trimmedToken = apiToken.trim()
const normalizedDomain = normalizeAtlassianDomain(domain)
const showDomainHint =
normalizedDomain.length > 0 && !ATLASSIAN_DOMAIN_HINT_REGEX.test(normalizedDomain)
const isPending = createCredential.isPending || updateCredential.isPending
const isDisabled = !trimmedToken || !normalizedDomain || isPending
const handleSubmit = async () => {
setError(null)
if (isDisabled) return
try {
if (credentialId) {
await updateCredential.mutateAsync({
credentialId,
apiToken: trimmedToken,
domain: normalizedDomain,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
} else {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
apiToken: trimmedToken,
domain: normalizedDomain,
displayName: displayName.trim() || undefined,
description: description.trim() || undefined,
})
}
onOpenChange(false)
} catch (err: unknown) {
setError(messageForAtlassianError(err))
logger.error('Failed to add Atlassian service account credential', err)
}
}
return (
<ChipModal
open={open}
onOpenChange={onOpenChange}
srTitle={`Add ${serviceName} service account`}
>
<ChipModalHeader icon={ServiceIcon} onClose={() => onOpenChange(false)}>
Add {serviceName} service account
</ChipModalHeader>
<ChipModalBody>
<ChipModalField type='custom' title='API token' required>
<SecretInput
value={apiToken}
onChange={(value) => {
setApiToken(value)
if (error) setError(null)
}}
placeholder='Paste API token'
name='atlassian_service_account_api_token'
autoComplete='new-password'
autoCorrect='off'
autoCapitalize='off'
data-lpignore='true'
data-form-type='other'
/>
</ChipModalField>
<ChipModalField
type='input'
title='Site domain'
value={domain}
onChange={(value) => {
setDomain(value)
if (error) setError(null)
}}
placeholder='your-team.atlassian.net'
autoComplete='off'
required
error={
showDomainHint
? 'Atlassian sites usually look like your-team.atlassian.net.'
: undefined
}
/>
<ChipModalField
type='input'
title='Display name'
value={displayName}
onChange={setDisplayName}
placeholder="Defaults to the account's Atlassian display name"
autoComplete='off'
/>
<ChipModalField
type='textarea'
title='Description'
value={description}
onChange={setDescription}
placeholder='Optional description'
maxLength={500}
minHeight={80}
/>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
secondaryActions={[
{
label: 'Setup guide',
onClick: () => openDocs(ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL),
},
]}
primaryAction={{
label: isPending ? 'Adding...' : 'Add service account',
onClick: handleSubmit,
disabled: isDisabled,
}}
/>
</ChipModal>
)
}
@@ -0,0 +1,4 @@
export {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal/connect-service-account-modal'
@@ -0,0 +1,463 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
ChipDropdown,
type ChipDropdownOption,
ChipInput,
Code,
CopyCodeButton,
Label,
SecretInput,
Wizard,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { Loader2 } from 'lucide-react'
import { SlackIcon } from '@/components/icons'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types'
import {
useCreateWorkspaceCredential,
useUpdateWorkspaceCredential,
} from '@/hooks/queries/credentials'
import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities'
const logger = createLogger('ConnectSlackBotModal')
const DEFAULT_APP_NAME = 'Sim Bot'
const DONE_STEP = 4
/** Every capability is granted by default; trimming is an opt-in dropdown. */
const ALL_CAPABILITIES = new Set(SLACK_CAPABILITIES.map((c) => c.id))
const CAPABILITY_OPTIONS: ChipDropdownOption[] = SLACK_CAPABILITIES.map((c) => ({
value: c.id,
label: c.label,
}))
interface ConnectSlackBotModalProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
/**
* When set, the modal reconnects (rotates secrets on) this existing credential
* instead of creating a new one — the id is reused so the Slack ingest URL
* `/api/webhooks/slack/custom/{id}` stays valid, and saving updates the
* credential in place.
*/
credentialId?: string
/** Existing display name, seeded into the bot-name field on reconnect. */
initialDisplayName?: string
/** Existing description, seeded into the description field on reconnect. */
initialDescription?: string
/** Called with the credential id after a successful create or reconnect. */
onCreated?: (credentialId: string) => void
}
/**
* One-time setup for a reusable custom Slack bot credential — the same guided
* wizard as the legacy in-block setup, but it persists a workspace credential
* instead of writing sub-block values. The credential id is pre-generated so the
* ingest URL `/api/webhooks/slack/custom/{id}` (and the manifest that embeds it)
* can be shown up front; the credential is created on the final step once the
* signing secret + bot token are pasted.
*/
export function ConnectSlackBotModal({
open,
onOpenChange,
workspaceId,
credentialId: reconnectCredentialId,
initialDisplayName,
initialDescription,
onCreated,
}: ConnectSlackBotModalProps) {
const isReconnect = Boolean(reconnectCredentialId)
const [step, setStep] = useState(0)
const [credentialId, setCredentialId] = useState(() => reconnectCredentialId ?? generateId())
const [appName, setAppName] = useState(initialDisplayName ?? '')
const [appDescription, setAppDescription] = useState(initialDescription ?? '')
const [selected, setSelected] = useState<Set<string>>(() => new Set(ALL_CAPABILITIES))
const [signingSecret, setSigningSecret] = useState('')
const [botToken, setBotToken] = useState('')
const [createError, setCreateError] = useState<string | null>(null)
const [created, setCreated] = useState(false)
const createCredential = useCreateWorkspaceCredential()
const updateCredential = useUpdateWorkspaceCredential()
useEffect(() => {
if (open) return
setStep(0)
setAppName(initialDisplayName ?? '')
setAppDescription(initialDescription ?? '')
setSelected(new Set(ALL_CAPABILITIES))
setSigningSecret('')
setBotToken('')
setCreateError(null)
// Mint a fresh ingest id only after a bot was actually saved, and never when
// reconnecting (that id belongs to an existing credential + Slack app).
// Otherwise keep it stable so a user who already pasted this Request URL into
// their Slack app can reopen and finish creating the credential under the
// same id — a regenerated id would leave Slack posting to a URL no credential
// resolves.
if (created) {
if (!isReconnect) setCredentialId(generateId())
setCreated(false)
}
}, [open, created, isReconnect, initialDisplayName, initialDescription])
// NEXT_PUBLIC_APP_URL, not window.location.origin: Slack's servers must be
// able to reach this URL, so it has to be the app's public base (e.g. the
// tunnel host in dev), not whatever host the browser happens to be on.
const requestUrl = useMemo(
() => `${getBaseUrl()}/api/webhooks/slack/custom/${credentialId}`,
[credentialId]
)
const manifestJson = useMemo(() => {
const manifest = buildSlackManifest(selected, {
appName: appName.trim() || DEFAULT_APP_NAME,
webhookUrl: requestUrl,
description: appDescription,
})
return JSON.stringify(manifest, null, 2)
}, [selected, appName, appDescription, requestUrl])
const capabilityIds = useMemo(() => [...selected], [selected])
const setCapabilityIds = useCallback((next: string[]) => setSelected(new Set(next)), [])
const isPending = createCredential.isPending || updateCredential.isPending
const runCreate = useCallback(async () => {
setCreateError(null)
try {
if (isReconnect) {
// Rotate secrets on the existing credential in place — same id, so the
// Slack app's Request URL and any shares stay intact.
await updateCredential.mutateAsync({
credentialId,
signingSecret: signingSecret.trim(),
botToken: botToken.trim(),
displayName: appName.trim() || undefined,
description: appDescription.trim() || undefined,
})
} else {
await createCredential.mutateAsync({
workspaceId,
type: 'service_account',
providerId: SLACK_CUSTOM_BOT_PROVIDER_ID,
id: credentialId,
signingSecret: signingSecret.trim(),
botToken: botToken.trim(),
displayName: appName.trim() || undefined,
description: appDescription.trim() || undefined,
})
}
setCreated(true)
onCreated?.(credentialId)
} catch (err: unknown) {
setCreateError(getErrorMessage(err, 'Could not connect the Slack bot.'))
logger.error('Failed to add custom Slack bot credential', err)
}
}, [
isReconnect,
updateCredential,
createCredential,
workspaceId,
credentialId,
signingSecret,
botToken,
appName,
appDescription,
onCreated,
])
// Create the credential once when the final step is first reached (reachable
// only after both secrets are entered). A ref guards against re-firing on
// failure — retry is manual via the "Try again" button.
const attemptedRef = useRef(false)
useEffect(() => {
if (step !== DONE_STEP) {
attemptedRef.current = false
return
}
if (attemptedRef.current) return
attemptedRef.current = true
void runCreate()
}, [step, runCreate])
return (
<Wizard
open={open}
onOpenChange={onOpenChange}
currentStep={step}
onStepChange={setStep}
size='lg'
icon={SlackIcon}
title={isReconnect ? 'Reconnect a custom Slack bot' : 'Create a custom Slack bot'}
doneLabel='Done'
>
{/* Bot name is required so the credential name, the manifest app name, and
uniqueness all use the user's choice — never the shared Slack team name
fallback, which collides for a second bot in the same workspace. */}
<Wizard.Step title='Configure your bot' canAdvance={appName.trim().length > 0}>
<StepConfigure
appName={appName}
onAppNameChange={setAppName}
appDescription={appDescription}
onAppDescriptionChange={setAppDescription}
capabilityIds={capabilityIds}
onCapabilityIdsChange={setCapabilityIds}
/>
</Wizard.Step>
<Wizard.Step title='Create the app in Slack'>
<StepCreate manifestJson={manifestJson} />
</Wizard.Step>
<Wizard.Step title='Paste your Signing Secret' canAdvance={signingSecret.trim().length > 0}>
<StepSecret value={signingSecret} onChange={setSigningSecret} />
</Wizard.Step>
<Wizard.Step title='Install and paste your Bot Token' canAdvance={botToken.trim().length > 0}>
<StepToken value={botToken} onChange={setBotToken} />
</Wizard.Step>
<Wizard.Step title='All set'>
<StepDone pending={isPending} created={created} error={createError} onRetry={runCreate} />
</Wizard.Step>
</Wizard>
)
}
interface SubStepListProps {
children: ReactNode
}
function SubStepList({ children }: SubStepListProps) {
return <ol className='space-y-2.5'>{children}</ol>
}
interface SubStepProps {
n: number
children: ReactNode
}
function SubStep({ n, children }: SubStepProps) {
return (
<li className='flex gap-2.5'>
<span className='mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-[var(--surface-5)] font-medium text-[var(--text-secondary)] text-xs tabular-nums'>
{n}
</span>
<div className='min-w-0 flex-1 text-[var(--text-secondary)] text-sm leading-relaxed'>
{children}
</div>
</li>
)
}
interface StepConfigureProps {
appName: string
onAppNameChange: (next: string) => void
appDescription: string
onAppDescriptionChange: (next: string) => void
capabilityIds: string[]
onCapabilityIdsChange: (next: string[]) => void
}
function StepConfigure({
appName,
onAppNameChange,
appDescription,
onAppDescriptionChange,
capabilityIds,
onCapabilityIdsChange,
}: StepConfigureProps) {
const allSelected = capabilityIds.length === SLACK_CAPABILITIES.length
return (
<div className='space-y-4'>
<div className='flex flex-col gap-[9px]'>
<Label htmlFor='slack-bot-name' className='text-[var(--text-muted)] text-small'>
Bot name
</Label>
<ChipInput
id='slack-bot-name'
value={appName}
onChange={(e) => onAppNameChange(e.target.value)}
placeholder={DEFAULT_APP_NAME}
/>
</div>
<div className='flex flex-col gap-[9px]'>
<Label htmlFor='slack-bot-description' className='text-[var(--text-muted)] text-small'>
Description
</Label>
<ChipInput
id='slack-bot-description'
value={appDescription}
onChange={(e) => onAppDescriptionChange(e.target.value)}
placeholder="Optional — shown on the bot's Slack profile"
maxLength={140}
/>
</div>
<div className='flex flex-col gap-[9px]'>
<Label className='text-[var(--text-muted)] text-small'>Permissions</Label>
<ChipDropdown
multiple
fullWidth
value={capabilityIds}
onChange={onCapabilityIdsChange}
options={CAPABILITY_OPTIONS}
allLabel='No permissions'
showAllOption={false}
/>
{allSelected && (
<p className='text-[var(--text-muted)] text-caption'>
Full access the bot can read and send messages, react, upload files, and chat as an AI
assistant.
</p>
)}
</div>
</div>
)
}
interface StepCreateProps {
manifestJson: string
}
function StepCreate({ manifestJson }: StepCreateProps) {
return (
<div className='space-y-4'>
<SubStepList>
<SubStep n={1}>
<div>Copy your manifest:</div>
<div className='mt-2 overflow-hidden rounded-md border border-[var(--border-1)]'>
<div className='flex items-center justify-between border-[var(--border-1)] border-b bg-[var(--surface-4)] px-3 py-1'>
<span className='font-sans text-[var(--text-tertiary)] text-xs'>manifest.json</span>
<CopyCodeButton
code={manifestJson}
className='text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
/>
</div>
<Code.Viewer code={manifestJson} language='json' wrapText className='max-h-[180px]' />
</div>
</SubStep>
<SubStep n={2}>
Open the{' '}
<a
href='https://api.slack.com/apps'
target='_blank'
rel='noopener noreferrer'
className='text-[var(--brand-secondary)] underline underline-offset-2'
>
Slack Apps page
</a>
.
</SubStep>
<SubStep n={3}>
Click <strong>Create New App</strong> <strong>From a manifest</strong> and pick your
workspace.
</SubStep>
<SubStep n={4}>
Paste your manifest, then click <strong>Next</strong> <strong>Create</strong>.
</SubStep>
</SubStepList>
</div>
)
}
interface SecretStepProps {
value: string
onChange: (next: string) => void
}
function StepSecret({ value, onChange }: SecretStepProps) {
return (
<div className='space-y-4'>
<SubStepList>
<SubStep n={1}>
In your new Slack app, open <strong>Basic Information</strong>.
</SubStep>
<SubStep n={2}>
Find <strong>Signing Secret</strong> and click <strong>Show</strong>, then copy it.
</SubStep>
<SubStep n={3}>Paste it into the field below.</SubStep>
</SubStepList>
<SecretField
label='Signing Secret'
value={value}
onChange={onChange}
placeholder='Paste your signing secret'
/>
</div>
)
}
function StepToken({ value, onChange }: SecretStepProps) {
return (
<div className='space-y-4'>
<SubStepList>
<SubStep n={1}>
In Slack, open <strong>Install App</strong> <strong>Install to Workspace</strong> and
authorize.
</SubStep>
<SubStep n={2}>
Copy the <strong>Bot User OAuth Token</strong> (starts with <code>xoxb-</code>).
</SubStep>
<SubStep n={3}>Paste it into the field below, then click Next.</SubStep>
</SubStepList>
<SecretField label='Bot Token' value={value} onChange={onChange} placeholder='xoxb-...' />
</div>
)
}
interface SecretFieldProps {
label: string
value: string
onChange: (next: string) => void
placeholder?: string
}
function SecretField({ label, value, onChange, placeholder }: SecretFieldProps) {
return (
<div className='flex flex-col gap-[9px]'>
<Label className='text-[var(--text-muted)] text-small'>{label}</Label>
<SecretInput value={value} onChange={onChange} placeholder={placeholder} />
</div>
)
}
interface StepDoneProps {
pending: boolean
created: boolean
error: string | null
onRetry: () => void
}
function StepDone({ pending, created, error, onRetry }: StepDoneProps) {
if (pending) {
return (
<div className='flex flex-col items-center gap-3 py-10 text-center'>
<Loader2 className='size-6 animate-spin text-[var(--text-muted)]' />
<p className='text-[var(--text-secondary)] text-sm'>Verifying your bot and connecting</p>
</div>
)
}
if (error) {
return (
<div className='flex flex-col items-center gap-3 py-10 text-center'>
<p className='max-w-sm text-[var(--text-error)] text-sm leading-relaxed'>{error}</p>
<Button variant='default' onClick={onRetry}>
Try again
</Button>
</div>
)
}
if (created) {
return (
<div className='flex flex-col items-center gap-4 py-10 text-center'>
<div className='space-y-1'>
<p className='font-medium text-[var(--text-primary)] text-base'>Bot connected</p>
<p className='max-w-sm text-[var(--text-secondary)] text-sm leading-relaxed'>
It's now selectable in Slack triggers and actions across this workspace. Click Done to
finish.
</p>
</div>
</div>
)
}
return null
}
@@ -0,0 +1 @@
export { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section/integration-section'
@@ -0,0 +1,24 @@
import type { ReactNode } from 'react'
interface IntegrationSectionProps {
label: string
children: ReactNode
}
/**
* Labeled section used throughout the integrations surface. Renders a small
* caption, a divider, and a responsive auto-fit grid for its children so the
* vertical rhythm stays consistent across the integrations list, the connected
* credentials list, and the integration detail page templates.
*/
export function IntegrationSection({ label, children }: IntegrationSectionProps) {
return (
<section className='flex flex-col'>
<span className='pl-0.5 text-[var(--text-muted)] text-small'>{label}</span>
<div className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
<div className='-mx-2 grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-x-2 gap-y-0.5'>
{children}
</div>
</section>
)
}
@@ -0,0 +1 @@
export { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header/integration-tabs-header'
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { ChipLink } from '@sim/emcn'
interface IntegrationTabsHeaderProps {
active: 'integrations' | 'skills'
workspaceId: string
rightSlot?: ReactNode
}
/**
* Top-of-page chip header shared by the Integrations and Skills pages.
* Highlights the active tab and links to the sibling tab; `rightSlot` lets
* each page render its own trailing actions (e.g. an "Add skill" button).
*/
export function IntegrationTabsHeader({
active,
workspaceId,
rightSlot,
}: IntegrationTabsHeaderProps) {
return (
<div className='flex flex-shrink-0 items-center bg-[var(--bg)] px-[16px] pt-[8.5px] pb-[8.5px]'>
<ChipLink href={`/workspace/${workspaceId}/integrations`} active={active === 'integrations'}>
Integrations
</ChipLink>
<ChipLink href={`/workspace/${workspaceId}/skills`} active={active === 'skills'}>
Skills
</ChipLink>
{rightSlot && <div className='ml-auto flex items-center'>{rightSlot}</div>}
</div>
)
}
@@ -0,0 +1,4 @@
export {
IntegrationsShowcase,
IntegrationTile,
} from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase/integrations-showcase'
@@ -0,0 +1,153 @@
import type { ComponentType } from 'react'
import { cn } from '@sim/emcn'
import { getBlock } from '@/blocks'
import { getTileIconColorClass } from '@/blocks/icon-color'
/**
* URL-encoded SVG used as a mask to carve the bottom-right notch out of the
* showcase's grid background, exposing the "Explore in chat" CTA underneath.
*/
const SHOWCASE_MASK_SVG = encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 144"><path d="M0 0L192 0L192 92A12 12 0 0 1 180 104L60 104A12 12 0 0 0 48 116L48 132A12 12 0 0 1 36 144L0 144Z" fill="white"/></svg>'
)
/**
* Composite `mask-image` value: a full-opacity gradient for the main area and
* the encoded SVG for the right-edge notch.
*/
const SHOWCASE_MASK_IMAGE = `linear-gradient(white, white), url("data:image/svg+xml,${SHOWCASE_MASK_SVG}")`
/**
* Hand-tuned SVG path that traces the showcase's outer border around the
* bottom-right notch, half-pixel offset so it renders crisply at 1px stroke.
*/
const SHOWCASE_OUTLINE_PATH =
'M 0 0.5 L 180 0.5 A 11.5 11.5 0 0 1 191.5 12 L 191.5 92 A 12 12 0 0 1 180 104 L 60 104 A 12 12 0 0 0 48 116 L 48 132 A 12 12 0 0 1 36 143.5 L 0 143.5'
/**
* Fixed grid coordinates for the brand tiles displayed inside the showcase.
* Coordinates are 1-based and match the 48px CSS grid.
*/
const SHOWCASE_TILES = [
{ id: 'slack', col: 2, row: 1 },
{ id: 'outlook', col: 5, row: 1 },
{ id: 'notion', col: 8, row: 1 },
{ id: 'linear', col: 10, row: 1 },
{ id: 'jira', col: 13, row: 1 },
{ id: 'google_calendar', col: 15, row: 1 },
{ id: 'airtable', col: 3, row: 2 },
{ id: 'hubspot', col: 7, row: 2 },
{ id: 'salesforce', col: 11, row: 2 },
{ id: 'microsoft_teams', col: 14, row: 2 },
{ id: 'google_sheets', col: 4, row: 3 },
{ id: 'asana', col: 6, row: 3 },
{ id: 'confluence', col: 8, row: 3 },
{ id: 'dropbox', col: 12, row: 3 },
] as const
/**
* Resolves the brand background color for a block type from the block registry.
* Returns `null` when the block is unknown or has no brand color configured.
*/
function resolveBrandTileBg(blockType: string): string | null {
return getBlock(blockType)?.bgColor || null
}
interface IntegrationTileProps {
blockType: string
icon: ComponentType<{ className?: string }>
framed?: boolean
}
/**
* Brand-colored square tile that renders a block's icon. The unframed variant
* is a 36px tile used in list rows and headers; the framed variant adds an
* outer 44px halo used inside the showcase grid.
*/
export function IntegrationTile({ blockType, icon: Icon, framed = false }: IntegrationTileProps) {
const brandBg = resolveBrandTileBg(blockType)
if (!framed) {
return (
<div className='size-9 flex-shrink-0'>
<div
className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]'
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className={cn('size-5', getTileIconColorClass(brandBg))} />
</div>
</div>
)
}
return (
<div className='size-11 flex-shrink-0 rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-sm dark:bg-[var(--surface-5)]'>
<div
className='flex size-full items-center justify-center rounded-[9px] border border-[var(--border-1)] bg-[var(--bg)]'
style={brandBg ? { background: brandBg } : undefined}
>
<Icon className={cn('size-6', getTileIconColorClass(brandBg))} />
</div>
</div>
)
}
/**
* Decorative integrations grid: a notched, masked grid background populated
* with a curated set of brand tiles. The bottom-right notch leaves room for an
* "Explore in chat" CTA rendered by `ShowcaseWithExplore`.
*/
export function IntegrationsShowcase() {
return (
<div
aria-hidden
className='relative h-[144px] w-full overflow-hidden rounded-xl shadow-[var(--shadow-overlay)]'
>
<div
className='absolute inset-0 bg-[var(--surface-4)] dark:bg-[var(--surface-5)]'
style={{
backgroundImage:
'linear-gradient(to right, var(--border-1) 1px, transparent 1px), linear-gradient(to bottom, var(--border-1) 1px, transparent 1px)',
backgroundSize: '48px 48px',
WebkitMaskImage: SHOWCASE_MASK_IMAGE,
maskImage: SHOWCASE_MASK_IMAGE,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskSize: 'calc(100% - 192px) 100%, 192px 144px',
maskSize: 'calc(100% - 192px) 100%, 192px 144px',
WebkitMaskPosition: 'top left, top right',
maskPosition: 'top left, top right',
}}
>
<div className='absolute inset-0 grid translate-x-[0.5px] translate-y-[0.5px] grid-cols-[repeat(auto-fill,48px)] grid-rows-[repeat(auto-fill,48px)]'>
{SHOWCASE_TILES.map((tile) => {
const block = getBlock(tile.id)
if (!block) return null
return (
<div
key={tile.id}
style={{ gridColumnStart: tile.col, gridRowStart: tile.row }}
className='m-0.5'
>
<IntegrationTile blockType={tile.id} icon={block.icon} framed />
</div>
)
})}
</div>
</div>
<div
className='pointer-events-none absolute top-0 bottom-0 left-0 rounded-l-xl border border-[var(--border-muted)] border-r-0'
style={{ width: 'calc(100% - 192px)' }}
/>
<svg
className='pointer-events-none absolute top-0 right-0'
width='192'
height='144'
viewBox='0 0 192 144'
fill='none'
>
<path d={SHOWCASE_OUTLINE_PATH} stroke='var(--border-muted)' strokeWidth='1' />
</svg>
</div>
)
}
@@ -0,0 +1 @@
export { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/showcase-with-explore'
@@ -0,0 +1,45 @@
'use client'
import { Chip } from '@sim/emcn'
import { ArrowRight } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { IntegrationsShowcase } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { storeCuratedPrompt } from '@/blocks/integration-matcher'
interface ShowcaseWithExploreProps {
/**
* Prompt stored for the home page chat to consume after navigation. Bare
* integration names are rewritten to `@`-mention form on store so they chip
* in the chat input (mention treatment is opt-in there).
*/
prompt: string
}
/**
* Renders the integrations showcase with an "Explore in chat" CTA pinned into
* the showcase's bottom-right mask notch. Clicking the CTA seeds the home page
* chat with `prompt` (via {@link storeCuratedPrompt} so integration names chip)
* and navigates to the workspace home.
*/
export function ShowcaseWithExplore({ prompt }: ShowcaseWithExploreProps) {
const params = useParams()
const router = useRouter()
const workspaceId = (params?.workspaceId as string) || ''
return (
<div className='relative'>
<IntegrationsShowcase />
<Chip
active
rightIcon={ArrowRight}
onClick={() => {
storeCuratedPrompt(prompt)
router.push(`/workspace/${workspaceId}/home`)
}}
className='absolute right-0 bottom-0 mx-0'
>
Explore in chat
</Chip>
</div>
)
}