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..313fd5ceaf2
--- /dev/null
+++ b/apps/docs/content/docs/en/integrations/atlassian-service-account.mdx
@@ -0,0 +1,166 @@
+---
+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)
+
+
+
+
+
+
+ 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/).
+
+
+
+
+
+
+ Use the **App** and **Scope type** filters to narrow the list to the scopes you need. Filter by `App: Jira` (or `Confluence`) and `Scope type: Classic` to find the three core Jira scopes; switch to **Granular** if your org doesn't expose Classic.
+
+
+
+ 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
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+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/docs/public/static/credentials/atlassian/admin-auth-type-picker.png b/apps/docs/public/static/credentials/atlassian/admin-auth-type-picker.png
new file mode 100644
index 00000000000..4232828137b
Binary files /dev/null and b/apps/docs/public/static/credentials/atlassian/admin-auth-type-picker.png differ
diff --git a/apps/docs/public/static/credentials/atlassian/admin-scope-picker.png b/apps/docs/public/static/credentials/atlassian/admin-scope-picker.png
new file mode 100644
index 00000000000..27b160adb16
Binary files /dev/null and b/apps/docs/public/static/credentials/atlassian/admin-scope-picker.png differ
diff --git a/apps/docs/public/static/credentials/atlassian/sim-add-modal.png b/apps/docs/public/static/credentials/atlassian/sim-add-modal.png
new file mode 100644
index 00000000000..881f43f60e8
Binary files /dev/null and b/apps/docs/public/static/credentials/atlassian/sim-add-modal.png differ
diff --git a/apps/docs/public/static/credentials/atlassian/sim-jira-block-credential.png b/apps/docs/public/static/credentials/atlassian/sim-jira-block-credential.png
new file mode 100644
index 00000000000..04c2a98960b
Binary files /dev/null and b/apps/docs/public/static/credentials/atlassian/sim-jira-block-credential.png differ
diff --git a/apps/sim/app/api/auth/oauth/token/route.ts b/apps/sim/app/api/auth/oauth/token/route.ts
index 1ab26f84159..adc517ceff9 100644
--- a/apps/sim/app/api/auth/oauth/token/route.ts
+++ b/apps/sim/app/api/auth/oauth/token/route.ts
@@ -9,7 +9,9 @@ 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,
getOAuthToken,
getServiceAccountToken,
@@ -118,6 +120,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
try {
+ if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
+ 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..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')
@@ -44,6 +48,7 @@ export interface ResolvedCredential {
usedCredentialTable: boolean
credentialType?: string
credentialId?: string
+ providerId?: string
}
/**
@@ -61,6 +66,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 +79,7 @@ export async function resolveOAuthAccountId(
credentialId: credentialRow.id,
credentialType: 'service_account',
workspaceId: credentialRow.workspaceId,
+ providerId: credentialRow.providerId ?? undefined,
usedCredentialTable: true,
}
}
@@ -208,6 +215,53 @@ export async function getServiceAccountToken(
return tokenData.access_token
}
+interface AtlassianServiceAccountSecret {
+ type: typeof ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE
+ 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_SECRET_TYPE ||
+ !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 +428,10 @@ export async function refreshAccessTokenIfNeeded(
}
if (resolved.credentialType === 'service_account' && resolved.credentialId) {
+ if (resolved.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
+ 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/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts
index b4e65a5a845..72d1c935ac9 100644
--- a/apps/sim/app/api/credentials/route.ts
+++ b/apps/sim/app/api/credentials/route.ts
@@ -16,9 +16,18 @@ 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 {
+ AtlassianValidationError,
+ normalizeAtlassianDomain,
+ validateAtlassianServiceAccount,
+} from '@/lib/credentials/atlassian-service-account'
import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
+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'
@@ -253,6 +262,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
envKey,
envOwnerUserId,
serviceAccountJson,
+ apiToken,
+ domain,
} = parsed.data.body
const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id)
@@ -302,34 +313,67 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
getServiceConfigByProviderId(accountRow.providerId)?.name || accountRow.providerId
}
} else if (type === 'service_account') {
- if (!serviceAccountJson) {
- return NextResponse.json(
- { error: 'serviceAccountJson is required for service account credentials' },
- { status: 400 }
- )
- }
+ if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
+ if (!apiToken || !domain) {
+ return NextResponse.json(
+ { error: 'apiToken and domain are required for Atlassian service account credentials' },
+ { status: 400 }
+ )
+ }
- const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
- if (!jsonParseResult.success) {
- return NextResponse.json(
- {
- error: getValidationErrorMessage(jsonParseResult.error, 'Invalid service account JSON'),
- },
- { status: 400 }
- )
- }
+ const normalizedDomain = normalizeAtlassianDomain(domain)
+ const validation = await validateAtlassianServiceAccount(apiToken, normalizedDomain)
- const parsed = jsonParseResult.data
- resolvedProviderId = 'google-service-account'
- resolvedAccountId = null
- resolvedEnvOwnerUserId = null
+ resolvedProviderId = ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
+ resolvedAccountId = null
+ resolvedEnvOwnerUserId = null
- if (!resolvedDisplayName) {
- resolvedDisplayName = parsed.client_email
- }
+ if (!resolvedDisplayName) {
+ resolvedDisplayName = validation.displayName
+ }
+
+ const blob = JSON.stringify({
+ type: ATLASSIAN_SERVICE_ACCOUNT_SECRET_TYPE,
+ apiToken,
+ domain: normalizedDomain,
+ cloudId: validation.cloudId,
+ atlassianAccountId: validation.accountId,
+ })
+ const { encrypted } = await encryptSecret(blob)
+ resolvedEncryptedServiceAccountKey = encrypted
+ } else {
+ if (!serviceAccountJson) {
+ return NextResponse.json(
+ { error: 'serviceAccountJson is required for service account credentials' },
+ { status: 400 }
+ )
+ }
- const { encrypted } = await encryptSecret(serviceAccountJson)
- resolvedEncryptedServiceAccountKey = encrypted
+ const jsonParseResult = serviceAccountJsonSchema.safeParse(serviceAccountJson)
+ if (!jsonParseResult.success) {
+ return NextResponse.json(
+ {
+ error: getValidationErrorMessage(
+ jsonParseResult.error,
+ 'Invalid service account JSON'
+ ),
+ },
+ { status: 400 }
+ )
+ }
+
+ const parsedKey = jsonParseResult.data
+ resolvedProviderId = 'google-service-account'
+ resolvedAccountId = null
+ resolvedEnvOwnerUserId = null
+
+ if (!resolvedDisplayName) {
+ resolvedDisplayName = parsedKey.client_email
+ }
+
+ const { encrypted } = await encryptSecret(serviceAccountJson)
+ resolvedEncryptedServiceAccountKey = encrypted
+ }
} else if (type === 'env_personal') {
resolvedEnvOwnerUserId = envOwnerUserId ?? session.user.id
if (resolvedEnvOwnerUserId !== session.user.id) {
@@ -514,6 +558,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ credential: created }, { status: 201 })
} catch (error: any) {
+ 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 })
+ }
if (error?.code === '23505') {
return NextResponse.json(
{ error: 'A credential with this source already exists' },
diff --git a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts
index 59a674df87a..3d7e2cc0ac9 100644
--- a/apps/sim/app/api/tools/confluence/selector-spaces/route.ts
+++ b/apps/sim/app/api/tools/confluence/selector-spaces/route.ts
@@ -6,7 +6,12 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateJiraCloudId } from '@/lib/core/security/input-validation'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
+import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
+import {
+ getAtlassianServiceAccountSecret,
+ refreshAccessTokenIfNeeded,
+ resolveOAuthAccountId,
+} from '@/app/api/auth/oauth/utils'
import { getConfluenceCloudId } from '@/tools/confluence/utils'
import { parseAtlassianErrorMessage } from '@/tools/jira/utils'
@@ -39,24 +44,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}
- const accessToken = await refreshAccessTokenIfNeeded(
- credential,
- authz.credentialOwnerUserId,
- requestId
- )
- if (!accessToken) {
- logger.error('Failed to get access token', {
- credentialId: credential,
- userId: authz.credentialOwnerUserId,
- })
- return NextResponse.json(
- { error: 'Could not retrieve access token', authRequired: true },
- { status: 401 }
+ // Resolve once so we know whether this is an Atlassian SA credential before
+ // doing any token / cloudId work. Atlassian SAs short-circuit the entire path:
+ // the API token IS the access token, and cloudId lives in the encrypted secret —
+ // so we skip refreshAccessTokenIfNeeded (avoids a redundant resolve+decrypt) and
+ // skip getConfluenceCloudId (which 401s for scoped SA tokens).
+ const resolved = await resolveOAuthAccountId(credential)
+ const isAtlassianServiceAccount =
+ resolved?.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID && !!resolved.credentialId
+
+ let accessToken: string | null
+ let cloudId: string
+ if (isAtlassianServiceAccount) {
+ const secret = await getAtlassianServiceAccountSecret(resolved.credentialId!)
+ accessToken = secret.apiToken
+ cloudId = secret.cloudId
+ } else {
+ accessToken = await refreshAccessTokenIfNeeded(
+ credential,
+ authz.credentialOwnerUserId,
+ requestId
)
+ if (!accessToken) {
+ logger.error('Failed to get access token', {
+ credentialId: credential,
+ userId: authz.credentialOwnerUserId,
+ })
+ return NextResponse.json(
+ { error: 'Could not retrieve access token', authRequired: true },
+ { status: 401 }
+ )
+ }
+ cloudId = await getConfluenceCloudId(domain, accessToken)
}
- const cloudId = await getConfluenceCloudId(domain, accessToken)
-
const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId')
if (!cloudIdValidation.isValid) {
return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 })
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..7ed77fe71e0
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/integrations/atlassian-service-account-form.tsx
@@ -0,0 +1,246 @@
+'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'
+import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID } from '@/lib/oauth/types'
+
+interface AtlassianServiceAccountFormProps {
+ service: OAuthServiceConfig | null
+ serviceLabel: string
+ workspaceId: string
+ onBack: () => void
+ onCreate: (input: {
+ workspaceId: string
+ type: 'service_account'
+ providerId: typeof ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID
+ 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?:\/\//i, '')
+ .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,
+ type: 'service_account',
+ providerId: ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID,
+ 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 (
+ <>
+
+