From 4c810b79b242d927b9840dd5e96fca5f2d228f17 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:26:51 -0700 Subject: [PATCH] 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 --- src/main/ipc/jira.ts | 3 +- src/main/jira/client.test.ts | 223 +++++++++++++- src/main/jira/client.ts | 77 ++++- src/main/jira/issues.test.ts | 77 +++++ src/main/jira/issues.ts | 77 +++-- src/main/runtime/rpc/methods/jira.ts | 11 +- src/preload/api-types.ts | 1 + src/preload/index.ts | 1 + src/renderer/src/components/TaskPage.tsx | 181 +---------- ...ture-interaction-writer-boundaries.test.ts | 5 +- .../src/components/jira-connect-dialog.tsx | 282 ++++++++++++++---- .../settings/jira-integration-card.tsx | 10 +- src/renderer/src/i18n/locales/en.json | 23 +- src/renderer/src/i18n/locales/es.json | 23 +- src/renderer/src/i18n/locales/ja.json | 23 +- src/renderer/src/i18n/locales/ko.json | 23 +- src/renderer/src/i18n/locales/zh.json | 23 +- .../src/runtime/runtime-jira-client.ts | 3 +- src/renderer/src/store/slices/jira.ts | 2 + src/shared/jira-types.ts | 9 + src/shared/types.ts | 1 + 21 files changed, 788 insertions(+), 290 deletions(-) diff --git a/src/main/ipc/jira.ts b/src/main/ipc/jira.ts index e4a0e7ff895..44335aae86a 100644 --- a/src/main/ipc/jira.ts +++ b/src/main/ipc/jira.ts @@ -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() diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 41cb70c5beb..0c0ee11511c 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -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( diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index cce8ee85b67..4c182ee2ad5 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -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, fallbackEmail: string): JiraViewer { const avatarUrls = data.avatarUrls as Record | 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 { 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, + 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, + (await jiraRequest(client, `${apiBasePath(client.site)}/myself`)) as Record, client.site.email ) return { ok: true, viewer } diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 1cf9ab31212..4b20b0c4a2f 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -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')) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 5a19b9897b3..2ce24a87420 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -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 { - const result = await jiraRequest(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(entry, searchPath, { method: 'POST', body: JSON.stringify({ jql, @@ -429,7 +441,7 @@ export async function getIssue( try { const issue = await jiraRequest( 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( 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 { 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(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>( 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(entry, '/rest/api/3/priority') + const response = await jiraRequest(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( 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), diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 35d1d067ca1..a18eb2d02d3 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -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({ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 695e037379a..2ce823028c5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 selectSite: (args: { siteId: JiraSiteSelection }) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 64276047cea..912e1852cfa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 0ade42f2811..af47d26736d 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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 >({}) - 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(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 => { - 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 { )}

- - . -

-

- - {translate( - 'auto.components.TaskPage.2abe22ef76', - 'Your token is encrypted via the OS keychain and stored locally.' - )} -

-
- - - - - - + ) } diff --git a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts index 79ea48d8929..ad7a1197323 100644 --- a/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts +++ b/src/renderer/src/components/feature-interaction-writer-boundaries.test.ts @@ -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) }) diff --git a/src/renderer/src/components/jira-connect-dialog.tsx b/src/renderer/src/components/jira-connect-dialog.tsx index eff9f0cde24..0de9093185c 100644 --- a/src/renderer/src/components/jira-connect-dialog.tsx +++ b/src/renderer/src/components/jira-connect-dialog.tsx @@ -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('cloud') + const [serverAuthMethod, setServerAuthMethod] = useState('pat') const [siteUrl, setSiteUrl] = useState('') const [email, setEmail] = useState('') const [apiToken, setApiToken] = useState('') const [connectState, setConnectState] = useState('idle') const [connectError, setConnectError] = useState(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')} - {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.' + )}
{ event.preventDefault() void handleConnect() }} >
+ { + if (!value || connectState === 'connecting') { + return + } + setInstanceType(value as JiraInstanceType) + clearCredentialsOnModeSwitch() + }} + aria-label={translate( + 'auto.components.jira.connect.dialog.b67e919bd5', + 'Jira instance type' + )} + > + + {translate('auto.components.jira.connect.dialog.17787d6e4b', 'Atlassian Cloud')} + + + {translate('auto.components.jira.connect.dialog.bc7a831773', 'Self-hosted')} + + + {isServer ? ( + { + if (!value || connectState === 'connecting') { + return + } + setServerAuthMethod(value as ServerAuthMethod) + clearCredentialsOnModeSwitch() + }} + aria-label={translate( + 'auto.components.jira.connect.dialog.f49708c369', + 'Jira authentication method' + )} + > + + {translate( + 'auto.components.jira.connect.dialog.730d973bae', + 'Personal access token' + )} + + + {translate( + 'auto.components.jira.connect.dialog.84a810dd0e', + 'Username & password' + )} + + + ) : null}
{ setSiteUrl(event.target.value) @@ -154,36 +282,66 @@ export function JiraConnectDialog({ disabled={connectState === 'connecting'} />
-
- - { - setEmail(event.target.value) - clearErrorOnEdit() - }} - disabled={connectState === 'connecting'} - /> -
+ {needsIdentity ? ( +
+ + { + setEmail(event.target.value) + clearErrorOnEdit() + }} + disabled={connectState === 'connecting'} + /> +
+ ) : null}
{ setApiToken(event.target.value) @@ -199,24 +357,40 @@ export function JiraConnectDialog({ {connectError}

) : null} -

- {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} - - . -

+

+ ) : isServer ? ( +

+ {translate( + 'auto.components.jira.connect.dialog.ccfb086d3e', + 'Create a personal access token in your Jira profile under Personal Access Tokens.' + )} +

+ ) : ( +

+ {translate('auto.components.jira.connect.dialog.8090504a3e', 'Create a token in')}{' '} + + . +

+ )}

{credentialStorageCopy} diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index 0501d05faf1..8427e6f4cf7 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -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') diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1abdf8130ef..5fa8b146a61 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 6c4707b5349..45e1afec7a4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a9c01fdbe14..c454ec9ce47 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index cb10041dfaa..b1f7cca3488 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f8a6e220407..1e59503c12c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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." } } }, diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index 812e28b8233..a6c058ac9f7 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -1,5 +1,6 @@ import type { GlobalSettings, + JiraAuthType, JiraComment, JiraConnectionStatus, JiraCreateField, @@ -57,7 +58,7 @@ export async function jiraStatus(settings: RuntimeJiraSettings): Promise { const target = getJiraRuntimeTarget(settings) return target.kind === 'environment' diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index fd084a3d985..4f6957366f2 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -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 diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 7831bd18416..ad8e84ecbad 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -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 = { diff --git a/src/shared/types.ts b/src/shared/types.ts index 3616a2e2900..ab975939e25 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1863,6 +1863,7 @@ export type { } from './gitlab-types' export type { + JiraAuthType, JiraComment, JiraConnectArgs, JiraConnectionStatus,