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) + +
+ Atlassian admin — Choose authentication type 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/). + +
+ Atlassian token scope picker filtered to App: Jira and Scope type: Classic +
+ + + 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 + +
+ Add Atlassian Service Account dialog with API token and site domain 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. + +
+ Jira block in a workflow with the Atlassian service account selected as the credential +
+ +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 ( + <> + +
+ + 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' + /> +
+ +
+ +