From d6ec115348d0581fc2e6729298db7f31c776d1d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Apr 2026 16:11:31 -0700 Subject: [PATCH 01/12] 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/12] 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' + /> +
+ +
+ +