From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Tue, 7 Apr 2026 16:11:31 -0700
Subject: [PATCH 01/15] v0.6.29: login improvements, posthog telemetry (#4026)
* feat(posthog): Add tracking on mothership abort (#4023)
Co-authored-by: Theodore Li
* fix(login): fix captcha headers for manual login (#4025)
* fix(signup): fix turnstile key loading
* fix(login): fix captcha header passing
* Catch user already exists, remove login form captcha
---
apps/sim/app/(auth)/signup/signup-form.tsx | 11 +++--------
.../app/workspace/[workspaceId]/home/home.tsx | 12 ++++++++++--
.../w/[workflowId]/components/panel/panel.tsx | 19 ++++++++++++++++++-
apps/sim/lib/posthog/events.ts | 5 +++++
4 files changed, 36 insertions(+), 11 deletions(-)
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index 55a0508ec1b..afb27cd729a 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -270,10 +270,8 @@ function SignupFormContent({
name: sanitizedName,
},
{
- fetchOptions: {
- headers: {
- ...(token ? { 'x-captcha-response': token } : {}),
- },
+ headers: {
+ ...(token ? { 'x-captcha-response': token } : {}),
},
onError: (ctx) => {
logger.error('Signup error:', ctx.error)
@@ -282,10 +280,7 @@ function SignupFormContent({
let errorCode = 'unknown'
if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) {
errorCode = 'user_already_exists'
- errorMessage.push(
- 'An account with this email already exists. Please sign in instead.'
- )
- setEmailError(errorMessage[0])
+ setEmailError('An account with this email already exists. Please sign in instead.')
} else if (
ctx.error.code?.includes('BAD_REQUEST') ||
ctx.error.message?.includes('Email and password sign up is not enabled')
diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
index d76f17ff454..38367339197 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx
@@ -223,6 +223,14 @@ export function Home({ chatId }: HomeProps = {}) {
posthogRef.current = posthog
}, [posthog])
+ const handleStopGeneration = useCallback(() => {
+ captureEvent(posthogRef.current, 'task_generation_aborted', {
+ workspace_id: workspaceId,
+ view: 'mothership',
+ })
+ stopGeneration()
+ }, [stopGeneration, workspaceId])
+
const handleSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
const trimmed = text.trim()
@@ -334,7 +342,7 @@ export function Home({ chatId }: HomeProps = {}) {
defaultValue={initialPrompt}
onSubmit={handleSubmit}
isSending={isSending}
- onStopGeneration={stopGeneration}
+ onStopGeneration={handleStopGeneration}
userId={session?.user?.id}
onContextAdd={handleContextAdd}
/>
@@ -359,7 +367,7 @@ export function Home({ chatId }: HomeProps = {}) {
isSending={isSending}
isReconnecting={isReconnecting}
onSubmit={handleSubmit}
- onStopGeneration={stopGeneration}
+ onStopGeneration={handleStopGeneration}
messageQueue={messageQueue}
onRemoveQueuedMessage={removeFromQueue}
onSendQueuedMessage={sendNow}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
index 4d485c763ce..da51910789b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
@@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { History, Plus, Square } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
+import { usePostHog } from 'posthog-js/react'
import { useShallow } from 'zustand/react/shallow'
import {
BubbleChatClose,
@@ -33,6 +34,7 @@ import {
import { Lock, Unlock, Upload } from '@/components/emcn/icons'
import { VariableIcon } from '@/components/icons'
import { useSession } from '@/lib/auth/auth-client'
+import { captureEvent } from '@/lib/posthog/client'
import { generateWorkflowJson } from '@/lib/workflows/operations/import-export'
import { ConversationListItem } from '@/app/workspace/[workspaceId]/components'
import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components'
@@ -101,6 +103,9 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
const params = useParams()
const workspaceId = propWorkspaceId ?? (params.workspaceId as string)
+ const posthog = usePostHog()
+ const posthogRef = useRef(posthog)
+
const panelRef = useRef(null)
const fileInputRef = useRef(null)
const { activeTab, setActiveTab, panelWidth, _hasHydrated, setHasHydrated } = usePanelStore(
@@ -264,6 +269,10 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
loadCopilotChats()
}, [loadCopilotChats])
+ useEffect(() => {
+ posthogRef.current = posthog
+ }, [posthog])
+
const handleCopilotSelectChat = useCallback((chat: { id: string; title: string | null }) => {
setCopilotChatId(chat.id)
setCopilotChatTitle(chat.title)
@@ -394,6 +403,14 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
[copilotEditQueuedMessage]
)
+ const handleCopilotStopGeneration = useCallback(() => {
+ captureEvent(posthogRef.current, 'task_generation_aborted', {
+ workspace_id: workspaceId,
+ view: 'copilot',
+ })
+ copilotStopGeneration()
+ }, [copilotStopGeneration, workspaceId])
+
const handleCopilotSubmit = useCallback(
(text: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => {
const trimmed = text.trim()
@@ -833,7 +850,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel
isSending={copilotIsSending}
isReconnecting={copilotIsReconnecting}
onSubmit={handleCopilotSubmit}
- onStopGeneration={copilotStopGeneration}
+ onStopGeneration={handleCopilotStopGeneration}
messageQueue={copilotMessageQueue}
onRemoveQueuedMessage={copilotRemoveFromQueue}
onSendQueuedMessage={copilotSendNow}
diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts
index 537a9864282..faf9895bf62 100644
--- a/apps/sim/lib/posthog/events.ts
+++ b/apps/sim/lib/posthog/events.ts
@@ -378,6 +378,11 @@ export interface PostHogEventMap {
workspace_id: string
}
+ task_generation_aborted: {
+ workspace_id: string
+ view: 'mothership' | 'copilot'
+ }
+
task_message_sent: {
workspace_id: string
has_attachments: boolean
From 320f2560972a73c27e64d9f82a1bd4e309bed131 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Mon, 4 May 2026 11:34:54 -0700
Subject: [PATCH 02/15] feat(credentials): add Atlassian service account
credentials
---
.../auth/atlassian-service-account/route.ts | 289 +++++++++++++++++
apps/sim/app/api/auth/oauth/token/route.ts | 12 +
apps/sim/app/api/auth/oauth/utils.ts | 50 +++
.../atlassian-service-account-form.tsx | 241 ++++++++++++++
.../integrations/integrations-manager.tsx | 302 ++---------------
.../integrations/service-account-form.tsx | 303 ++++++++++++++++++
apps/sim/hooks/queries/credentials.ts | 17 +
apps/sim/hooks/selectors/helpers.ts | 27 ++
.../providers/confluence/selectors.ts | 16 +-
.../selectors/providers/jira/selectors.ts | 30 +-
apps/sim/lib/api/client/request.ts | 9 +
.../contracts/atlassian-service-account.ts | 30 ++
apps/sim/lib/api/contracts/index.ts | 1 +
.../lib/api/contracts/oauth-connections.ts | 2 +
apps/sim/lib/api/contracts/selectors/oauth.ts | 2 +
apps/sim/lib/oauth/oauth.ts | 19 ++
apps/sim/lib/oauth/types.ts | 2 +
apps/sim/tools/index.ts | 6 +
scripts/check-api-validation-contracts.ts | 4 +-
19 files changed, 1066 insertions(+), 296 deletions(-)
create mode 100644 apps/sim/app/api/auth/atlassian-service-account/route.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/integrations/service-account-form.tsx
create mode 100644 apps/sim/lib/api/contracts/atlassian-service-account.ts
diff --git a/apps/sim/app/api/auth/atlassian-service-account/route.ts b/apps/sim/app/api/auth/atlassian-service-account/route.ts
new file mode 100644
index 00000000000..a57b11bc03a
--- /dev/null
+++ b/apps/sim/app/api/auth/atlassian-service-account/route.ts
@@ -0,0 +1,289 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { db } from '@sim/db'
+import { credential, credentialMember, workspace } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
+import { and, eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { createAtlassianServiceAccountContract } from '@/lib/api/contracts/atlassian-service-account'
+import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { encryptSecret } from '@/lib/core/security/encryption'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
+import { captureServerEvent } from '@/lib/posthog/server'
+import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('AtlassianServiceAccountAPI')
+
+const ATLASSIAN_PROVIDER_ID = 'atlassian-service-account'
+
+/**
+ * Discrete validation failure codes returned to the client. The UI maps each
+ * code to a human message; raw Atlassian response bodies stay in server logs.
+ */
+type AtlassianValidationCode = 'invalid_credentials' | 'site_not_found' | 'atlassian_unavailable'
+
+class AtlassianValidationError extends Error {
+ constructor(
+ public readonly code: AtlassianValidationCode,
+ public readonly status: number,
+ public readonly logDetail?: Record
+ ) {
+ super(code)
+ this.name = 'AtlassianValidationError'
+ }
+}
+
+function buildBearerAuthHeader(apiToken: string): string {
+ return `Bearer ${apiToken}`
+}
+
+function normalizeDomain(rawDomain: string): string {
+ return rawDomain.replace(/^https?:\/\//, '').replace(/\/+$/, '')
+}
+
+/**
+ * Validates an Atlassian service account scoped API token.
+ *
+ * Scoped service-account tokens cannot call `api.atlassian.com/oauth/token/accessible-resources`
+ * (that endpoint is for OAuth-3LO tokens). Instead we use the public, unauthenticated
+ * `tenant_info` discovery endpoint to resolve cloudId from the site domain, then verify
+ * the token works by hitting `/myself` through the gateway.
+ */
+async function validateAtlassianServiceAccount(
+ apiToken: string,
+ domain: string
+): Promise<{ accountId: string; displayName: string; cloudId: string }> {
+ const tenantInfoRes = await fetch(`https://${domain}/_edge/tenant_info`, {
+ headers: { Accept: 'application/json' },
+ })
+ if (tenantInfoRes.status === 404) {
+ throw new AtlassianValidationError('site_not_found', 404, {
+ step: 'tenant_info',
+ domain,
+ })
+ }
+ if (!tenantInfoRes.ok) {
+ throw new AtlassianValidationError('atlassian_unavailable', tenantInfoRes.status, {
+ step: 'tenant_info',
+ domain,
+ body: (await tenantInfoRes.text()).slice(0, 200),
+ })
+ }
+ const tenantInfo = (await tenantInfoRes.json()) as { cloudId?: string }
+ if (!tenantInfo.cloudId) {
+ throw new AtlassianValidationError('atlassian_unavailable', 502, {
+ step: 'tenant_info',
+ reason: 'missing cloudId in response',
+ domain,
+ })
+ }
+ const cloudId = tenantInfo.cloudId
+
+ const auth = buildBearerAuthHeader(apiToken)
+ const myselfRes = await fetch(`https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/myself`, {
+ headers: { Authorization: auth, Accept: 'application/json' },
+ })
+ if (myselfRes.status === 401 || myselfRes.status === 403) {
+ throw new AtlassianValidationError('invalid_credentials', myselfRes.status, {
+ step: 'myself',
+ cloudId,
+ body: (await myselfRes.text()).slice(0, 200),
+ })
+ }
+ if (!myselfRes.ok) {
+ throw new AtlassianValidationError('atlassian_unavailable', myselfRes.status, {
+ step: 'myself',
+ cloudId,
+ body: (await myselfRes.text()).slice(0, 200),
+ })
+ }
+
+ const myself = (await myselfRes.json()) as {
+ accountId?: string
+ displayName?: string
+ emailAddress?: string
+ }
+ if (!myself.accountId) {
+ throw new AtlassianValidationError('atlassian_unavailable', 502, {
+ step: 'myself',
+ reason: 'missing accountId in response',
+ })
+ }
+
+ return {
+ accountId: myself.accountId,
+ displayName: myself.displayName || myself.emailAddress || domain,
+ cloudId,
+ }
+}
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+ const session = await getSession()
+
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ try {
+ const parsed = await parseRequest(
+ createAtlassianServiceAccountContract,
+ request,
+ {},
+ {
+ validationErrorResponse: (error) =>
+ NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }),
+ }
+ )
+ if (!parsed.success) return parsed.response
+
+ const { workspaceId, apiToken, domain, displayName, description } = parsed.data.body
+
+ const access = await checkWorkspaceAccess(workspaceId, session.user.id)
+ if (!access.canWrite) {
+ return NextResponse.json({ error: 'Write permission required' }, { status: 403 })
+ }
+
+ const normalizedDomain = normalizeDomain(domain)
+
+ const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
+
+ const resolvedDisplayName = displayName?.trim() || validation.displayName
+ const resolvedDescription = description?.trim() || null
+
+ const [existing] = await db
+ .select({ id: credential.id })
+ .from(credential)
+ .where(
+ and(
+ eq(credential.workspaceId, workspaceId),
+ eq(credential.type, 'service_account'),
+ eq(credential.providerId, ATLASSIAN_PROVIDER_ID),
+ eq(credential.displayName, resolvedDisplayName)
+ )
+ )
+ .limit(1)
+ if (existing) {
+ return NextResponse.json(
+ {
+ code: 'duplicate_display_name',
+ error: 'A credential with that name already exists in this workspace.',
+ },
+ { status: 409 }
+ )
+ }
+
+ const blob = JSON.stringify({
+ type: 'atlassian_service_account',
+ apiToken,
+ domain: normalizedDomain,
+ cloudId: validation.cloudId,
+ atlassianAccountId: validation.accountId,
+ })
+ const { encrypted } = await encryptSecret(blob)
+
+ const now = new Date()
+ const credentialId = generateId()
+
+ const [workspaceRow] = await db
+ .select({ ownerId: workspace.ownerId })
+ .from(workspace)
+ .where(eq(workspace.id, workspaceId))
+ .limit(1)
+
+ await db.transaction(async (tx) => {
+ await tx.insert(credential).values({
+ id: credentialId,
+ workspaceId,
+ type: 'service_account',
+ displayName: resolvedDisplayName,
+ description: resolvedDescription,
+ providerId: ATLASSIAN_PROVIDER_ID,
+ accountId: null,
+ envKey: null,
+ envOwnerUserId: null,
+ encryptedServiceAccountKey: encrypted,
+ createdBy: session.user.id,
+ createdAt: now,
+ updatedAt: now,
+ })
+
+ const memberUserIds = workspaceRow?.ownerId
+ ? await getWorkspaceMemberUserIds(workspaceId)
+ : [session.user.id]
+
+ const userIds = memberUserIds.length > 0 ? memberUserIds : [session.user.id]
+ for (const userId of userIds) {
+ await tx.insert(credentialMember).values({
+ id: generateId(),
+ credentialId,
+ userId,
+ role: userId === workspaceRow?.ownerId || userId === session.user.id ? 'admin' : 'member',
+ status: 'active',
+ joinedAt: now,
+ invitedBy: session.user.id,
+ createdAt: now,
+ updatedAt: now,
+ })
+ }
+ })
+
+ const [created] = await db
+ .select()
+ .from(credential)
+ .where(eq(credential.id, credentialId))
+ .limit(1)
+
+ captureServerEvent(
+ session.user.id,
+ 'credential_connected',
+ {
+ credential_type: 'service_account',
+ provider_id: ATLASSIAN_PROVIDER_ID,
+ workspace_id: workspaceId,
+ },
+ {
+ groups: { workspace: workspaceId },
+ setOnce: { first_credential_connected_at: new Date().toISOString() },
+ }
+ )
+
+ recordAudit({
+ workspaceId,
+ actorId: session.user.id,
+ actorName: session.user.name,
+ actorEmail: session.user.email,
+ action: AuditAction.CREDENTIAL_CREATED,
+ resourceType: AuditResourceType.CREDENTIAL,
+ resourceId: credentialId,
+ resourceName: resolvedDisplayName,
+ description: `Created Atlassian service account credential "${resolvedDisplayName}"`,
+ metadata: {
+ credentialType: 'service_account',
+ providerId: ATLASSIAN_PROVIDER_ID,
+ atlassianDomain: normalizedDomain,
+ atlassianCloudId: validation.cloudId,
+ },
+ request,
+ })
+
+ return NextResponse.json({ credential: created }, { status: 201 })
+ } catch (error) {
+ if (error instanceof AtlassianValidationError) {
+ logger.warn(`[${requestId}] Atlassian credential rejected: ${error.code}`, {
+ code: error.code,
+ upstreamStatus: error.status,
+ ...error.logDetail,
+ })
+ return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
+ }
+ logger.error(`[${requestId}] Failed to create Atlassian service account credential`, error)
+ return NextResponse.json(
+ { code: 'unexpected_error', error: 'unexpected_error' },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts
index 1ab26f84159..232b30b0d3c 100644
--- a/apps/sim/app/api/auth/oauth/token/route.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.ts
@@ -10,6 +10,7 @@ import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
+ getAtlassianServiceAccountSecret,
getCredential,
getOAuthToken,
getServiceAccountToken,
@@ -118,6 +119,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
try {
+ if (resolved.providerId === 'atlassian-service-account') {
+ const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
+ return NextResponse.json(
+ {
+ accessToken: secret.apiToken,
+ cloudId: secret.cloudId,
+ domain: secret.domain,
+ },
+ { status: 200 }
+ )
+ }
const accessToken = await getServiceAccountToken(
resolved.credentialId,
scopes ?? [],
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index 38b84a59777..cd8385b3aa4 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -44,6 +44,7 @@ export interface ResolvedCredential {
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
+ providerId?: string
}
/**
@@ -61,6 +62,7 @@ export async function resolveOAuthAccountId(
type: credential.type,
accountId: credential.accountId,
workspaceId: credential.workspaceId,
+ providerId: credential.providerId,
})
.from(credential)
.where(eq(credential.id, credentialId))
@@ -73,6 +75,7 @@ export async function resolveOAuthAccountId(
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
+ providerId: credentialRow.providerId ?? undefined,
usedCredentialTable: true,
}
}
@@ -208,6 +211,49 @@ export async function getServiceAccountToken(
return tokenData.access_token
}
+interface AtlassianServiceAccountSecret {
+ type: 'atlassian_service_account'
+ apiToken: string
+ domain: string
+ cloudId: string
+ atlassianAccountId?: string
+}
+
+/**
+ * Loads the decrypted Atlassian service account secret blob for a credential.
+ * Throws if the credential is missing or not an Atlassian service account.
+ */
+export async function getAtlassianServiceAccountSecret(
+ credentialId: string
+): Promise {
+ const [credentialRow] = await db
+ .select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
+ .from(credential)
+ .where(eq(credential.id, credentialId))
+ .limit(1)
+
+ if (!credentialRow?.encryptedServiceAccountKey) {
+ throw new Error('Atlassian service account secret not found')
+ }
+
+ const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
+ const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret
+ if (parsed.type !== 'atlassian_service_account' || !parsed.apiToken || !parsed.cloudId) {
+ throw new Error('Stored Atlassian service account secret is malformed')
+ }
+ return parsed
+}
+
+/**
+ * For Atlassian service accounts, the API token IS the access token —
+ * blocks call api.atlassian.com/ex/jira/{cloudId}/... with `Authorization: Bearer {apiToken}`.
+ * No exchange or refresh is needed; we just decrypt and return the raw token.
+ */
+export async function getAtlassianServiceAccountToken(credentialId: string): Promise {
+ const secret = await getAtlassianServiceAccountSecret(credentialId)
+ return secret.apiToken
+}
+
/**
* Safely inserts an account record, handling duplicate constraint violations gracefully.
* If a duplicate is detected (unique constraint violation), logs a warning and returns success.
@@ -374,6 +420,10 @@ export async function refreshAccessTokenIfNeeded(
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
+ if (resolved.providerId === 'atlassian-service-account') {
+ logger.info(`[${requestId}] Using Atlassian service account token for credential`)
+ return getAtlassianServiceAccountToken(resolved.credentialId)
+ }
if (!scopes?.length) {
throw new Error('Scopes are required for service account credentials')
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx
new file mode 100644
index 00000000000..fc07a67e75d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx
@@ -0,0 +1,241 @@
+'use client'
+
+import { createElement, useState } from 'react'
+import {
+ Badge,
+ Button,
+ Input,
+ Label,
+ ModalBody,
+ ModalFooter,
+ ModalHeader,
+ Textarea,
+ toast,
+} from '@/components/emcn'
+import { isApiClientError } from '@/lib/api/client/errors'
+import type { OAuthServiceConfig } from '@/lib/oauth'
+
+interface AtlassianServiceAccountFormProps {
+ service: OAuthServiceConfig | null
+ serviceLabel: string
+ workspaceId: string
+ onBack: () => void
+ onCreate: (input: {
+ workspaceId: string
+ apiToken: string
+ domain: string
+ displayName?: string
+ description?: string
+ }) => Promise
+ onCreated: () => void
+}
+
+const DOMAIN_HINT_REGEX = /^[a-z0-9-]+\.atlassian\.net$/i
+
+const ERROR_MESSAGES: Record = {
+ 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 normalizeDomain(raw: string): string {
+ return raw
+ .trim()
+ .replace(/^https?:\/\//, '')
+ .replace(/\/+$/, '')
+}
+
+function messageForError(err: unknown): string {
+ if (isApiClientError(err) && err.code && ERROR_MESSAGES[err.code]) {
+ return ERROR_MESSAGES[err.code]
+ }
+ return FALLBACK_ERROR_MESSAGE
+}
+
+export function AtlassianServiceAccountForm({
+ service,
+ serviceLabel,
+ workspaceId,
+ onBack,
+ onCreate,
+ onCreated,
+}: AtlassianServiceAccountFormProps) {
+ const [apiToken, setApiToken] = useState('')
+ const [domain, setDomain] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [description, setDescription] = useState('')
+ const [error, setError] = useState(null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const trimmedToken = apiToken.trim()
+ const normalizedDomain = normalizeDomain(domain)
+
+ const canSubmit = trimmedToken.length > 0 && normalizedDomain.length > 0 && !isSubmitting
+ const showDomainHint = normalizedDomain.length > 0 && !DOMAIN_HINT_REGEX.test(normalizedDomain)
+
+ const handleSubmit = async () => {
+ setError(null)
+ if (!trimmedToken || !normalizedDomain) return
+
+ setIsSubmitting(true)
+ try {
+ await onCreate({
+ workspaceId,
+ apiToken: trimmedToken,
+ domain: normalizedDomain,
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ })
+ toast.success('Service account connected')
+ onCreated()
+ } catch (err) {
+ setError(messageForError(err))
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+ <>
+
+
+
+ Add {serviceLabel}
+
+
+
+ {error && (
+
+
+ {error}
+
+
+ )}
+
+
+
+ {service && createElement(service.icon, { className: 'h-[18px] w-[18px]' })}
+
+
+
+ Add {service?.name || 'Atlassian service account'}
+
+
+ {service?.description ||
+ 'Use a scoped API token from a service account in admin.atlassian.com.'}
+
+
+ View setup guide
+
+
+
+
+
+
+
{
+ setApiToken(event.target.value)
+ setError(null)
+ }}
+ placeholder='Paste API token'
+ autoComplete='off'
+ data-lpignore='true'
+ className='mt-1.5'
+ />
+
+ Issued from the service account's profile in admin.atlassian.com. Stored encrypted.
+
+
+
+
+
+
{
+ setDomain(event.target.value)
+ setError(null)
+ }}
+ placeholder='your-team.atlassian.net'
+ autoComplete='off'
+ data-lpignore='true'
+ className='mt-1.5'
+ />
+ {showDomainHint && (
+
+ Atlassian sites usually look like your-team.atlassian.net. We'll strip
+ any leading https://.
+
+ )}
+
+
+
+
+ setDisplayName(event.target.value)}
+ placeholder="Defaults to the account's Atlassian display name"
+ autoComplete='off'
+ data-lpignore='true'
+ className='mt-1.5'
+ />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx
index 27bb15a58b6..673af31063f 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx
@@ -35,8 +35,11 @@ import {
import { getCanonicalScopesForProvider, getServiceConfigByProviderId } from '@/lib/oauth'
import { getScopeDescription } from '@/lib/oauth/utils'
import { getUserColor } from '@/lib/workspaces/colors'
+import { AtlassianServiceAccountForm } from '@/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form'
import { CredentialSkeleton } from '@/app/workspace/[workspaceId]/settings/components/integrations/credential-skeleton'
+import { ServiceAccountForm } from '@/app/workspace/[workspaceId]/settings/components/integrations/service-account-form'
import {
+ useCreateAtlassianServiceAccount,
useCreateCredentialDraft,
useCreateWorkspaceCredential,
useDeleteWorkspaceCredential,
@@ -108,12 +111,6 @@ export function IntegrationsManager() {
| { type: 'kb-connectors'; knowledgeBaseId: string }
| undefined
>(undefined)
- const [saJsonInput, setSaJsonInput] = useState('')
- const [saDisplayName, setSaDisplayName] = useState('')
- const [saDescription, setSaDescription] = useState('')
- const [saError, setSaError] = useState(null)
- const [saIsSubmitting, setSaIsSubmitting] = useState(false)
- const [saDragActive, setSaDragActive] = useState(false)
const { data: session } = useSession()
const currentUserId = session?.user?.id || ''
@@ -149,6 +146,7 @@ export function IntegrationsManager() {
const createDraft = useCreateCredentialDraft()
const createCredential = useCreateWorkspaceCredential()
+ const createAtlassianServiceAccount = useCreateAtlassianServiceAccount()
const updateCredential = useUpdateWorkspaceCredential()
const deleteCredential = useDeleteWorkspaceCredential()
const upsertMember = useUpsertWorkspaceCredentialMember()
@@ -384,10 +382,6 @@ export function IntegrationsManager() {
setCreateError(null)
setCreateStep(1)
setServiceSearch('')
- setSaJsonInput('')
- setSaDisplayName('')
- setSaDescription('')
- setSaError(null)
pendingReturnOriginRef.current = undefined
}
@@ -651,117 +645,6 @@ export function IntegrationsManager() {
setShowCreateModal(true)
}, [])
- const validateServiceAccountJson = (raw: string): { valid: boolean; error?: string } => {
- let parsed: Record
- try {
- parsed = JSON.parse(raw)
- } catch {
- return { valid: false, error: 'Invalid JSON. Paste the full service account key file.' }
- }
- if (parsed.type !== 'service_account') {
- return { valid: false, error: 'JSON key must have "type": "service_account".' }
- }
- if (!parsed.client_email || typeof parsed.client_email !== 'string') {
- return { valid: false, error: 'Missing "client_email" field.' }
- }
- if (!parsed.private_key || typeof parsed.private_key !== 'string') {
- return { valid: false, error: 'Missing "private_key" field.' }
- }
- if (!parsed.project_id || typeof parsed.project_id !== 'string') {
- return { valid: false, error: 'Missing "project_id" field.' }
- }
- return { valid: true }
- }
-
- const handleCreateServiceAccount = async () => {
- setSaError(null)
- const trimmed = saJsonInput.trim()
- if (!trimmed) {
- setSaError('Paste the service account JSON key.')
- return
- }
- const validation = validateServiceAccountJson(trimmed)
- if (!validation.valid) {
- setSaError(validation.error ?? 'Invalid JSON')
- return
- }
- setSaIsSubmitting(true)
- try {
- await createCredential.mutateAsync({
- workspaceId,
- type: 'service_account',
- displayName: saDisplayName.trim() || undefined,
- description: saDescription.trim() || undefined,
- serviceAccountJson: trimmed,
- })
- setShowCreateModal(false)
- resetCreateForm()
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : 'Failed to add service account'
- setSaError(message)
- logger.error('Failed to create service account credential', error)
- } finally {
- setSaIsSubmitting(false)
- }
- }
-
- const readSaJsonFile = useCallback(
- (file: File) => {
- if (!file.name.endsWith('.json')) {
- setSaError('Only .json files are supported')
- return
- }
- const reader = new FileReader()
- reader.onload = (e) => {
- const text = e.target?.result
- if (typeof text === 'string') {
- setSaJsonInput(text)
- setSaError(null)
- try {
- const parsed = JSON.parse(text)
- if (parsed.client_email && !saDisplayName.trim()) {
- setSaDisplayName(parsed.client_email)
- }
- } catch {
- // validation will catch this on submit
- }
- }
- }
- reader.readAsText(file)
- },
- [saDisplayName]
- )
-
- const handleSaFileUpload = (event: React.ChangeEvent) => {
- const file = event.target.files?.[0]
- if (!file) return
- readSaJsonFile(file)
- event.target.value = ''
- }
-
- const handleSaDragOver = useCallback((event: React.DragEvent) => {
- event.preventDefault()
- event.stopPropagation()
- setSaDragActive(true)
- }, [])
-
- const handleSaDragLeave = useCallback((event: React.DragEvent) => {
- event.preventDefault()
- event.stopPropagation()
- setSaDragActive(false)
- }, [])
-
- const handleSaDrop = useCallback(
- (event: React.DragEvent) => {
- event.preventDefault()
- event.stopPropagation()
- setSaDragActive(false)
- const file = event.dataTransfer.files[0]
- if (file) readSaJsonFile(file)
- },
- [readSaJsonFile]
- )
-
const filteredServices = useMemo(() => {
if (!serviceSearch.trim()) return oauthServiceOptions
const q = serviceSearch.toLowerCase()
@@ -965,160 +848,31 @@ export function IntegrationsManager() {
>
+ ) : selectedOAuthService?.providerId === 'atlassian-service-account' ? (
+ setCreateStep(1)}
+ onCreate={(input) => createAtlassianServiceAccount.mutateAsync(input)}
+ onCreated={() => {
+ setShowCreateModal(false)
+ resetCreateForm()
+ }}
+ />
) : (
- <>
-
-
-
-
- Add {selectedOAuthService?.name || resolveProviderLabel(createOAuthProviderId)}
-
-
-
-
- {saError && (
-
-
- {saError}
-
-
- )}
-
-
-
- {selectedOAuthService &&
- createElement(selectedOAuthService.icon, { className: 'h-[18px] w-[18px]' })}
-
-
-
- Add {selectedOAuthService?.name || 'service account'}
-
-
- {selectedOAuthService?.description || 'Paste or upload the JSON key file'}
-
-
- View setup guide
-
-
-
-
-
-
-
- {saDragActive && (
-
-
- Drop JSON key file here
-
-
- )}
-
-
-
-
-
-
-
- setSaDisplayName(event.target.value)}
- placeholder='Auto-populated from client_email'
- autoComplete='off'
- data-lpignore='true'
- className='mt-1.5'
- />
-
-
-
-
-
-
-
-
-
-
- >
+ setCreateStep(1)}
+ onCreate={(input) => createCredential.mutateAsync(input)}
+ onCreated={() => {
+ setShowCreateModal(false)
+ resetCreateForm()
+ }}
+ />
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/service-account-form.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/service-account-form.tsx
new file mode 100644
index 00000000000..9e12aae14e3
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/service-account-form.tsx
@@ -0,0 +1,303 @@
+'use client'
+
+import { createElement, useState } from 'react'
+import {
+ Badge,
+ Button,
+ Input,
+ Label,
+ ModalBody,
+ ModalFooter,
+ ModalHeader,
+ Textarea,
+} from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
+import type { OAuthServiceConfig } from '@/lib/oauth'
+
+interface ServiceAccountFormProps {
+ service: OAuthServiceConfig | null
+ serviceLabel: string
+ workspaceId: string
+ setupGuideHref?: string
+ onBack: () => void
+ onCreate: (input: {
+ workspaceId: string
+ type: 'service_account'
+ serviceAccountJson: string
+ displayName?: string
+ description?: string
+ }) => Promise
+ onCreated: () => void
+}
+
+interface ValidationResult {
+ valid: boolean
+ error?: string
+}
+
+function validateServiceAccountJson(raw: string): ValidationResult {
+ let parsed: Record
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ return { valid: false, error: 'Invalid JSON. Paste the full service account key file.' }
+ }
+ if (parsed.type !== 'service_account') {
+ return { valid: false, error: 'JSON key must have "type": "service_account".' }
+ }
+ if (!parsed.client_email || typeof parsed.client_email !== 'string') {
+ return { valid: false, error: 'Missing "client_email" field.' }
+ }
+ if (!parsed.private_key || typeof parsed.private_key !== 'string') {
+ return { valid: false, error: 'Missing "private_key" field.' }
+ }
+ if (!parsed.project_id || typeof parsed.project_id !== 'string') {
+ return { valid: false, error: 'Missing "project_id" field.' }
+ }
+ return { valid: true }
+}
+
+export function ServiceAccountForm({
+ service,
+ serviceLabel,
+ workspaceId,
+ setupGuideHref,
+ onBack,
+ onCreate,
+ onCreated,
+}: ServiceAccountFormProps) {
+ const [jsonInput, setJsonInput] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [description, setDescription] = useState('')
+ const [error, setError] = useState(null)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [dragActive, setDragActive] = useState(false)
+
+ const readJsonFile = (file: File) => {
+ if (!file.name.endsWith('.json')) {
+ setError('Only .json files are supported')
+ return
+ }
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const text = e.target?.result
+ if (typeof text !== 'string') return
+ setJsonInput(text)
+ setError(null)
+ if (!displayName.trim()) {
+ try {
+ const parsed = JSON.parse(text)
+ if (parsed.client_email) setDisplayName(parsed.client_email)
+ } catch {
+ // surface validation on submit instead
+ }
+ }
+ }
+ reader.readAsText(file)
+ }
+
+ const handleFileUpload = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+ readJsonFile(file)
+ event.target.value = ''
+ }
+
+ const handleDragOver = (event: React.DragEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ setDragActive(true)
+ }
+
+ const handleDragLeave = (event: React.DragEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ setDragActive(false)
+ }
+
+ const handleDrop = (event: React.DragEvent) => {
+ event.preventDefault()
+ event.stopPropagation()
+ setDragActive(false)
+ const file = event.dataTransfer.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 = validateServiceAccountJson(trimmed)
+ if (!validation.valid) {
+ setError(validation.error ?? 'Invalid JSON')
+ return
+ }
+ setIsSubmitting(true)
+ try {
+ await onCreate({
+ workspaceId,
+ type: 'service_account',
+ displayName: displayName.trim() || undefined,
+ description: description.trim() || undefined,
+ serviceAccountJson: trimmed,
+ })
+ onCreated()
+ } catch (err: unknown) {
+ const message = err instanceof Error ? err.message : 'Failed to add service account'
+ setError(message)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+ <>
+
+
+
+ Add {serviceLabel}
+
+
+
+ {error && (
+
+
+ {error}
+
+
+ )}
+
+
+
+ {service && createElement(service.icon, { className: 'h-[18px] w-[18px]' })}
+
+
+
+ Add {service?.name || 'service account'}
+
+
+ {service?.description || 'Paste or upload the JSON key file'}
+
+ {setupGuideHref && (
+
+ View setup guide
+
+ )}
+
+
+
+
+
+
+ {dragActive && (
+
+
+ Drop JSON key file here
+
+
+ )}
+
+
+
+
+
+
+
+ setDisplayName(event.target.value)}
+ placeholder='Auto-populated from client_email'
+ autoComplete='off'
+ data-lpignore='true'
+ className='mt-1.5'
+ />
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts
index cade11bb7a7..0c0e05dbfcf 100644
--- a/apps/sim/hooks/queries/credentials.ts
+++ b/apps/sim/hooks/queries/credentials.ts
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import type { ContractBodyInput, ContractQueryInput } from '@/lib/api/contracts'
import {
+ createAtlassianServiceAccountContract,
createCredentialDraftContract,
createWorkspaceCredentialContract,
deleteWorkspaceCredentialContract,
@@ -131,6 +132,22 @@ export function useCreateCredentialDraft() {
})
}
+export function useCreateAtlassianServiceAccount() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (
+ payload: ContractBodyInput
+ ) => {
+ return requestJson(createAtlassianServiceAccountContract, { body: payload })
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: workspaceCredentialKeys.lists() })
+ queryClient.invalidateQueries({ queryKey: OAUTH_CREDENTIALS_KEY })
+ },
+ })
+}
+
export function useCreateWorkspaceCredential() {
const queryClient = useQueryClient()
diff --git a/apps/sim/hooks/selectors/helpers.ts b/apps/sim/hooks/selectors/helpers.ts
index 837205385af..20afeaa58f9 100644
--- a/apps/sim/hooks/selectors/helpers.ts
+++ b/apps/sim/hooks/selectors/helpers.ts
@@ -1,6 +1,12 @@
import { requestJson } from '@/lib/api/client/request'
import { oauthTokenContract } from '@/lib/api/contracts/selectors'
+export interface OAuthTokenBundle {
+ accessToken: string
+ cloudId?: string
+ domain?: string
+}
+
export async function fetchOAuthToken(
credentialId: string,
workflowId?: string
@@ -11,3 +17,24 @@ export async function fetchOAuthToken(
})
return token.accessToken ?? null
}
+
+/**
+ * Same as fetchOAuthToken but also returns provider-specific extras —
+ * notably `cloudId` for Atlassian service accounts whose tokens cannot
+ * call api.atlassian.com/oauth/token/accessible-resources.
+ */
+export async function fetchOAuthTokenBundle(
+ credentialId: string,
+ workflowId?: string
+): Promise {
+ if (!credentialId) return null
+ const token = await requestJson(oauthTokenContract, {
+ body: { credentialId, workflowId },
+ })
+ if (!token.accessToken) return null
+ return {
+ accessToken: token.accessToken,
+ cloudId: token.cloudId,
+ domain: token.domain,
+ }
+}
diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts
index 2daaf5290c7..220a50698c1 100644
--- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts
+++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts
@@ -1,6 +1,6 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
-import { fetchOAuthToken } from '@/hooks/selectors/helpers'
+import { fetchOAuthTokenBundle } from '@/hooks/selectors/helpers'
import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
@@ -67,14 +67,15 @@ export const confluenceSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
- const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
- if (!accessToken) {
+ const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ if (!bundle) {
throw new Error('Missing Confluence access token')
}
const data = await requestJson(selectorContracts.confluencePagesSelectorContract, {
body: {
domain,
- accessToken,
+ accessToken: bundle.accessToken,
+ cloudId: bundle.cloudId,
title: search,
},
signal,
@@ -88,14 +89,15 @@ export const confluenceSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
- const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
- if (!accessToken) {
+ const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ if (!bundle) {
throw new Error('Missing Confluence access token')
}
const data = await requestJson(selectorContracts.confluencePageSelectorContract, {
body: {
domain,
- accessToken,
+ accessToken: bundle.accessToken,
+ cloudId: bundle.cloudId,
pageId: detailId,
},
signal,
diff --git a/apps/sim/hooks/selectors/providers/jira/selectors.ts b/apps/sim/hooks/selectors/providers/jira/selectors.ts
index 9c8477e31b2..af11d8d7a6d 100644
--- a/apps/sim/hooks/selectors/providers/jira/selectors.ts
+++ b/apps/sim/hooks/selectors/providers/jira/selectors.ts
@@ -1,6 +1,6 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
-import { fetchOAuthToken } from '@/hooks/selectors/helpers'
+import { fetchOAuthTokenBundle } from '@/hooks/selectors/helpers'
import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
@@ -23,14 +23,15 @@ export const jiraSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
- const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
- if (!accessToken) {
+ const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraProjectsSelectorContract, {
query: {
domain,
- accessToken,
+ accessToken: bundle.accessToken,
+ cloudId: bundle.cloudId,
query: search,
},
signal,
@@ -44,14 +45,15 @@ export const jiraSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
- const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
- if (!accessToken) {
+ const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraProjectSelectorContract, {
body: {
domain,
- accessToken,
+ accessToken: bundle.accessToken,
+ cloudId: bundle.cloudId,
projectId: detailId,
},
signal,
@@ -82,14 +84,15 @@ export const jiraSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
- const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
- if (!accessToken) {
+ const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraIssuesSelectorContract, {
query: {
domain,
- accessToken,
+ accessToken: bundle.accessToken,
+ cloudId: bundle.cloudId,
projectId: context.projectId,
query: search,
},
@@ -110,14 +113,15 @@ export const jiraSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
- const accessToken = await fetchOAuthToken(credentialId, context.workflowId)
- if (!accessToken) {
+ const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ if (!bundle) {
throw new Error('Missing Jira access token')
}
const data = await requestJson(selectorContracts.jiraIssueSelectorContract, {
body: {
domain,
- accessToken,
+ accessToken: bundle.accessToken,
+ cloudId: bundle.cloudId,
issueKeys: [detailId],
},
signal,
diff --git a/apps/sim/lib/api/client/request.ts b/apps/sim/lib/api/client/request.ts
index b2ecd688b8a..d0e8255d2f5 100644
--- a/apps/sim/lib/api/client/request.ts
+++ b/apps/sim/lib/api/client/request.ts
@@ -134,6 +134,14 @@ function messageFromErrorBody(body: unknown, fallback: string): string {
return fallback
}
+function codeFromErrorBody(body: unknown): string | undefined {
+ if (body && typeof body === 'object') {
+ const record = body as Record
+ if (typeof record.code === 'string' && record.code.length > 0) return record.code
+ }
+ return undefined
+}
+
function isSchemaValidationError(error: unknown): boolean {
return Boolean(
error &&
@@ -173,6 +181,7 @@ export async function requestJson(
message: messageFromErrorBody(parsed, `Request failed with ${response.status}`),
body: parsed,
rawBody: raw,
+ code: codeFromErrorBody(parsed),
})
}
diff --git a/apps/sim/lib/api/contracts/atlassian-service-account.ts b/apps/sim/lib/api/contracts/atlassian-service-account.ts
new file mode 100644
index 00000000000..b439ddb09bb
--- /dev/null
+++ b/apps/sim/lib/api/contracts/atlassian-service-account.ts
@@ -0,0 +1,30 @@
+import { z } from 'zod'
+import { workspaceCredentialSchema } from '@/lib/api/contracts/credentials'
+import { defineRouteContract } from '@/lib/api/contracts/types'
+
+export const createAtlassianServiceAccountBodySchema = z.object({
+ workspaceId: z.string().uuid('Workspace ID must be a valid UUID'),
+ apiToken: z.string().trim().min(1, 'API token is required'),
+ domain: z
+ .string()
+ .trim()
+ .min(1, 'Atlassian site domain is required (e.g., your-team.atlassian.net)'),
+ displayName: z.string().trim().min(1).max(255).optional(),
+ description: z.string().trim().max(500).optional(),
+})
+
+export type CreateAtlassianServiceAccountBody = z.input<
+ typeof createAtlassianServiceAccountBodySchema
+>
+
+export const createAtlassianServiceAccountContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/auth/atlassian-service-account',
+ body: createAtlassianServiceAccountBodySchema,
+ response: {
+ mode: 'json',
+ schema: z.object({
+ credential: workspaceCredentialSchema,
+ }),
+ },
+})
diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts
index 062c01a5156..708bfa2d8ba 100644
--- a/apps/sim/lib/api/contracts/index.ts
+++ b/apps/sim/lib/api/contracts/index.ts
@@ -1,6 +1,7 @@
export * from './academy'
export * from './admin'
export * from './api-keys'
+export * from './atlassian-service-account'
export * from './audit-logs'
export * from './byok-keys'
export * from './chats'
diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts
index ee37437e218..cc778ee6abb 100644
--- a/apps/sim/lib/api/contracts/oauth-connections.ts
+++ b/apps/sim/lib/api/contracts/oauth-connections.ts
@@ -77,6 +77,8 @@ const oauthTokenResponseSchema = z.object({
accessToken: z.string(),
idToken: z.string().optional(),
instanceUrl: z.string().optional(),
+ cloudId: z.string().optional(),
+ domain: z.string().optional(),
})
export const oauthTokenGetContract = defineRouteContract({
diff --git a/apps/sim/lib/api/contracts/selectors/oauth.ts b/apps/sim/lib/api/contracts/selectors/oauth.ts
index ddb75089013..2afd417f68d 100644
--- a/apps/sim/lib/api/contracts/selectors/oauth.ts
+++ b/apps/sim/lib/api/contracts/selectors/oauth.ts
@@ -8,6 +8,8 @@ const oauthTokenResponseSchema = z
accessToken: z.string().optional(),
idToken: z.string().optional(),
instanceUrl: z.string().optional(),
+ cloudId: z.string().optional(),
+ domain: z.string().optional(),
})
.passthrough()
diff --git a/apps/sim/lib/oauth/oauth.ts b/apps/sim/lib/oauth/oauth.ts
index 9f12b4c3f60..08ae538e839 100644
--- a/apps/sim/lib/oauth/oauth.ts
+++ b/apps/sim/lib/oauth/oauth.ts
@@ -427,6 +427,23 @@ export const OAUTH_PROVIDERS: Record = {
},
defaultService: 'x',
},
+ atlassian: {
+ name: 'Atlassian',
+ icon: JiraIcon,
+ services: {
+ 'atlassian-service-account': {
+ name: 'Atlassian Service Account',
+ description:
+ 'Authenticate as an Atlassian service account using an email and API token from admin.atlassian.com.',
+ providerId: 'atlassian-service-account',
+ icon: JiraIcon,
+ baseProviderIcon: JiraIcon,
+ scopes: [],
+ authType: 'service_account',
+ },
+ },
+ defaultService: 'atlassian-service-account',
+ },
confluence: {
name: 'Confluence',
icon: ConfluenceIcon,
@@ -437,6 +454,7 @@ export const OAUTH_PROVIDERS: Record = {
providerId: 'confluence',
icon: ConfluenceIcon,
baseProviderIcon: ConfluenceIcon,
+ serviceAccountProviderId: 'atlassian-service-account',
scopes: [
'read:confluence-content.all',
'read:confluence-space.summary',
@@ -490,6 +508,7 @@ export const OAUTH_PROVIDERS: Record = {
providerId: 'jira',
icon: JiraIcon,
baseProviderIcon: JiraIcon,
+ serviceAccountProviderId: 'atlassian-service-account',
scopes: [
'read:jira-user',
'read:jira-work',
diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts
index 5c39f53440a..de2b27c4dfc 100644
--- a/apps/sim/lib/oauth/types.ts
+++ b/apps/sim/lib/oauth/types.ts
@@ -21,6 +21,7 @@ export type OAuthProvider =
| 'airtable'
| 'notion'
| 'jira'
+ | 'atlassian-service-account'
| 'box'
| 'dropbox'
| 'microsoft'
@@ -72,6 +73,7 @@ export type OAuthService =
| 'airtable'
| 'notion'
| 'jira'
+ | 'atlassian-service-account'
| 'box'
| 'dropbox'
| 'microsoft-ad'
diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts
index 3bf69a56101..3d06aaf4226 100644
--- a/apps/sim/tools/index.ts
+++ b/apps/sim/tools/index.ts
@@ -904,6 +904,12 @@ export async function executeTool(
if (data.instanceUrl) {
contextParams.instanceUrl = data.instanceUrl
}
+ if (data.cloudId && !contextParams.cloudId) {
+ contextParams.cloudId = data.cloudId
+ }
+ if (data.domain && !contextParams.domain) {
+ contextParams.domain = data.domain
+ }
logger.info(`[${requestId}] Successfully got access token for ${toolId}`)
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index 751c1919f54..909e680e710 100644
--- a/scripts/check-api-validation-contracts.ts
+++ b/scripts/check-api-validation-contracts.ts
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
- totalRoutes: 717,
- zodRoutes: 717,
+ totalRoutes: 718,
+ zodRoutes: 718,
nonZodRoutes: 0,
} as const
From 06c99c4ce044eb94f819bfe8a5ecddcd264e257b Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Mon, 4 May 2026 11:47:38 -0700
Subject: [PATCH 03/15] improvement(credentials): tighten Atlassian service
account plumbing
- Collapse fetchOAuthTokenBundle into fetchOAuthToken (returns the bundle)
- Reuse serviceAccountJsonSchema in the JSON form instead of hand-rolled checks
- Use parseAtlassianErrorMessage for log details; drop one-line bearer helper
- Extract ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID/_SECRET_TYPE constants
- Use Drizzle .returning() instead of post-insert SELECT
- Helper for the duplicated 401/403 + non-OK pattern in the validator
---
.../auth/atlassian-service-account/route.ts | 118 +++++++++---------
apps/sim/app/api/auth/oauth/token/route.ts | 3 +-
apps/sim/app/api/auth/oauth/utils.ts | 14 ++-
.../integrations/integrations-manager.tsx | 3 +-
.../integrations/service-account-form.tsx | 34 +----
apps/sim/hooks/selectors/helpers.ts | 18 +--
.../providers/confluence/selectors.ts | 6 +-
.../selectors/providers/jira/selectors.ts | 10 +-
apps/sim/lib/oauth/types.ts | 12 ++
9 files changed, 101 insertions(+), 117 deletions(-)
diff --git a/apps/sim/app/api/auth/atlassian-service-account/route.ts b/apps/sim/app/api/auth/atlassian-service-account/route.ts
index a57b11bc03a..ce40d68717e 100644
--- a/apps/sim/app/api/auth/atlassian-service-account/route.ts
+++ b/apps/sim/app/api/auth/atlassian-service-account/route.ts
@@ -12,13 +12,16 @@ import { encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
+import {
+ ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+} from '@/lib/oauth/types'
import { captureServerEvent } from '@/lib/posthog/server'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
+import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
const logger = createLogger('AtlassianServiceAccountAPI')
-const ATLASSIAN_PROVIDER_ID = 'atlassian-service-account'
-
/**
* Discrete validation failure codes returned to the client. The UI maps each
* code to a human message; raw Atlassian response bodies stay in server logs.
@@ -36,14 +39,33 @@ class AtlassianValidationError extends Error {
}
}
-function buildBearerAuthHeader(apiToken: string): string {
- return `Bearer ${apiToken}`
-}
-
function normalizeDomain(rawDomain: string): string {
return rawDomain.replace(/^https?:\/\//, '').replace(/\/+$/, '')
}
+/**
+ * Throws an `AtlassianValidationError` with `unauthorizedCode` for 401/403 responses
+ * (which mean the token itself was rejected) and `atlassian_unavailable` for any
+ * other non-2xx. Successful responses are returned unchanged.
+ */
+async function assertAtlassianResponseOk(
+ res: Response,
+ step: string,
+ unauthorizedCode: AtlassianValidationCode,
+ context: Record = {}
+): Promise {
+ if (res.ok) return res
+ const body = parseAtlassianErrorMessage(res.status, res.statusText, await res.text())
+ if (res.status === 401 || res.status === 403) {
+ throw new AtlassianValidationError(unauthorizedCode, res.status, { step, body, ...context })
+ }
+ throw new AtlassianValidationError('atlassian_unavailable', res.status, {
+ step,
+ body,
+ ...context,
+ })
+}
+
/**
* Validates an Atlassian service account scoped API token.
*
@@ -60,18 +82,11 @@ async function validateAtlassianServiceAccount(
headers: { Accept: 'application/json' },
})
if (tenantInfoRes.status === 404) {
- throw new AtlassianValidationError('site_not_found', 404, {
- step: 'tenant_info',
- domain,
- })
- }
- if (!tenantInfoRes.ok) {
- throw new AtlassianValidationError('atlassian_unavailable', tenantInfoRes.status, {
- step: 'tenant_info',
- domain,
- body: (await tenantInfoRes.text()).slice(0, 200),
- })
+ throw new AtlassianValidationError('site_not_found', 404, { step: 'tenant_info', domain })
}
+ // tenant_info is unauthenticated, so there is no "invalid credentials" branch here —
+ // any non-OK that isn't a 404 means Atlassian is unavailable, not the token's fault.
+ await assertAtlassianResponseOk(tenantInfoRes, 'tenant_info', 'atlassian_unavailable', { domain })
const tenantInfo = (await tenantInfoRes.json()) as { cloudId?: string }
if (!tenantInfo.cloudId) {
throw new AtlassianValidationError('atlassian_unavailable', 502, {
@@ -82,24 +97,10 @@ async function validateAtlassianServiceAccount(
}
const cloudId = tenantInfo.cloudId
- const auth = buildBearerAuthHeader(apiToken)
const myselfRes = await fetch(`https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/myself`, {
- headers: { Authorization: auth, Accept: 'application/json' },
+ headers: { Authorization: `Bearer ${apiToken}`, Accept: 'application/json' },
})
- if (myselfRes.status === 401 || myselfRes.status === 403) {
- throw new AtlassianValidationError('invalid_credentials', myselfRes.status, {
- step: 'myself',
- cloudId,
- body: (await myselfRes.text()).slice(0, 200),
- })
- }
- if (!myselfRes.ok) {
- throw new AtlassianValidationError('atlassian_unavailable', myselfRes.status, {
- step: 'myself',
- cloudId,
- body: (await myselfRes.text()).slice(0, 200),
- })
- }
+ await assertAtlassianResponseOk(myselfRes, 'myself', 'invalid_credentials', { cloudId })
const myself = (await myselfRes.json()) as {
accountId?: string
@@ -161,7 +162,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
and(
eq(credential.workspaceId, workspaceId),
eq(credential.type, 'service_account'),
- eq(credential.providerId, ATLASSIAN_PROVIDER_ID),
+ eq(credential.providerId, ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID),
eq(credential.displayName, resolvedDisplayName)
)
)
@@ -177,7 +178,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
const blob = JSON.stringify({
- type: 'atlassian_service_account',
+ type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
apiToken,
domain: normalizedDomain,
cloudId: validation.cloudId,
@@ -194,22 +195,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
.where(eq(workspace.id, workspaceId))
.limit(1)
- await db.transaction(async (tx) => {
- await tx.insert(credential).values({
- id: credentialId,
- workspaceId,
- type: 'service_account',
- displayName: resolvedDisplayName,
- description: resolvedDescription,
- providerId: ATLASSIAN_PROVIDER_ID,
- accountId: null,
- envKey: null,
- envOwnerUserId: null,
- encryptedServiceAccountKey: encrypted,
- createdBy: session.user.id,
- createdAt: now,
- updatedAt: now,
- })
+ const created = await db.transaction(async (tx) => {
+ const [row] = await tx
+ .insert(credential)
+ .values({
+ id: credentialId,
+ workspaceId,
+ type: 'service_account',
+ displayName: resolvedDisplayName,
+ description: resolvedDescription,
+ providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ accountId: null,
+ envKey: null,
+ envOwnerUserId: null,
+ encryptedServiceAccountKey: encrypted,
+ createdBy: session.user.id,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .returning()
const memberUserIds = workspaceRow?.ownerId
? await getWorkspaceMemberUserIds(workspaceId)
@@ -229,20 +233,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
updatedAt: now,
})
}
- })
- const [created] = await db
- .select()
- .from(credential)
- .where(eq(credential.id, credentialId))
- .limit(1)
+ return row
+ })
captureServerEvent(
session.user.id,
'credential_connected',
{
credential_type: 'service_account',
- provider_id: ATLASSIAN_PROVIDER_ID,
+ provider_id: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
workspace_id: workspaceId,
},
{
@@ -263,7 +263,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
description: `Created Atlassian service account credential "${resolvedDisplayName}"`,
metadata: {
credentialType: 'service_account',
- providerId: ATLASSIAN_PROVIDER_ID,
+ providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
atlassianDomain: normalizedDomain,
atlassianCloudId: validation.cloudId,
},
diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts
index 232b30b0d3c..adc517ceff9 100644
--- a/apps/sim/app/api/auth/oauth/token/route.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.ts
@@ -9,6 +9,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import {
getAtlassianServiceAccountSecret,
getCredential,
@@ -119,7 +120,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
try {
- if (resolved.providerId === 'atlassian-service-account') {
+ if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
const secret = await getAtlassianServiceAccountSecret(resolved.credentialId)
return NextResponse.json(
{
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index cd8385b3aa4..4e33048a83d 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -11,6 +11,10 @@ import {
isMicrosoftProvider,
PROACTIVE_REFRESH_THRESHOLD_DAYS,
} from '@/lib/oauth/microsoft'
+import {
+ ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+} from '@/lib/oauth/types'
const logger = createLogger('OAuthUtilsAPI')
@@ -212,7 +216,7 @@ export async function getServiceAccountToken(
}
interface AtlassianServiceAccountSecret {
- type: 'atlassian_service_account'
+ type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
apiToken: string
domain: string
cloudId: string
@@ -238,7 +242,11 @@ export async function getAtlassianServiceAccountSecret(
const { decrypted } = await decryptSecret(credentialRow.encryptedServiceAccountKey)
const parsed = JSON.parse(decrypted) as AtlassianServiceAccountSecret
- if (parsed.type !== 'atlassian_service_account' || !parsed.apiToken || !parsed.cloudId) {
+ if (
+ parsed.type !== ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE ||
+ !parsed.apiToken ||
+ !parsed.cloudId
+ ) {
throw new Error('Stored Atlassian service account secret is malformed')
}
return parsed
@@ -420,7 +428,7 @@ export async function refreshAccessTokenIfNeeded(
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
- if (resolved.providerId === 'atlassian-service-account') {
+ if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
logger.info(`[${requestId}] Using Atlassian service account token for credential`)
return getAtlassianServiceAccountToken(resolved.credentialId)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx
index 673af31063f..eb5e37f0eea 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/integrations-manager.tsx
@@ -33,6 +33,7 @@ import {
writeOAuthReturnContext,
} from '@/lib/credentials/client-state'
import { getCanonicalScopesForProvider, getServiceConfigByProviderId } from '@/lib/oauth'
+import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
import { getScopeDescription } from '@/lib/oauth/utils'
import { getUserColor } from '@/lib/workspaces/colors'
import { AtlassianServiceAccountForm } from '@/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form'
@@ -848,7 +849,7 @@ export function IntegrationsManager() {
>
- ) : selectedOAuthService?.providerId === 'atlassian-service-account' ? (
+ ) : selectedOAuthService?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID ? (
void
}
-interface ValidationResult {
- valid: boolean
- error?: string
-}
-
-function validateServiceAccountJson(raw: string): ValidationResult {
- let parsed: Record
- try {
- parsed = JSON.parse(raw)
- } catch {
- return { valid: false, error: 'Invalid JSON. Paste the full service account key file.' }
- }
- if (parsed.type !== 'service_account') {
- return { valid: false, error: 'JSON key must have "type": "service_account".' }
- }
- if (!parsed.client_email || typeof parsed.client_email !== 'string') {
- return { valid: false, error: 'Missing "client_email" field.' }
- }
- if (!parsed.private_key || typeof parsed.private_key !== 'string') {
- return { valid: false, error: 'Missing "private_key" field.' }
- }
- if (!parsed.project_id || typeof parsed.project_id !== 'string') {
- return { valid: false, error: 'Missing "project_id" field.' }
- }
- return { valid: true }
-}
-
export function ServiceAccountForm({
service,
serviceLabel,
@@ -130,9 +104,9 @@ export function ServiceAccountForm({
setError('Paste the service account JSON key.')
return
}
- const validation = validateServiceAccountJson(trimmed)
- if (!validation.valid) {
- setError(validation.error ?? 'Invalid JSON')
+ const validation = serviceAccountJsonSchema.safeParse(trimmed)
+ if (!validation.success) {
+ setError(validation.error.issues[0]?.message ?? 'Invalid JSON')
return
}
setIsSubmitting(true)
diff --git a/apps/sim/hooks/selectors/helpers.ts b/apps/sim/hooks/selectors/helpers.ts
index 20afeaa58f9..ca4d2151abf 100644
--- a/apps/sim/hooks/selectors/helpers.ts
+++ b/apps/sim/hooks/selectors/helpers.ts
@@ -7,23 +7,11 @@ export interface OAuthTokenBundle {
domain?: string
}
-export async function fetchOAuthToken(
- credentialId: string,
- workflowId?: string
-): Promise {
- if (!credentialId) return null
- const token = await requestJson(oauthTokenContract, {
- body: { credentialId, workflowId },
- })
- return token.accessToken ?? null
-}
-
/**
- * Same as fetchOAuthToken but also returns provider-specific extras —
- * notably `cloudId` for Atlassian service accounts whose tokens cannot
- * call api.atlassian.com/oauth/token/accessible-resources.
+ * Returns the access token plus any provider-specific extras (e.g. `cloudId` for
+ * Atlassian service accounts whose tokens cannot call api.atlassian.com/oauth/token/accessible-resources).
*/
-export async function fetchOAuthTokenBundle(
+export async function fetchOAuthToken(
credentialId: string,
workflowId?: string
): Promise {
diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts
index 220a50698c1..6a2f81d581b 100644
--- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts
+++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts
@@ -1,6 +1,6 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
-import { fetchOAuthTokenBundle } from '@/hooks/selectors/helpers'
+import { fetchOAuthToken } from '@/hooks/selectors/helpers'
import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
@@ -67,7 +67,7 @@ export const confluenceSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
- const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Confluence access token')
}
@@ -89,7 +89,7 @@ export const confluenceSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'confluence.pages')
const domain = ensureDomain(context, 'confluence.pages')
- const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Confluence access token')
}
diff --git a/apps/sim/hooks/selectors/providers/jira/selectors.ts b/apps/sim/hooks/selectors/providers/jira/selectors.ts
index af11d8d7a6d..6175f8a0961 100644
--- a/apps/sim/hooks/selectors/providers/jira/selectors.ts
+++ b/apps/sim/hooks/selectors/providers/jira/selectors.ts
@@ -1,6 +1,6 @@
import { requestJson } from '@/lib/api/client/request'
import * as selectorContracts from '@/lib/api/contracts/selectors'
-import { fetchOAuthTokenBundle } from '@/hooks/selectors/helpers'
+import { fetchOAuthToken } from '@/hooks/selectors/helpers'
import { ensureCredential, ensureDomain, SELECTOR_STALE } from '@/hooks/selectors/providers/shared'
import type { SelectorDefinition, SelectorKey, SelectorQueryArgs } from '@/hooks/selectors/types'
@@ -23,7 +23,7 @@ export const jiraSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
- const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
@@ -45,7 +45,7 @@ export const jiraSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.projects')
const domain = ensureDomain(context, 'jira.projects')
- const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
@@ -84,7 +84,7 @@ export const jiraSelectors = {
fetchList: async ({ context, search, signal }: SelectorQueryArgs) => {
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
- const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
@@ -113,7 +113,7 @@ export const jiraSelectors = {
if (!detailId) return null
const credentialId = ensureCredential(context, 'jira.issues')
const domain = ensureDomain(context, 'jira.issues')
- const bundle = await fetchOAuthTokenBundle(credentialId, context.workflowId)
+ const bundle = await fetchOAuthToken(credentialId, context.workflowId)
if (!bundle) {
throw new Error('Missing Jira access token')
}
diff --git a/apps/sim/lib/oauth/types.ts b/apps/sim/lib/oauth/types.ts
index de2b27c4dfc..bd1de917bf2 100644
--- a/apps/sim/lib/oauth/types.ts
+++ b/apps/sim/lib/oauth/types.ts
@@ -1,5 +1,17 @@
import type { ReactNode } from 'react'
+/**
+ * Stable identifier for the Atlassian service account provider. Used as the
+ * `providerId` on credential rows and as the `serviceAccountProviderId` on
+ * Jira/Confluence service configs.
+ */
+export const ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID = 'atlassian-service-account' as const
+
+/**
+ * Discriminator stored inside the encrypted Atlassian service account secret blob.
+ */
+export const ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE = 'atlassian_service_account' as const
+
export type OAuthProvider =
| 'google'
| 'google-email'
From 9db3fc9f409450e007ff723301af5d9c53ec1dee Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Mon, 4 May 2026 12:54:55 -0700
Subject: [PATCH 04/15] docs(credentials): add Atlassian service account setup
guide
- New /integrations/atlassian-service-account doc covers token creation,
scope selection, and adding the credential to Sim
- Form's "View setup guide" link now points at the doc
- Fix the existing Google form link that pointed to the wrong path
Screenshot TODOs left inline as MDX comments for the docs team.
---
.../atlassian-service-account.mdx | 130 ++++++++++++++++++
.../content/docs/en/integrations/meta.json | 2 +-
.../atlassian-service-account-form.tsx | 2 +-
.../integrations/integrations-manager.tsx | 2 +-
4 files changed, 133 insertions(+), 3 deletions(-)
create mode 100644 apps/docs/content/docs/en/integrations/atlassian-service-account.mdx
diff --git a/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx
new file mode 100644
index 00000000000..05107691219
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx
@@ -0,0 +1,130 @@
+---
+title: Atlassian Service Accounts
+description: Set up an Atlassian service account with a scoped API token to use Jira and Confluence in Sim workflows
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { Step, Steps } from 'fumadocs-ui/components/steps'
+import { Image } from '@/components/ui/image'
+import { FAQ } from '@/components/ui/faq'
+
+Atlassian service accounts let your workflows authenticate to Jira and Confluence as a non-human bot user — independent of any individual employee's account. Each service account has its own email, its own permissions, and its own API tokens, all managed centrally in admin.atlassian.com.
+
+This is the recommended way to use Jira and Confluence in production workflows: no one person's OAuth consent expires, the bot's permissions are auditable, and access can be revoked without touching anyone's personal account.
+
+## Prerequisites
+
+You need an Atlassian organization admin to create the service account. Service accounts are an Atlassian organization-level feature — they cannot be created from a regular user account.
+
+## Setting Up the Service Account
+
+### 1. Create the Service Account
+
+
+
+ Open [admin.atlassian.com](https://admin.atlassian.com/) and go to **Directory** → **Service accounts**
+
+ {/* TODO(screenshot): admin.atlassian.com directory page with the "Service accounts" tab highlighted */}
+
+
+ Click **Create service account**, give it a name (e.g. `sim-jira-bot`), and finish creation
+
+
+ Grant the service account access to the Atlassian sites and products it needs. Open the service account, go to **Product access**, and add Jira and/or Confluence on the relevant site
+
+ {/* TODO(screenshot): service account "Product access" tab showing Jira granted on a site */}
+
+
+
+
+The service account inherits permissions from the project/space roles you grant it — exactly like a human user. If a workflow needs to write to a specific Jira project, give the service account write access to that project in Jira's project settings.
+
+
+### 2. Create a Scoped API Token
+
+
+
+ From the service account's page in admin.atlassian.com, open the **API tokens** tab and click **Create API token**
+
+ {/* TODO(screenshot): service account API tokens tab with "Create API token" button */}
+
+
+ Choose **API token** as the authentication type (not OAuth 2.0 — Sim uses the API token flow)
+
+ {/* TODO(screenshot): "Choose authentication type" page with API token selected */}
+
+
+ Select the scopes the token needs. The minimum set Sim's Jira and Confluence blocks expect is:
+
+ **Jira (granular):**
+ ```
+ read:jira-user
+ read:jira-work
+ write:jira-work
+ ```
+
+ **Confluence (granular):**
+ ```
+ read:confluence-content.all
+ read:confluence-space.summary
+ write:confluence-content
+ read:page:confluence
+ write:page:confluence
+ ```
+
+ Add more scopes only if you need the corresponding operations (delete, manage webhooks, etc.). The full list of scopes Sim's blocks may use is documented in [Atlassian's developer reference](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/).
+
+ {/* TODO(screenshot): scope selection page with the minimum scopes checked */}
+
+
+ Copy the token when it's shown. Atlassian only displays it once — if you close the dialog, you'll have to create a new token.
+
+
+
+
+The API token is bearer credentials for the service account. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest.
+
+
+### 3. Find Your Site Domain
+
+Your Atlassian site domain is the URL you use to access Jira or Confluence in your browser — for example, `your-team.atlassian.net`. Open Jira or Confluence, look at the address bar, and copy the part before the first `/`.
+
+## Adding the Service Account to Sim
+
+
+
+ Open your workspace **Settings** and go to the **Integrations** tab
+
+
+ Search for "Atlassian Service Account" and click it
+
+ {/* TODO(screenshot): Integrations page with "Atlassian Service Account" in the service list */}
+
+
+ Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description
+
+ {/* TODO(screenshot): "Add Atlassian Service Account" modal with the three fields filled in */}
+
+
+ Click **Add Service Account**. Sim verifies the token by calling Atlassian's `/myself` endpoint through the gateway — if it fails, you'll see a specific error explaining what went wrong.
+
+
+
+The token, domain, and discovered cloudId are encrypted before being stored.
+
+## Using the Service Account in Workflows
+
+Add a Jira or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
+
+{/* TODO(screenshot): Jira block in a workflow with the credential dropdown open showing both OAuth and service account credentials */}
+
+The block calls Atlassian's API gateway (`api.atlassian.com/ex/jira/{cloudId}/...`) using the service account's token. There's no impersonation step — the service account acts as itself, with whatever permissions you granted it in admin.atlassian.com.
+
+
diff --git a/apps/docs/content/docs/en/integrations/meta.json b/apps/docs/content/docs/en/integrations/meta.json
index 282504513b3..424b4ce6d4f 100644
--- a/apps/docs/content/docs/en/integrations/meta.json
+++ b/apps/docs/content/docs/en/integrations/meta.json
@@ -1,5 +1,5 @@
{
"title": "Integrations",
- "pages": ["index", "google-service-account"],
+ "pages": ["index", "google-service-account", "atlassian-service-account"],
"defaultOpen": false
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx
index fc07a67e75d..36f3db5336c 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx
@@ -142,7 +142,7 @@ export function AtlassianServiceAccountForm({
'Use a scoped API token from a service account in admin.atlassian.com.'}
setCreateStep(1)}
onCreate={(input) => createCredential.mutateAsync(input)}
onCreated={() => {
From 7b318bf86edd7cc59eb1ce94b7886c024a21cefc Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Mon, 4 May 2026 16:13:50 -0700
Subject: [PATCH 05/15] docs(credentials): add Atlassian service account
screenshots
- Auth type picker, Sim add-credential modal, Jira block credential dropdown
- Scope-picker screenshot still TODO
---
.../atlassian-service-account.mdx | 30 ++++++++++++++++--
.../atlassian/admin-auth-type-picker.png | Bin 0 -> 155870 bytes
.../credentials/atlassian/sim-add-modal.png | Bin 0 -> 174503 bytes
.../atlassian/sim-jira-block-credential.png | Bin 0 -> 241315 bytes
4 files changed, 27 insertions(+), 3 deletions(-)
create mode 100644 apps/sim/public/static/credentials/atlassian/admin-auth-type-picker.png
create mode 100644 apps/sim/public/static/credentials/atlassian/sim-add-modal.png
create mode 100644 apps/sim/public/static/credentials/atlassian/sim-jira-block-credential.png
diff --git a/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx
index 05107691219..b543281ffeb 100644
--- a/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx
+++ b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx
@@ -51,7 +51,15 @@ The service account inherits permissions from the project/space roles you grant
Choose **API token** as the authentication type (not OAuth 2.0 — Sim uses the API token flow)
- {/* TODO(screenshot): "Choose authentication type" page with API token selected */}
+
+
+
Select the scopes the token needs. The minimum set Sim's Jira and Confluence blocks expect is:
@@ -103,7 +111,15 @@ Your Atlassian site domain is the URL you use to access Jira or Confluence in yo
Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description
- {/* TODO(screenshot): "Add Atlassian Service Account" modal with the three fields filled in */}
+
+
+
Click **Add Service Account**. Sim verifies the token by calling Atlassian's `/myself` endpoint through the gateway — if it fails, you'll see a specific error explaining what went wrong.
@@ -116,7 +132,15 @@ The token, domain, and discovered cloudId are encrypted before being stored.
Add a Jira or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would.
-{/* TODO(screenshot): Jira block in a workflow with the credential dropdown open showing both OAuth and service account credentials */}
+
+
+
The block calls Atlassian's API gateway (`api.atlassian.com/ex/jira/{cloudId}/...`) using the service account's token. There's no impersonation step — the service account acts as itself, with whatever permissions you granted it in admin.atlassian.com.
diff --git a/apps/sim/public/static/credentials/atlassian/admin-auth-type-picker.png b/apps/sim/public/static/credentials/atlassian/admin-auth-type-picker.png
new file mode 100644
index 0000000000000000000000000000000000000000..4232828137b635ec282d3149a424ae6d528322cb
GIT binary patch
literal 155870
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ⅆ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