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:
+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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user