feat(jira): support self-hosted Jira Server/DC with PAT + username/password (#8976)

Adds self-hosted Jira Server/Data Center support (personal access token or classic username + password) alongside Atlassian Cloud, fully addressing the older-instance ask in #6676. Takeover of #7724 (@wquintal's original PAT implementation), brought current with main and hardened via a multi-agent adversarial review.

Fixes #6676.

Co-authored-by: William Quintal <williamquintal95@gmail.com>
This commit is contained in:
Neil
2026-07-16 00:26:51 -07:00
committed by GitHub
co-authored by William Quintal
parent 1830348dc4
commit 4c810b79b2
21 changed files with 788 additions and 290 deletions
+2 -1
View File
@@ -91,7 +91,8 @@ export function registerJiraHandlers(): void {
const result = await connect({
siteUrl: args.siteUrl,
email: args.email,
apiToken: args.apiToken
apiToken: args.apiToken,
authType: args.authType === 'server' ? 'server' : 'cloud'
})
if (result.ok) {
_resetPreflightCache()
+222 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
@@ -402,6 +402,227 @@ describe('Jira client credential storage', () => {
expect(jira.isAuthError(new jira.JiraApiError('Forbidden', 403))).toBe(false)
})
it('connects to self-hosted Jira with a Bearer PAT against REST v2', async () => {
netFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
name: 'wquintal',
key: 'JIRAUSER10101',
displayName: 'William',
emailAddress: 'william@example.com'
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
const jira = await loadClientModule()
await expect(
jira.connect({
siteUrl: 'jira.example.com',
email: '',
apiToken: 'pat-token',
authType: 'server'
})
).resolves.toMatchObject({
ok: true,
// Server /myself has no accountId; the username stands in for it.
viewer: { displayName: 'William', accountId: 'wquintal' }
})
expect(netFetchMock).toHaveBeenCalledWith(
'https://jira.example.com/rest/api/2/myself',
expect.objectContaining({ headers: expect.any(Headers) })
)
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(headers.get('Authorization')).toBe('Bearer pat-token')
})
it('connects to self-hosted Jira with Basic username/password against REST v2', async () => {
netFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ name: 'jdoe', key: 'JIRAUSER20202', displayName: 'Jane Doe' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
const jira = await loadClientModule()
await expect(
jira.connect({
siteUrl: 'jira.example.com',
// A username present means classic Basic auth (older Server/DC that
// predate PATs); the token slot carries the account password.
email: 'jdoe',
apiToken: 'account-password',
authType: 'server'
})
).resolves.toMatchObject({
ok: true,
viewer: { displayName: 'Jane Doe', accountId: 'jdoe' }
})
expect(netFetchMock).toHaveBeenCalledWith(
'https://jira.example.com/rest/api/2/myself',
expect.objectContaining({ headers: expect.any(Headers) })
)
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(headers.get('Authorization')).toBe(
`Basic ${Buffer.from('jdoe:account-password').toString('base64')}`
)
})
it('uses Basic auth for stored self-hosted sites that carry a username', async () => {
const siteId = 'site-server-basic'
const orcaDir = join(tempHome, '.orca')
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
writeFileSync(
join(orcaDir, 'jira-sites.json'),
JSON.stringify({
version: 1,
activeSiteId: siteId,
selectedSiteId: siteId,
sites: [
{
id: siteId,
siteUrl: 'https://jira.example.com',
email: 'jdoe',
displayName: 'Jane Doe',
accountId: 'jdoe',
authType: 'server'
}
]
}),
{ encoding: 'utf-8' }
)
writeFileSync(tokenPathForSite(siteId), 'account-password')
netFetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ name: 'jdoe', displayName: 'Jane Doe' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
)
const jira = await loadClientModule()
await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: true })
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(headers.get('Authorization')).toBe(
`Basic ${Buffer.from('jdoe:account-password').toString('base64')}`
)
})
it('requires a token but not an email for self-hosted connections', async () => {
const jira = await loadClientModule()
await expect(
jira.connect({
siteUrl: 'jira.example.com',
email: '',
apiToken: '',
authType: 'server'
})
).resolves.toEqual({ ok: false, error: 'Personal access token is required.' })
expect(netFetchMock).not.toHaveBeenCalled()
})
it('requires a password when a username is present on self-hosted', async () => {
const jira = await loadClientModule()
await expect(
jira.connect({
siteUrl: 'jira.example.com',
email: 'jdoe',
apiToken: '',
authType: 'server'
})
).resolves.toEqual({ ok: false, error: 'Password is required.' })
expect(netFetchMock).not.toHaveBeenCalled()
})
it('keeps distinct self-hosted PAT accounts on one host as separate sites', async () => {
netFetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ name: 'alice', displayName: 'Alice' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ name: 'bot', displayName: 'Bot' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
)
const jira = await loadClientModule()
await jira.connect({
siteUrl: 'jira.example.com',
email: '',
apiToken: 'alice-pat',
authType: 'server'
})
await jira.connect({
siteUrl: 'jira.example.com',
email: '',
apiToken: 'bot-pat',
authType: 'server'
})
// Two PATs (both with empty email) to the same host must not collide onto
// one id and silently overwrite each other — the viewer identity keys them.
const stored = JSON.parse(
readFileSync(join(tempHome, '.orca', 'jira-sites.json'), 'utf-8')
) as {
sites: { accountId: string }[]
}
expect(stored.sites).toHaveLength(2)
expect(stored.sites.map((site) => site.accountId).sort()).toEqual(['alice', 'bot'])
})
it('uses Bearer auth and REST v2 for stored self-hosted sites', async () => {
const siteId = 'site-server'
const orcaDir = join(tempHome, '.orca')
mkdirSync(join(orcaDir, 'jira-tokens'), { recursive: true })
writeFileSync(
join(orcaDir, 'jira-sites.json'),
JSON.stringify({
version: 1,
activeSiteId: siteId,
selectedSiteId: siteId,
sites: [
{
id: siteId,
siteUrl: 'https://jira.example.com',
email: '',
displayName: 'William',
accountId: 'wquintal',
authType: 'server'
}
]
}),
{ encoding: 'utf-8' }
)
writeFileSync(tokenPathForSite(siteId), 'pat-token')
netFetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ name: 'wquintal', displayName: 'William' }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
)
const jira = await loadClientModule()
await expect(jira.testConnection(siteId)).resolves.toMatchObject({
ok: true,
viewer: { displayName: 'William' }
})
expect(netFetchMock).toHaveBeenCalledWith(
'https://jira.example.com/rest/api/2/myself',
expect.objectContaining({ headers: expect.any(Headers) })
)
const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers
expect(headers.get('Authorization')).toBe('Bearer pat-token')
})
it('bridges proxy environment settings before Jira connect requests', async () => {
netFetchMock.mockResolvedValueOnce(
new Response(
+62 -15
View File
@@ -14,6 +14,7 @@ import {
import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
import { withSpan } from '../observability/tracer'
import type {
JiraAuthType,
JiraConnectArgs,
JiraConnectionStatus,
JiraSite,
@@ -65,6 +66,13 @@ export type JiraClientForSite = {
authorization: string
}
// Self-hosted Jira Server/Data Center only exposes REST v2; Cloud endpoints
// in this codebase are written against v3. Callers build paths with this
// prefix so one code path serves both deployments.
export function apiBasePath(site: JiraSite): string {
return site.authType === 'server' ? '/rest/api/2' : '/rest/api/3'
}
export class JiraApiError extends Error {
status: number | null
@@ -143,7 +151,9 @@ function normalizeSite(input: unknown): JiraSite | null {
siteUrl: record.siteUrl,
email: record.email,
displayName: record.displayName,
accountId: record.accountId
accountId: record.accountId,
// Sites saved before self-hosted support have no authType; they are Cloud.
authType: record.authType === 'server' ? 'server' : 'cloud'
}
}
@@ -284,8 +294,17 @@ function getSiteId(siteUrl: string, email: string): string {
function toViewer(data: Record<string, unknown>, fallbackEmail: string): JiraViewer {
const avatarUrls = data.avatarUrls as Record<string, unknown> | undefined
// Server/DC /myself has no accountId; its stable identifiers are name/key.
const accountId =
typeof data.accountId === 'string'
? data.accountId
: typeof data.name === 'string'
? data.name
: typeof data.key === 'string'
? data.key
: ''
return {
accountId: typeof data.accountId === 'string' ? data.accountId : '',
accountId,
displayName: typeof data.displayName === 'string' ? data.displayName : fallbackEmail,
email: typeof data.emailAddress === 'string' ? data.emailAddress : fallbackEmail,
avatarUrl:
@@ -308,7 +327,14 @@ function siteToViewer(site: JiraSite | null): JiraViewer | null {
}
}
function authHeader(email: string, apiToken: string): string {
function authHeader(email: string, apiToken: string, authType?: JiraAuthType): string {
// Self-hosted with no username = a personal access token (Bearer); Basic auth
// with a PAT in the password slot is what produces the 401s users report.
// Self-hosted WITH a username is classic username+password Basic auth, which
// older Server/DC instances (predating PATs) require. Cloud is always Basic.
if (authType === 'server' && !email) {
return `Bearer ${apiToken}`
}
return `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`
}
@@ -366,13 +392,14 @@ async function requestWithCredentials(
email: string,
apiToken: string,
path: string,
init?: RequestInit
init?: RequestInit,
authType?: JiraAuthType
): Promise<unknown> {
const headers = new Headers(init?.headers)
headers.set('Accept', 'application/json')
headers.set('Content-Type', 'application/json')
headers.set('User-Agent', JIRA_API_USER_AGENT)
headers.set('Authorization', authHeader(email, apiToken))
headers.set('Authorization', authHeader(email, apiToken, authType))
const response = await jiraFetch(`${siteUrl}${path}`, {
...init,
headers
@@ -453,7 +480,7 @@ export function getClients(selection?: JiraSiteSelection | null): JiraClientForS
}
throw error
}
return token ? [{ site, authorization: authHeader(site.email, token) }] : []
return token ? [{ site, authorization: authHeader(site.email, token, site.authType) }] : []
})
}
@@ -484,28 +511,48 @@ export async function connect(
return { ok: false, error: 'Enter a valid Jira site URL.' }
}
const authType: JiraAuthType = args.authType === 'server' ? 'server' : 'cloud'
const email = args.email.trim()
const apiToken = args.apiToken.trim()
if (!email || !apiToken) {
if (authType === 'server') {
if (!apiToken) {
// A username present means classic Basic auth (password); its absence
// means the credential is a personal access token sent as Bearer.
return {
ok: false,
error: email ? 'Password is required.' : 'Personal access token is required.'
}
}
} else if (!email || !apiToken) {
return { ok: false, error: 'Email and API token are required.' }
}
await acquire()
try {
const myselfPath = authType === 'server' ? '/rest/api/2/myself' : '/rest/api/3/myself'
const viewer = toViewer(
(await requestWithCredentials(siteUrl, email, apiToken, '/rest/api/3/myself')) as Record<
string,
unknown
>,
email
(await requestWithCredentials(
siteUrl,
email,
apiToken,
myselfPath,
undefined,
authType
)) as Record<string, unknown>,
email || siteUrl
)
const id = getSiteId(siteUrl, email)
// PAT sites have no email, so keying on it alone would collide every PAT
// connection to the same host into one id (silently overwriting a prior
// account + token). Fall back to the verified viewer identity so distinct
// accounts stay distinct. Cloud/Basic keep keying on their non-empty email.
const id = getSiteId(siteUrl, email || viewer.accountId)
const site: JiraSite = {
id,
siteUrl,
email,
displayName: viewer.displayName,
accountId: viewer.accountId
accountId: viewer.accountId,
authType
}
saveToken(id, apiToken)
const file = getSiteFile()
@@ -565,7 +612,7 @@ export async function testConnection(
await acquire()
try {
const viewer = toViewer(
(await jiraRequest(client, '/rest/api/3/myself')) as Record<string, unknown>,
(await jiraRequest(client, `${apiBasePath(client.site)}/myself`)) as Record<string, unknown>,
client.site.email
)
return { ok: true, viewer }
+77
View File
@@ -12,6 +12,8 @@ const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi.
vi.mock('./client', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
apiBasePath: (site: { authType?: string }) =>
site.authType === 'server' ? '/rest/api/2' : '/rest/api/3',
clearToken: (...args: unknown[]) => clearTokenMock(...args),
getClients: (...args: unknown[]) => getClientsMock(...args),
isAuthError: (...args: unknown[]) => isAuthErrorMock(...args),
@@ -31,6 +33,20 @@ function makeEntry(id = 'site-1'): JiraClientForSite {
}
}
function makeServerEntry(id = 'server-1'): JiraClientForSite {
return {
site: {
id,
siteUrl: 'https://jira.example.com',
email: '',
displayName: 'Self-hosted Jira',
accountId: 'wquintal',
authType: 'server'
},
authorization: 'Bearer pat-token'
}
}
describe('Jira issue operations', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -60,6 +76,67 @@ describe('Jira issue operations', () => {
).rejects.toThrow(error.message)
})
it('uses classic /search and REST v2 for self-hosted sites', async () => {
getClientsMock.mockReturnValue([makeServerEntry()])
jiraRequestMock.mockResolvedValueOnce({ issues: [] })
const { searchIssues } = await import('./issues')
await searchIssues('project = ALP', 20, 'server-1')
expect(jiraRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ site: expect.objectContaining({ authType: 'server' }) }),
'/rest/api/2/search',
expect.objectContaining({ method: 'POST' })
)
})
it('sends plain-text bodies and v2 paths for self-hosted issue creation', async () => {
getClientsMock.mockReturnValue([makeServerEntry()])
jiraRequestMock.mockResolvedValueOnce({ id: '1', key: 'ALP-1', self: '' })
const { createIssue } = await import('./issues')
await createIssue({
siteId: 'server-1',
projectId: '10000',
issueTypeId: '10001',
title: 'Fix auth',
description: 'Body text'
})
const [, path, init] = jiraRequestMock.mock.calls[0]
expect(path).toBe('/rest/api/2/issue')
const body = JSON.parse((init as { body: string }).body) as {
fields: { description: unknown }
}
// REST v2 rejects ADF documents; the description must stay a plain string.
expect(body.fields.description).toBe('Body text')
})
it('assigns by username on self-hosted sites', async () => {
getClientsMock.mockReturnValue([makeServerEntry()])
jiraRequestMock.mockResolvedValue(null)
const { updateIssue } = await import('./issues')
await updateIssue('ALP-1', { assigneeAccountId: 'wquintal' }, 'server-1')
expect(jiraRequestMock).toHaveBeenCalledWith(
expect.anything(),
'/rest/api/2/issue/ALP-1/assignee',
expect.objectContaining({ body: JSON.stringify({ name: 'wquintal' }) })
)
})
it('lists self-hosted projects from the unpaged /project resource', async () => {
getClientsMock.mockReturnValue([makeServerEntry()])
jiraRequestMock.mockResolvedValueOnce([{ id: '1', key: 'ALP', name: 'Alpha' }])
const { listProjects } = await import('./issues')
const projects = await listProjects('server-1')
expect(jiraRequestMock).toHaveBeenCalledWith(expect.anything(), '/rest/api/2/project')
expect(projects).toMatchObject([{ key: 'ALP', name: 'Alpha' }])
})
it('rejects single-site search failures so the UI can surface them', async () => {
getClientsMock.mockReturnValue([makeEntry('site-1')])
jiraRequestMock.mockRejectedValueOnce(new Error('Forbidden'))
+52 -25
View File
@@ -23,6 +23,7 @@ import type {
} from '../../shared/types'
import {
acquire,
apiBasePath,
clearToken,
getClients,
isAuthError,
@@ -191,7 +192,8 @@ function avatarUrl(value: unknown): string | undefined {
function mapUser(value: unknown): JiraUser | undefined {
const user = asRecord(value)
const accountId = asString(user.accountId)
// Server/DC users have no accountId; name (login) and key are its stable ids.
const accountId = asString(user.accountId) || asString(user.name) || asString(user.key)
if (!accountId) {
return undefined
}
@@ -307,6 +309,11 @@ function issueUrl(site: JiraSite, key: string): string {
return `${site.siteUrl}/browse/${encodeURIComponent(key)}`
}
// REST v2 (Server/DC) bodies are plain text; v3 (Cloud) requires ADF documents.
function toBodyText(site: JiraSite, text: string): unknown {
return site.authType === 'server' ? text : textToAdf(text)
}
export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue {
const fields = asRecord(raw.fields)
const key = asString(raw.key)
@@ -354,7 +361,12 @@ async function searchIssuesForClient(
jql: string,
limit: number
): Promise<JiraIssue[]> {
const result = await jiraRequest<JiraSearchResponse>(entry, '/rest/api/3/search/jql', {
// Server/DC only has the classic /search resource; /search/jql is Cloud-only.
const searchPath =
entry.site.authType === 'server'
? `${apiBasePath(entry.site)}/search`
: '/rest/api/3/search/jql'
const result = await jiraRequest<JiraSearchResponse>(entry, searchPath, {
method: 'POST',
body: JSON.stringify({
jql,
@@ -429,7 +441,7 @@ export async function getIssue(
try {
const issue = await jiraRequest<JiraRecord>(
entry,
`/rest/api/3/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent(
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent(
ISSUE_FIELDS.join(',')
)}`
)
@@ -468,7 +480,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise<JiraCreate
summary: title
}
if (args.description?.trim()) {
fields.description = textToAdf(args.description.trim())
fields.description = toBodyText(entry.site, args.description.trim())
}
for (const [fieldKey, value] of Object.entries(args.customFields ?? {})) {
if (!fieldKey || value === undefined || value === null || value === '') {
@@ -478,7 +490,7 @@ export async function createIssue(args: JiraCreateIssueArgs): Promise<JiraCreate
}
const created = await jiraRequest<{ id: string; key: string; self: string }>(
entry,
'/rest/api/3/issue',
`${apiBasePath(entry.site)}/issue`,
{
method: 'POST',
body: JSON.stringify({ fields })
@@ -517,20 +529,27 @@ export async function updateIssue(
if (updates.priorityId !== undefined) {
fields.priority = updates.priorityId ? { id: updates.priorityId } : null
}
const issueBase = `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}`
if (Object.keys(fields).length > 0) {
await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}`, {
await jiraRequest(entry, issueBase, {
method: 'PUT',
body: JSON.stringify({ fields })
})
}
if (updates.assigneeAccountId !== undefined) {
await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/assignee`, {
// Server/DC identifies assignees by username (`name`), not accountId;
// mapUser stores the Server username in the accountId slot.
const assigneeBody =
entry.site.authType === 'server'
? { name: updates.assigneeAccountId }
: { accountId: updates.assigneeAccountId }
await jiraRequest(entry, `${issueBase}/assignee`, {
method: 'PUT',
body: JSON.stringify({ accountId: updates.assigneeAccountId })
body: JSON.stringify(assigneeBody)
})
}
if (updates.transitionId) {
await jiraRequest(entry, `/rest/api/3/issue/${encodeURIComponent(key)}/transitions`, {
await jiraRequest(entry, `${issueBase}/transitions`, {
method: 'POST',
body: JSON.stringify({ transition: { id: updates.transitionId } })
})
@@ -560,10 +579,10 @@ export async function addIssueComment(
try {
const comment = await jiraRequest<{ id: string }>(
entry,
`/rest/api/3/issue/${encodeURIComponent(key)}/comment`,
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment`,
{
method: 'POST',
body: JSON.stringify({ body: textToAdf(body) })
body: JSON.stringify({ body: toBodyText(entry.site, body) })
}
)
return { ok: true, id: comment.id }
@@ -604,7 +623,7 @@ export async function getIssueComments(
orderBy: 'created',
startAt: String(startAt)
})
return `/rest/api/3/issue/${encodeURIComponent(key)}/comment?${params.toString()}`
return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}`
})
return comments.map(mapComment)
} catch (error) {
@@ -628,13 +647,18 @@ export async function listProjects(siteId?: JiraSiteSelection | null): Promise<J
entries.map(async (entry) => {
await acquire()
try {
const projects = await fetchPagedRecords(entry, 'values', (startAt, maxResults) => {
const params = new URLSearchParams({
maxResults: String(maxResults),
startAt: String(startAt)
})
return `/rest/api/3/project/search?${params.toString()}`
})
// Server/DC has no /project/search resource; /project returns the
// full list as a plain (unpaged) array.
const projects =
entry.site.authType === 'server'
? await jiraRequest<JiraRecord[]>(entry, `${apiBasePath(entry.site)}/project`)
: await fetchPagedRecords(entry, 'values', (startAt, maxResults) => {
const params = new URLSearchParams({
maxResults: String(maxResults),
startAt: String(startAt)
})
return `/rest/api/3/project/search?${params.toString()}`
})
return projects.map((project) => mapProject(project, entry.site))
} catch (error) {
if (isAuthError(error)) {
@@ -669,7 +693,8 @@ export async function listIssueTypes(
maxResults: String(maxResults),
startAt: String(startAt)
})
return `/rest/api/3/issue/createmeta/${encodeURIComponent(
// Per-project createmeta paths exist on Server/DC from Jira 8.4 onward.
return `${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent(
projectIdOrKey
)}/issuetypes?${params.toString()}`
})
@@ -707,7 +732,7 @@ export async function listCreateFields(
})
const response = await jiraRequest<JiraPagedResponse<JiraRecord>>(
entry,
`/rest/api/3/issue/createmeta/${encodeURIComponent(
`${apiBasePath(entry.site)}/issue/createmeta/${encodeURIComponent(
projectIdOrKey
)}/issuetypes/${encodeURIComponent(issueTypeId)}?${params.toString()}`
)
@@ -742,7 +767,7 @@ export async function listPriorities(siteId?: string | null): Promise<JiraPriori
}
await acquire()
try {
const response = await jiraRequest<JiraRecord[]>(entry, '/rest/api/3/priority')
const response = await jiraRequest<JiraRecord[]>(entry, `${apiBasePath(entry.site)}/priority`)
return response.map(mapPriority).filter((priority): priority is JiraPriority => !!priority)
} catch (error) {
if (isAuthError(error)) {
@@ -765,15 +790,17 @@ export async function listAssignableUsers(
if (!entry) {
return []
}
const isServer = entry.site.authType === 'server'
const params = new URLSearchParams({ issueKey: key, maxResults: '50' })
if (query?.trim()) {
params.set('query', query.trim())
// Server/DC filters assignable users by `username`; `query` is Cloud-only.
params.set(isServer ? 'username' : 'query', query.trim())
}
await acquire()
try {
const response = await jiraRequest<JiraRecord[]>(
entry,
`/rest/api/3/user/assignable/search?${params.toString()}`
`${apiBasePath(entry.site)}/user/assignable/search?${params.toString()}`
)
return response.map(mapUser).filter((user): user is JiraUser => !!user)
} catch (error) {
@@ -800,7 +827,7 @@ export async function listTransitions(
try {
const response = await jiraRequest<{ transitions?: JiraRecord[] }>(
entry,
`/rest/api/3/issue/${encodeURIComponent(key)}/transitions`
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/transitions`
)
return (response.transitions ?? []).map((transition) => ({
id: asString(transition.id),
+7 -4
View File
@@ -17,8 +17,10 @@ const SiteSelection = z
const Connect = z.object({
siteUrl: requiredString('Site URL is required'),
email: requiredString('Email is required'),
apiToken: requiredString('API token is required')
// Self-hosted PAT auth needs no email; connect() enforces it for Cloud.
email: OptionalPlainString,
apiToken: requiredString('API token is required'),
authType: z.enum(['cloud', 'server']).optional()
})
const SelectSite = z.object({
@@ -100,8 +102,9 @@ export const JIRA_METHODS: RpcMethod[] = [
handler: async (params, { runtime }) =>
runtime.jiraConnect({
siteUrl: params.siteUrl.trim(),
email: params.email.trim(),
apiToken: params.apiToken.trim()
email: params.email?.trim() ?? '',
apiToken: params.apiToken.trim(),
authType: params.authType
})
}),
defineMethod({
+1
View File
@@ -2007,6 +2007,7 @@ export type PreloadApi = {
siteUrl: string
email: string
apiToken: string
authType?: 'cloud' | 'server'
}) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }>
disconnect: (args?: { siteId?: string }) => Promise<void>
selectSite: (args: { siteId: JiraSiteSelection }) => Promise<JiraConnectionStatus>
+1
View File
@@ -1744,6 +1744,7 @@ const api = {
siteUrl: string
email: string
apiToken: string
authType?: 'cloud' | 'server'
}): Promise<{ ok: true; viewer: unknown } | { ok: false; error: string }> =>
ipcRenderer.invoke('jira:connect', args),
+5 -176
View File
@@ -27,7 +27,6 @@ import {
GitPullRequestDraft,
List,
LoaderCircle,
Lock,
Minus,
Plus,
RefreshCw,
@@ -90,6 +89,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import TaskProjectSourceCombobox from '@/components/task-project-source-combobox'
import { JiraConnectDialog } from '@/components/jira-connect-dialog'
import { LinearApiKeyDialog } from '@/components/linear-api-key-dialog'
import { LinearScopeSelector } from '@/components/linear-scope-selector'
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
@@ -3118,7 +3118,6 @@ export default function TaskPage(): React.JSX.Element {
const jiraStatus = useAppStore((s) => s.jiraStatus)
const jiraStatusChecked = useAppStore((s) => s.jiraStatusChecked)
const jiraStatusContextKey = useAppStore((s) => s.jiraStatusContextKey)
const connectJira = useAppStore((s) => s.connectJira)
const selectJiraSite = useAppStore((s) => s.selectJiraSite)
const searchJiraIssues = useAppStore((s) => s.searchJiraIssues)
const listJiraIssues = useAppStore((s) => s.listJiraIssues)
@@ -5849,6 +5848,7 @@ export default function TaskPage(): React.JSX.Element {
}, [newLinearStates.data, newLinearIssueStateId])
const [linearConnectOpen, setLinearConnectOpen] = useState(false)
const [jiraConnectOpen, setJiraConnectOpen] = useState(false)
useContextualTour(
'tasks',
!dialogWorkItem &&
@@ -5858,6 +5858,7 @@ export default function TaskPage(): React.JSX.Element {
!newLinearProjectOpen &&
!newLinearIssueOpen &&
!linearConnectOpen &&
!jiraConnectOpen &&
activeModal === 'none',
'tasks_open'
)
@@ -5897,12 +5898,6 @@ export default function TaskPage(): React.JSX.Element {
const [newJiraIssueCustomFieldValues, setNewJiraIssueCustomFieldValues] = useState<
Record<string, string>
>({})
const [jiraConnectOpen, setJiraConnectOpen] = useState(false)
const [jiraSiteUrlDraft, setJiraSiteUrlDraft] = useState('')
const [jiraEmailDraft, setJiraEmailDraft] = useState('')
const [jiraApiTokenDraft, setJiraApiTokenDraft] = useState('')
const [jiraConnectState, setJiraConnectState] = useState<'idle' | 'connecting' | 'error'>('idle')
const [jiraConnectError, setJiraConnectError] = useState<string | null>(null)
const includeJiraSiteNameInProjectLabel = selectedJiraSiteId === 'all'
const previousProviderRuntimeContextKeyRef = useRef(providerRuntimeContextKey)
@@ -8114,33 +8109,6 @@ export default function TaskPage(): React.JSX.Element {
[openComposerForJiraItem]
)
const handleJiraConnect = useCallback(async (): Promise<void> => {
const siteUrl = jiraSiteUrlDraft.trim()
const email = jiraEmailDraft.trim()
const apiToken = jiraApiTokenDraft.trim()
if (!siteUrl || !email || !apiToken) {
return
}
setJiraConnectState('connecting')
setJiraConnectError(null)
try {
const result = await connectJira({ siteUrl, email, apiToken })
if (result.ok) {
setJiraSiteUrlDraft('')
setJiraEmailDraft('')
setJiraApiTokenDraft('')
setJiraConnectState('idle')
setJiraConnectOpen(false)
} else {
setJiraConnectState('error')
setJiraConnectError(result.error)
}
} catch (error) {
setJiraConnectState('error')
setJiraConnectError(error instanceof Error ? error.message : 'Connection failed')
}
}, [connectJira, jiraApiTokenDraft, jiraEmailDraft, jiraSiteUrlDraft])
const taskPageListChromeHidden = shouldHideTaskPageListChrome({
taskSource,
hasGitHubDetail: Boolean(dialogWorkItem),
@@ -10085,16 +10053,7 @@ export default function TaskPage(): React.JSX.Element {
)}
</p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-2">
<Button
onClick={() => {
setJiraSiteUrlDraft('')
setJiraEmailDraft('')
setJiraApiTokenDraft('')
setJiraConnectState('idle')
setJiraConnectError(null)
setJiraConnectOpen(true)
}}
>
<Button onClick={() => setJiraConnectOpen(true)}>
{translate('auto.components.TaskPage.83bce6be5c', 'Connect Jira')}
</Button>
<Button variant="outline" onClick={() => hideTaskSource('jira', 'Jira')}>
@@ -12700,137 +12659,7 @@ export default function TaskPage(): React.JSX.Element {
onConnected={handleLinearAccessConnected}
/>
<Dialog
open={jiraConnectOpen}
onOpenChange={(open) => {
if (jiraConnectState !== 'connecting') {
setJiraConnectOpen(open)
}
}}
>
<DialogContent
className="sm:max-w-md"
onKeyDown={(e) => {
if (
e.key === 'Enter' &&
jiraSiteUrlDraft.trim() &&
jiraEmailDraft.trim() &&
jiraApiTokenDraft.trim() &&
jiraConnectState !== 'connecting'
) {
e.preventDefault()
void handleJiraConnect()
}
}}
>
<DialogHeader className="gap-3">
<DialogTitle className="leading-tight">
{translate('auto.components.TaskPage.60f806ce99', 'Connect Jira site')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.TaskPage.33fc2bcb30',
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
)}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
autoFocus
placeholder={translate(
'auto.components.TaskPage.163df31e0e',
'https://example.atlassian.net'
)}
value={jiraSiteUrlDraft}
onChange={(e) => {
setJiraSiteUrlDraft(e.target.value)
if (jiraConnectState === 'error') {
setJiraConnectState('idle')
setJiraConnectError(null)
}
}}
disabled={jiraConnectState === 'connecting'}
/>
<Input
type="email"
placeholder={translate('auto.components.TaskPage.68df347677', 'you@example.com')}
value={jiraEmailDraft}
onChange={(e) => {
setJiraEmailDraft(e.target.value)
if (jiraConnectState === 'error') {
setJiraConnectState('idle')
setJiraConnectError(null)
}
}}
disabled={jiraConnectState === 'connecting'}
/>
<Input
type="password"
placeholder={translate('auto.components.TaskPage.b95623e93f', 'Atlassian API token')}
value={jiraApiTokenDraft}
onChange={(e) => {
setJiraApiTokenDraft(e.target.value)
if (jiraConnectState === 'error') {
setJiraConnectState('idle')
setJiraConnectError(null)
}
}}
disabled={jiraConnectState === 'connecting'}
/>
{jiraConnectState === 'error' && jiraConnectError && (
<p className="text-xs text-destructive">{jiraConnectError}</p>
)}
<p className="text-xs text-muted-foreground">
{translate('auto.components.TaskPage.59c14d34a2', 'Create a token in')}{' '}
<button
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl(
'https://id.atlassian.com/manage-profile/security/api-tokens'
)
}
>
{translate('auto.components.TaskPage.246c2b3dd3', 'Atlassian account settings')}
</button>
.
</p>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
<Lock className="size-3 shrink-0" />
{translate(
'auto.components.TaskPage.2abe22ef76',
'Your token is encrypted via the OS keychain and stored locally.'
)}
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setJiraConnectOpen(false)}
disabled={jiraConnectState === 'connecting'}
>
{translate('auto.components.TaskPage.ff69a30681', 'Cancel')}
</Button>
<Button
onClick={() => void handleJiraConnect()}
disabled={
!jiraSiteUrlDraft.trim() ||
!jiraEmailDraft.trim() ||
!jiraApiTokenDraft.trim() ||
jiraConnectState === 'connecting'
}
>
{jiraConnectState === 'connecting' ? (
<>
<LoaderCircle className="size-4 animate-spin" />
{translate('auto.components.TaskPage.513cddfa7a', 'Verifying…')}
</>
) : (
translate('auto.components.TaskPage.887efe9140', 'Connect')
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<JiraConnectDialog open={jiraConnectOpen} onOpenChange={setJiraConnectOpen} />
</div>
)
}
@@ -205,8 +205,11 @@ describe('feature interaction writer boundaries', () => {
const taskPageSource = componentSource('TaskPage.tsx')
const jiraWriter = "recordFeatureInteraction('jira-tasks')"
// End boundary is the declaration after the handler: the Jira connect flow
// now lives in the shared JiraConnectDialog, so handleJiraConnect (the prior
// marker) no longer exists in TaskPage.
expect(
sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const handleJiraConnect')
sourceBetween(taskPageSource, 'const handleUseJiraItem', 'const taskPageListChromeHidden')
).toContain(jiraWriter)
})
@@ -1,4 +1,4 @@
import { useId, useState } from 'react'
import { useId, useLayoutEffect, useState } from 'react'
import { LoaderCircle, Lock } from 'lucide-react'
import { useAppStore } from '@/store'
import { useMountedRef } from '@/hooks/useMountedRef'
@@ -13,6 +13,7 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
import { hasRemoteProviderRuntime } from '@/lib/provider-runtime-context'
import { translate } from '@/i18n/i18n'
@@ -26,6 +27,10 @@ type JiraConnectDialogProps = {
}
type ConnectState = 'idle' | 'connecting' | 'error'
type JiraInstanceType = 'cloud' | 'server'
// Self-hosted Jira accepts either a personal access token (Bearer) or classic
// username + password (Basic); older Server/DC instances predate PATs.
type ServerAuthMethod = 'pat' | 'basic'
// Why: mirrors the inline Jira connect dialog in TaskPage so the onboarding
// "Connect integrations" step can reuse the same site URL + email + API token
@@ -45,15 +50,39 @@ export function JiraConnectDialog({
const tokenId = useId()
const errorId = useId()
const [instanceType, setInstanceType] = useState<JiraInstanceType>('cloud')
const [serverAuthMethod, setServerAuthMethod] = useState<ServerAuthMethod>('pat')
const [siteUrl, setSiteUrl] = useState('')
const [email, setEmail] = useState('')
const [apiToken, setApiToken] = useState('')
const [connectState, setConnectState] = useState<ConnectState>('idle')
const [connectError, setConnectError] = useState<string | null>(null)
// Start every open with a clean slate so a previously-typed secret, stale
// instance/auth-method selection, or old error can't linger across reopens.
// Runs before paint so a stale credential never renders for a frame.
useLayoutEffect(() => {
if (!open) {
return
}
setInstanceType('cloud')
setServerAuthMethod('pat')
setSiteUrl('')
setEmail('')
setApiToken('')
setConnectState('idle')
setConnectError(null)
}, [open])
const isServer = instanceType === 'server'
// `needsIdentity` folds "Cloud Atlassian email" and "self-hosted Basic
// username" — the identity slot that keys/labels the stored site. PAT auth
// uses no identity, so the email field is hidden and left empty.
const isServerBasic = isServer && serverAuthMethod === 'basic'
const needsIdentity = !isServer || isServerBasic
const canSubmit =
Boolean(siteUrl.trim()) &&
Boolean(email.trim()) &&
(!needsIdentity || Boolean(email.trim())) &&
Boolean(apiToken.trim()) &&
connectState !== 'connecting'
const credentialStorageCopy = hasRemoteProviderRuntime(settings)
@@ -67,6 +96,16 @@ export function JiraConnectDialog({
}
}
// A Cloud email, a Server username, a PAT, and an account password are
// different secrets; drop the credential fields when the deployment or auth
// method changes so one can't be submitted as another (e.g. a password
// silently riding along as a Bearer PAT).
const clearCredentialsOnModeSwitch = (): void => {
setEmail('')
setApiToken('')
clearErrorOnEdit()
}
const handleOpenChange = (nextOpen: boolean): void => {
if (connectState !== 'connecting') {
onOpenChange(nextOpen)
@@ -77,7 +116,12 @@ export function JiraConnectDialog({
const trimmedSite = siteUrl.trim()
const trimmedEmail = email.trim()
const trimmedToken = apiToken.trim()
if (!trimmedSite || !trimmedEmail || !trimmedToken || connectState === 'connecting') {
if (
!trimmedSite ||
(needsIdentity && !trimmedEmail) ||
!trimmedToken ||
connectState === 'connecting'
) {
return
}
setConnectState('connecting')
@@ -85,8 +129,11 @@ export function JiraConnectDialog({
try {
const result = await connectJira({
siteUrl: trimmedSite,
email: trimmedEmail,
apiToken: trimmedToken
// Cloud sends the Atlassian email; self-hosted Basic sends the username;
// PAT sends nothing, so a stale email can't key/label the stored site.
email: needsIdentity ? trimmedEmail : '',
apiToken: trimmedToken,
authType: instanceType
})
if (!mountedRef.current) {
return
@@ -95,6 +142,8 @@ export function JiraConnectDialog({
setSiteUrl('')
setEmail('')
setApiToken('')
setInstanceType('cloud')
setServerAuthMethod('pat')
setConnectState('idle')
onOpenChange(false)
onConnected?.()
@@ -121,31 +170,110 @@ export function JiraConnectDialog({
{translate('auto.components.jira.connect.dialog.8388bdea2b', 'Connect Jira site')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.jira.connect.dialog.d785c42b8b',
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
)}
{!isServer
? translate(
'auto.components.jira.connect.dialog.d785c42b8b',
'Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.'
)
: isServerBasic
? translate(
'auto.components.jira.connect.dialog.1d947a07ab',
'Use a self-hosted Jira base URL, username, and password to browse issues.'
)
: translate(
'auto.components.jira.connect.dialog.2e2b69e48e',
'Use a self-hosted Jira base URL and a personal access token to browse issues.'
)}
</DialogDescription>
</DialogHeader>
<form
className="flex flex-col gap-4"
noValidate
onSubmit={(event) => {
event.preventDefault()
void handleConnect()
}}
>
<div className="flex flex-col gap-3">
<ToggleGroup
type="single"
variant="outline"
value={instanceType}
disabled={connectState === 'connecting'}
onValueChange={(value) => {
if (!value || connectState === 'connecting') {
return
}
setInstanceType(value as JiraInstanceType)
clearCredentialsOnModeSwitch()
}}
aria-label={translate(
'auto.components.jira.connect.dialog.b67e919bd5',
'Jira instance type'
)}
>
<ToggleGroupItem value="cloud" className="h-8 px-3 text-xs">
{translate('auto.components.jira.connect.dialog.17787d6e4b', 'Atlassian Cloud')}
</ToggleGroupItem>
<ToggleGroupItem value="server" className="h-8 px-3 text-xs">
{translate('auto.components.jira.connect.dialog.bc7a831773', 'Self-hosted')}
</ToggleGroupItem>
</ToggleGroup>
{isServer ? (
<ToggleGroup
type="single"
variant="outline"
value={serverAuthMethod}
disabled={connectState === 'connecting'}
onValueChange={(value) => {
if (!value || connectState === 'connecting') {
return
}
setServerAuthMethod(value as ServerAuthMethod)
clearCredentialsOnModeSwitch()
}}
aria-label={translate(
'auto.components.jira.connect.dialog.f49708c369',
'Jira authentication method'
)}
>
<ToggleGroupItem value="pat" className="h-8 px-3 text-xs">
{translate(
'auto.components.jira.connect.dialog.730d973bae',
'Personal access token'
)}
</ToggleGroupItem>
<ToggleGroupItem value="basic" className="h-8 px-3 text-xs">
{translate(
'auto.components.jira.connect.dialog.84a810dd0e',
'Username & password'
)}
</ToggleGroupItem>
</ToggleGroup>
) : null}
<div className="space-y-2">
<Label htmlFor={siteUrlId} className="text-xs">
{translate('auto.components.jira.connect.dialog.e176f9d0c5', 'Jira Cloud site URL')}
{isServer
? translate('auto.components.jira.connect.dialog.3489e186d6', 'Jira site URL')
: translate(
'auto.components.jira.connect.dialog.e176f9d0c5',
'Jira Cloud site URL'
)}
</Label>
<Input
id={siteUrlId}
autoFocus
placeholder={translate(
'auto.components.jira.connect.dialog.70fcd360c4',
'https://example.atlassian.net'
)}
placeholder={
isServer
? translate(
'auto.components.jira.connect.dialog.cbc27fa599',
'https://jira.example.com'
)
: translate(
'auto.components.jira.connect.dialog.70fcd360c4',
'https://example.atlassian.net'
)
}
value={siteUrl}
onChange={(event) => {
setSiteUrl(event.target.value)
@@ -154,36 +282,66 @@ export function JiraConnectDialog({
disabled={connectState === 'connecting'}
/>
</div>
<div className="space-y-2">
<Label htmlFor={emailId} className="text-xs">
{translate('auto.components.jira.connect.dialog.2849ddb295', 'Atlassian email')}
</Label>
<Input
id={emailId}
type="email"
placeholder={translate(
'auto.components.jira.connect.dialog.e91b9a4073',
'you@example.com'
)}
value={email}
onChange={(event) => {
setEmail(event.target.value)
clearErrorOnEdit()
}}
disabled={connectState === 'connecting'}
/>
</div>
{needsIdentity ? (
<div className="space-y-2">
<Label htmlFor={emailId} className="text-xs">
{isServerBasic
? translate('auto.components.jira.connect.dialog.8d1223fa5c', 'Username')
: translate(
'auto.components.jira.connect.dialog.2849ddb295',
'Atlassian email'
)}
</Label>
<Input
id={emailId}
type={isServerBasic ? 'text' : 'email'}
placeholder={
isServerBasic
? translate('auto.components.jira.connect.dialog.be9eba0a1b', 'username')
: translate(
'auto.components.jira.connect.dialog.e91b9a4073',
'you@example.com'
)
}
value={email}
onChange={(event) => {
setEmail(event.target.value)
clearErrorOnEdit()
}}
disabled={connectState === 'connecting'}
/>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor={tokenId} className="text-xs">
{translate('auto.components.jira.connect.dialog.3d81bf3ab3', 'API token')}
{isServerBasic
? translate('auto.components.jira.connect.dialog.70035652d7', 'Password')
: isServer
? translate(
'auto.components.jira.connect.dialog.730d973bae',
'Personal access token'
)
: translate('auto.components.jira.connect.dialog.3d81bf3ab3', 'API token')}
</Label>
<Input
id={tokenId}
type="password"
placeholder={translate(
'auto.components.jira.connect.dialog.7b3967c12f',
'Atlassian API token'
)}
placeholder={
isServerBasic
? translate(
'auto.components.jira.connect.dialog.c50abbf340',
'Jira account password'
)
: isServer
? translate(
'auto.components.jira.connect.dialog.8b9c7b9e7b',
'Jira personal access token'
)
: translate(
'auto.components.jira.connect.dialog.7b3967c12f',
'Atlassian API token'
)
}
value={apiToken}
onChange={(event) => {
setApiToken(event.target.value)
@@ -199,24 +357,40 @@ export function JiraConnectDialog({
{connectError}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
<button
type="button"
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl(
'https://id.atlassian.com/manage-profile/security/api-tokens'
)
}
>
{isServerBasic ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.jira.connect.dialog.fdd26d81cc',
'Atlassian account settings'
'auto.components.jira.connect.dialog.d8737db691',
'Use your Jira Server or Data Center account username and password.'
)}
</button>
.
</p>
</p>
) : isServer ? (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.jira.connect.dialog.ccfb086d3e',
'Create a personal access token in your Jira profile under Personal Access Tokens.'
)}
</p>
) : (
<p className="text-xs text-muted-foreground">
{translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '}
<button
type="button"
className="text-primary underline-offset-2 hover:underline"
onClick={() =>
window.api.shell.openUrl(
'https://id.atlassian.com/manage-profile/security/api-tokens'
)
}
>
{translate(
'auto.components.jira.connect.dialog.fdd26d81cc',
'Atlassian account settings'
)}
</button>
.
</p>
)}
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground/70">
<Lock className="size-3 shrink-0" />
{credentialStorageCopy}
@@ -38,8 +38,14 @@ export function JiraIntegrationCard(): React.JSX.Element {
const siteCount = sites.length || (connected ? 1 : 0)
const accountScope = getProviderAccountScope(settings)
const credentialCopy = hasRemoteProviderRuntime(settings)
? 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.'
: 'Connect a Jira Cloud site with your Atlassian email and an API token. Credentials are stored locally and encrypted when local runtime storage supports it.'
? translate(
'auto.components.settings.task.tracker.integration.cards.2d60ec7921',
'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.'
)
: translate(
'auto.components.settings.task.tracker.integration.cards.977e360b71',
'Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it.'
)
const subordinateRowClass = useIntegrationSubordinateRowClass('flex items-center gap-3')
const accountScopeRowClass = useIntegrationSubordinateRowClass('text-xs')
+21 -2
View File
@@ -8678,7 +8678,9 @@
"fe9231215b": "Checking Linear access before showing setup actions.",
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
"disconnect_all": "Disconnect",
"account_scope_prefix": "Account scope"
"account_scope_prefix": "Account scope",
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
}
}
}
@@ -12692,7 +12694,24 @@
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud site URL",
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
"8388bdea2b": "Connect Jira site",
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
"b67e919bd5": "Jira instance type",
"17787d6e4b": "Atlassian Cloud",
"bc7a831773": "Self-hosted",
"3489e186d6": "Jira site URL",
"cbc27fa599": "https://jira.example.com",
"730d973bae": "Personal access token",
"8b9c7b9e7b": "Jira personal access token",
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
"f49708c369": "Jira authentication method",
"84a810dd0e": "Username & password",
"8d1223fa5c": "Username",
"be9eba0a1b": "username",
"70035652d7": "Password",
"c50abbf340": "Jira account password",
"d8737db691": "Use your Jira Server or Data Center account username and password."
}
}
},
+21 -2
View File
@@ -8670,7 +8670,9 @@
"fe9231215b": "Comprobando el acceso a Linear antes de mostrar las acciones de configuración.",
"e1f5e6424c": "Conexión completada para {{value0}} workspace{{value1}}",
"disconnect_all": "Desconectar todo",
"account_scope_prefix": "Alcance de la cuenta"
"account_scope_prefix": "Alcance de la cuenta",
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
}
}
}
@@ -12692,7 +12694,24 @@
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "URL del sitio de Jira Cloud",
"d785c42b8b": "Usa una URL de Jira Cloud, email de Atlassian y token API para explorar issues.",
"8388bdea2b": "Conectar sitio de Jira"
"8388bdea2b": "Conectar sitio de Jira",
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
"b67e919bd5": "Jira instance type",
"17787d6e4b": "Atlassian Cloud",
"bc7a831773": "Self-hosted",
"3489e186d6": "Jira site URL",
"cbc27fa599": "https://jira.example.com",
"730d973bae": "Personal access token",
"8b9c7b9e7b": "Jira personal access token",
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
"f49708c369": "Jira authentication method",
"84a810dd0e": "Username & password",
"8d1223fa5c": "Username",
"be9eba0a1b": "username",
"70035652d7": "Password",
"c50abbf340": "Jira account password",
"d8737db691": "Use your Jira Server or Data Center account username and password."
}
}
},
+21 -2
View File
@@ -8670,7 +8670,9 @@
"fe9231215b": "Checking Linear access before showing setup actions.",
"e1f5e6424c": "{{value0}} workspace{{value1}} connected",
"disconnect_all": "切断",
"account_scope_prefix": "Account scope"
"account_scope_prefix": "Account scope",
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
}
}
}
@@ -12692,7 +12694,24 @@
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud site URL",
"d785c42b8b": "Use a Jira Cloud site URL, Atlassian email, and API token to browse issues.",
"8388bdea2b": "Connect Jira site"
"8388bdea2b": "Connect Jira site",
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
"b67e919bd5": "Jira instance type",
"17787d6e4b": "Atlassian Cloud",
"bc7a831773": "Self-hosted",
"3489e186d6": "Jira site URL",
"cbc27fa599": "https://jira.example.com",
"730d973bae": "Personal access token",
"8b9c7b9e7b": "Jira personal access token",
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
"f49708c369": "Jira authentication method",
"84a810dd0e": "Username & password",
"8d1223fa5c": "Username",
"be9eba0a1b": "username",
"70035652d7": "Password",
"c50abbf340": "Jira account password",
"d8737db691": "Use your Jira Server or Data Center account username and password."
}
}
},
+21 -2
View File
@@ -8670,7 +8670,9 @@
"fe9231215b": "설정 작업을 표시하기 전에 Linear 액세스를 확인하는 중입니다.",
"e1f5e6424c": "{{value0}}개 워크스페이스{{value1}} 연결됨",
"disconnect_all": "연결 해제",
"account_scope_prefix": "계정 범위"
"account_scope_prefix": "계정 범위",
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
}
}
}
@@ -12692,7 +12694,24 @@
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud 사이트 URL",
"d785c42b8b": "Jira Cloud 사이트 URL, Atlassian 이메일, API 토큰을 사용해 이슈를 탐색합니다.",
"8388bdea2b": "Jira 사이트 연결"
"8388bdea2b": "Jira 사이트 연결",
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
"b67e919bd5": "Jira instance type",
"17787d6e4b": "Atlassian Cloud",
"bc7a831773": "Self-hosted",
"3489e186d6": "Jira site URL",
"cbc27fa599": "https://jira.example.com",
"730d973bae": "Personal access token",
"8b9c7b9e7b": "Jira personal access token",
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
"f49708c369": "Jira authentication method",
"84a810dd0e": "Username & password",
"8d1223fa5c": "Username",
"be9eba0a1b": "username",
"70035652d7": "Password",
"c50abbf340": "Jira account password",
"d8737db691": "Use your Jira Server or Data Center account username and password."
}
}
},
+21 -2
View File
@@ -8670,7 +8670,9 @@
"fe9231215b": "在显示设置操作前正在检查 Linear 访问权限。",
"e1f5e6424c": "{{value0}} 个工作区{{value1}} 已连接",
"disconnect_all": "断开连接",
"account_scope_prefix": "账户范围"
"account_scope_prefix": "账户范围",
"2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.",
"977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it."
}
}
}
@@ -12692,7 +12694,24 @@
"70fcd360c4": "https://example.atlassian.net",
"e176f9d0c5": "Jira Cloud 站点 URL",
"d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。",
"8388bdea2b": "连接 Jira 站点"
"8388bdea2b": "连接 Jira 站点",
"2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.",
"b67e919bd5": "Jira instance type",
"17787d6e4b": "Atlassian Cloud",
"bc7a831773": "Self-hosted",
"3489e186d6": "Jira site URL",
"cbc27fa599": "https://jira.example.com",
"730d973bae": "Personal access token",
"8b9c7b9e7b": "Jira personal access token",
"ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.",
"1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.",
"f49708c369": "Jira authentication method",
"84a810dd0e": "Username & password",
"8d1223fa5c": "Username",
"be9eba0a1b": "username",
"70035652d7": "Password",
"c50abbf340": "Jira account password",
"d8737db691": "Use your Jira Server or Data Center account username and password."
}
}
},
@@ -1,5 +1,6 @@
import type {
GlobalSettings,
JiraAuthType,
JiraComment,
JiraConnectionStatus,
JiraCreateField,
@@ -57,7 +58,7 @@ export async function jiraStatus(settings: RuntimeJiraSettings): Promise<JiraCon
export async function jiraConnect(
settings: RuntimeJiraSettings,
args: { siteUrl: string; email: string; apiToken: string }
args: { siteUrl: string; email: string; apiToken: string; authType?: JiraAuthType }
): Promise<JiraConnectResult> {
const target = getJiraRuntimeTarget(settings)
return target.kind === 'environment'
+2
View File
@@ -4,6 +4,7 @@
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
import type {
JiraAuthType,
JiraConnectionStatus,
JiraIssue,
JiraIssueFilter,
@@ -163,6 +164,7 @@ export type JiraSlice = {
siteUrl: string
email: string
apiToken: string
authType?: JiraAuthType
}) => Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }>
testJiraConnection: (
siteId?: string | null
+9
View File
@@ -1,9 +1,15 @@
// 'cloud' = Atlassian Cloud (email + API token, Basic auth, REST v3).
// 'server' = self-hosted Jira Server/Data Center (personal access token,
// Bearer auth, REST v2). Older stored sites omit the field and mean 'cloud'.
export type JiraAuthType = 'cloud' | 'server'
export type JiraSite = {
id: string
siteUrl: string
email: string
displayName: string
accountId: string
authType?: JiraAuthType
}
export type JiraViewer = {
@@ -130,8 +136,11 @@ export type JiraIssueFilter = 'assigned' | 'reported' | 'all' | 'done'
export type JiraConnectArgs = {
siteUrl: string
// Ignored for 'server' auth: self-hosted PATs authenticate via Bearer
// header alone, so the email field may be empty.
email: string
apiToken: string
authType?: JiraAuthType
}
export type JiraCreateIssueArgs = {
+1
View File
@@ -1863,6 +1863,7 @@ export type {
} from './gitlab-types'
export type {
JiraAuthType,
JiraComment,
JiraConnectArgs,
JiraConnectionStatus,