diff --git a/src/main/integration-credential-file.ts b/src/main/integration-credential-file.ts new file mode 100644 index 000000000..5f971b601 --- /dev/null +++ b/src/main/integration-credential-file.ts @@ -0,0 +1,80 @@ +import { statSync } from 'fs' +import { safeStorage } from 'electron' +import { + credentialDecryptionMessage, + type IntegrationCredentialService +} from '../shared/integration-credential-errors' + +// Why: connection status treats a token file as a saved credential; empty +// files read as "missing", so counting them would split-brain getStatus. +export function credentialFileHasContent(path: string): boolean { + try { + return statSync(path).size > 0 + } catch { + return false + } +} + +export class CredentialDecryptionError extends Error { + constructor(service: IntegrationCredentialService) { + super(credentialDecryptionMessage(service)) + this.name = 'CredentialDecryptionError' + } +} + +// Returns the stored token, null when the file is empty, and throws +// CredentialDecryptionError when the file holds ciphertext we cannot decrypt +// (e.g. the user denied the OS keychain prompt after an app re-sign). +export function readStoredCredentialToken( + service: IntegrationCredentialService, + raw: Buffer +): string | null { + if (raw.length === 0) { + return null + } + + if (safeStorage.isEncryptionAvailable()) { + try { + return usableToken(safeStorage.decryptString(raw)) + } catch { + return readPlaintextLegacyCredential(service, raw) + } + } + + return readPlaintextLegacyCredential(service, raw) +} + +function readPlaintextLegacyCredential( + service: IntegrationCredentialService, + raw: Buffer +): string | null { + const plaintext = decodeUtf8(raw) + // Why: legacy plaintext tokens are printable UTF-8; safeStorage ciphertext + // such as macOS v10 blobs must not be decoded into auth-header junk. + if (plaintext === null || hasControlCharacter(plaintext)) { + throw new CredentialDecryptionError(service) + } + return usableToken(plaintext) +} + +function usableToken(token: string): string | null { + return token.length > 0 ? token : null +} + +function decodeUtf8(raw: Buffer): string | null { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(raw) + } catch { + return null + } +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index) + if (code < 0x20 || code === 0x7f) { + return true + } + } + return false +} diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts new file mode 100644 index 000000000..00b2087e3 --- /dev/null +++ b/src/main/jira/client.test.ts @@ -0,0 +1,240 @@ +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import type * as Os from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const OLD_FETCH = globalThis.fetch + +type SafeStorageMockOptions = { + encryptionAvailable?: boolean + decryptString?: (value: Buffer) => string +} + +let tempHome = '' +let fetchMock: ReturnType + +function mkdtempLike(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)) +} + +function tokenPathForSite(siteId: string): string { + return join(tempHome, '.orca', 'jira-tokens', `${Buffer.from(siteId).toString('base64url')}.enc`) +} + +function writeJiraFiles(siteId: string, token: string | Buffer): void { + 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://example.atlassian.net', + email: 'ada@example.com', + displayName: 'Ada', + accountId: 'account-alpha' + } + ] + }, + null, + 2 + ), + { encoding: 'utf-8' } + ) + writeFileSync(tokenPathForSite(siteId), token) +} + +async function loadClientModule(options: SafeStorageMockOptions = {}) { + vi.resetModules() + vi.doMock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => options.encryptionAvailable ?? false, + encryptString: (value: string) => Buffer.from(value), + decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8')) + } + })) + vi.doMock('os', async () => { + const actual = await vi.importActual('os') + return { ...actual, homedir: () => tempHome } + }) + + return import('./client') +} + +beforeEach(() => { + tempHome = mkdtempLike('orca-jira-client-') + fetchMock = vi.fn(async () => { + throw new Error('fetch should not be called') + }) + globalThis.fetch = fetchMock as typeof fetch + vi.restoreAllMocks() +}) + +afterEach(() => { + globalThis.fetch = OLD_FETCH +}) + +describe('Jira client credential storage', () => { + it('preserves plaintext fallback and reaches Jira auth header construction', async () => { + const siteId = 'site-alpha' + writeJiraFiles(siteId, 'token-alpha') + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + accountId: 'account-alpha', + displayName: 'Ada', + emailAddress: 'ada@example.com' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule({ + encryptionAvailable: true, + decryptString: () => { + throw new Error('not encrypted') + } + }) + + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ + ok: true, + viewer: { displayName: 'Ada' } + }) + + const headers = fetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe( + `Basic ${Buffer.from('ada@example.com:token-alpha').toString('base64')}` + ) + }) + + it('does not pass encrypted safeStorage bytes to Jira when encryption is unavailable', async () => { + const siteId = 'site-alpha' + const tokenPath = tokenPathForSite(siteId) + writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe])) + const jira = await loadClientModule({ encryptionAvailable: false }) + + await expect(jira.testConnection(siteId)).resolves.toEqual({ + ok: false, + error: 'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.' + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(existsSync(tokenPath)).toBe(true) + expect(jira.getStatus()).toMatchObject({ + connected: true, + credentialError: + 'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.', + sites: [{ id: siteId }] + }) + }) + + it('does not clear the Jira token when safeStorage decryption fails', async () => { + const siteId = 'site-alpha' + const tokenPath = tokenPathForSite(siteId) + writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe])) + const jira = await loadClientModule({ + encryptionAvailable: true, + decryptString: () => { + throw new Error('userCanceledErr') + } + }) + + await expect(jira.testConnection(siteId)).resolves.toEqual({ + ok: false, + error: 'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.' + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(existsSync(tokenPath)).toBe(true) + expect(jira.getStatus()).toMatchObject({ + connected: true, + credentialError: + 'Could not decrypt saved Jira credential. Approve Keychain access or reconnect Jira.', + sites: [{ id: siteId }] + }) + }) + + it('does not clear plaintext fallback credentials on Jira auth failure after decrypt failure', async () => { + const siteId = 'site-alpha' + const tokenPath = tokenPathForSite(siteId) + writeJiraFiles(siteId, 'token-revoked') + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ errorMessages: ['Jira authentication failed'] }), { + status: 401, + statusText: 'Unauthorized', + headers: { 'Content-Type': 'application/json' } + }) + ) + const jira = await loadClientModule({ + encryptionAvailable: true, + decryptString: () => { + throw new Error('userCanceledErr') + } + }) + + await expect(jira.testConnection(siteId)).resolves.toEqual({ + ok: false, + error: 'Jira authentication failed' + }) + + expect(existsSync(tokenPath)).toBe(true) + expect(jira.getStatus()).toMatchObject({ + connected: true, + sites: [{ id: siteId }] + }) + }) + + it('clears the recorded credential error after Keychain access is approved', async () => { + const siteId = 'site-alpha' + let keychainApproved = false + writeJiraFiles(siteId, Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe])) + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + accountId: 'account-alpha', + displayName: 'Ada', + emailAddress: 'ada@example.com' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + const jira = await loadClientModule({ + encryptionAvailable: true, + decryptString: () => { + if (!keychainApproved) { + throw new Error('userCanceledErr') + } + return 'token-alpha' + } + }) + + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ ok: false }) + expect(jira.getStatus().credentialError).toContain('Could not decrypt') + + keychainApproved = true + await expect(jira.testConnection(siteId)).resolves.toMatchObject({ + ok: true, + viewer: { displayName: 'Ada' } + }) + expect(jira.getStatus().credentialError).toBeUndefined() + }) + + it('treats empty Jira token files as missing credentials', async () => { + const siteId = 'site-alpha' + writeJiraFiles(siteId, Buffer.alloc(0)) + const jira = await loadClientModule({ encryptionAvailable: false }) + + await expect(jira.testConnection(siteId)).resolves.toEqual({ + ok: false, + error: 'Not connected to Jira.' + }) + + expect(fetchMock).not.toHaveBeenCalled() + expect(jira.getStatus()).toMatchObject({ connected: false }) + }) +}) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index cc7c56459..468c5d936 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -6,6 +6,11 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from ' import { homedir } from 'os' import { join } from 'path' import { safeStorage } from 'electron' +import { + CredentialDecryptionError, + credentialFileHasContent, + readStoredCredentialToken +} from '../integration-credential-file' import type { JiraConnectArgs, JiraConnectionStatus, @@ -63,6 +68,9 @@ export class JiraApiError extends Error { let cachedSiteFile: JiraSiteFile | null = null let siteFileLoaded = false const cachedTokens = new Map() +// Why: decrypt failures are recorded per site so getStatus can explain +// failing reads without re-touching the keychain on every status poll. +const credentialErrors = new Map() function getOrcaDir(): string { return join(homedir(), '.orca') @@ -104,7 +112,7 @@ function emptySiteFile(): JiraSiteFile { } function hasStoredToken(siteId: string): boolean { - return cachedTokens.has(siteId) || existsSync(getTokenPath(siteId)) + return cachedTokens.has(siteId) || credentialFileHasContent(getTokenPath(siteId)) } function normalizeSite(input: unknown): JiraSite | null { @@ -206,7 +214,7 @@ function writeEncryptedToken(path: string, apiToken: string): void { function readToken(siteId: string): string | null { const cached = cachedTokens.get(siteId) - if (cached) { + if (cached !== undefined) { return cached } const path = getTokenPath(siteId) @@ -215,12 +223,17 @@ function readToken(siteId: string): string | null { } try { const raw = readFileSync(path) - const token = safeStorage.isEncryptionAvailable() - ? safeStorage.decryptString(raw) - : raw.toString('utf-8') - cachedTokens.set(siteId, token) + const token = readStoredCredentialToken('Jira', raw) + if (token) { + cachedTokens.set(siteId, token) + } + credentialErrors.delete(siteId) return token - } catch { + } catch (error) { + if (error instanceof CredentialDecryptionError) { + credentialErrors.set(siteId, error.message) + throw error + } return null } } @@ -230,10 +243,12 @@ function saveToken(siteId: string, apiToken: string): void { ensureTokenDir() writeEncryptedToken(getTokenPath(siteId), apiToken) cachedTokens.set(siteId, apiToken) + credentialErrors.delete(siteId) } function deleteToken(siteId: string): void { cachedTokens.delete(siteId) + credentialErrors.delete(siteId) try { unlinkSync(getTokenPath(siteId)) } catch { @@ -373,12 +388,16 @@ export function getStatus(): JiraConnectionStatus { const file = getSiteFile() const sites = file.sites.filter((site) => hasStoredToken(site.id)) const activeSite = sites.find((site) => site.id === file.activeSiteId) ?? sites[0] ?? null + const credentialError = sites + .map((site) => credentialErrors.get(site.id)) + .find((message) => message !== undefined) return { connected: sites.length > 0, viewer: siteToViewer(activeSite), sites, activeSiteId: activeSite?.id ?? null, - selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null + selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null, + ...(credentialError ? { credentialError } : {}) } } @@ -461,7 +480,12 @@ export function selectSite(siteId: JiraSiteSelection): JiraConnectionStatus { export async function testConnection( siteId?: string ): Promise<{ ok: true; viewer: JiraViewer } | { ok: false; error: string }> { - const client = getClients(siteId)[0] + let client: JiraClientForSite | undefined + try { + client = getClients(siteId)[0] + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Connection failed.' } + } if (!client) { return { ok: false, error: 'Not connected to Jira.' } } diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index c5fa06b81..7225a0e3e 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { JiraClientForSite } from './client' +import { credentialDecryptionMessage } from '../../shared/integration-credential-errors' -const { clearTokenMock, getClientsMock, jiraRequestMock } = vi.hoisted(() => ({ +const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi.hoisted(() => ({ clearTokenMock: vi.fn(), getClientsMock: vi.fn(), + isAuthErrorMock: vi.fn(), jiraRequestMock: vi.fn() })) @@ -12,7 +14,7 @@ vi.mock('./client', () => ({ release: vi.fn(), clearToken: (...args: unknown[]) => clearTokenMock(...args), getClients: (...args: unknown[]) => getClientsMock(...args), - isAuthError: vi.fn().mockReturnValue(false), + isAuthError: (...args: unknown[]) => isAuthErrorMock(...args), jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) })) @@ -32,9 +34,32 @@ function makeEntry(): JiraClientForSite { describe('Jira issue operations', () => { beforeEach(() => { vi.clearAllMocks() + isAuthErrorMock.mockReturnValue(false) getClientsMock.mockReturnValue([makeEntry()]) }) + it('surfaces Jira credential decrypt errors on active issue, metadata, and mutation paths', async () => { + const error = new Error(credentialDecryptionMessage('Jira')) + getClientsMock.mockImplementation(() => { + throw error + }) + const { createIssue, getIssue, listIssueTypes, listProjects, searchIssues } = + await import('./issues') + + await expect(searchIssues('project = ALP', 20, 'site-1')).rejects.toThrow(error.message) + await expect(getIssue('ALP-1', 'site-1')).rejects.toThrow(error.message) + await expect(listProjects('site-1')).rejects.toThrow(error.message) + await expect(listIssueTypes('10000', 'site-1')).rejects.toThrow(error.message) + await expect( + createIssue({ + siteId: 'site-1', + projectId: '10000', + issueTypeId: '10001', + title: 'Fix auth' + }) + ).rejects.toThrow(error.message) + }) + it('paginates Jira project search results before sorting them', async () => { jiraRequestMock .mockResolvedValueOnce({ diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts index 30c049b3c..d566c087c 100644 --- a/src/main/linear/client.test.ts +++ b/src/main/linear/client.test.ts @@ -16,16 +16,25 @@ let tempHome = '' let fixtures = new Map() let linearClientMock: ReturnType +type SafeStorageMockOptions = { + encryptionAvailable?: boolean + decryptString?: (value: Buffer) => string +} + function writeLegacyLinearFiles(token: string, viewer: Record): void { + writeLegacyLinearToken(token, viewer) +} + +function writeLegacyLinearToken(token: string | Buffer, viewer: Record): void { const orcaDir = join(tempHome, '.orca') mkdirSync(orcaDir, { recursive: true }) - writeFileSync(join(orcaDir, 'linear-token.enc'), token, { encoding: 'utf-8' }) + writeFileSync(join(orcaDir, 'linear-token.enc'), token) writeFileSync(join(orcaDir, 'linear-viewer.json'), JSON.stringify(viewer), { encoding: 'utf-8' }) } -async function loadClientModule() { +async function loadClientModule(options: SafeStorageMockOptions = {}) { vi.resetModules() linearClientMock = vi.fn(function LinearClient( this: { viewer: Promise }, @@ -47,17 +56,18 @@ async function loadClientModule() { }) vi.doMock('electron', () => ({ safeStorage: { - isEncryptionAvailable: () => false, + isEncryptionAvailable: () => options.encryptionAvailable ?? false, encryptString: (value: string) => Buffer.from(value), - decryptString: (value: Buffer) => value.toString('utf-8') + decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8')) } })) vi.doMock('os', async () => { const actual = await vi.importActual('os') return { ...actual, homedir: () => tempHome } }) + class AuthenticationLinearError extends Error {} vi.doMock('@linear/sdk', () => ({ - AuthenticationLinearError: class AuthenticationLinearError extends Error {}, + AuthenticationLinearError, LinearClient: linearClientMock })) @@ -167,4 +177,129 @@ describe('Linear client workspace storage', () => { 'org-alpha' ) }) + + it('preserves plaintext legacy token fallback when safeStorage cannot decrypt it', async () => { + writeLegacyLinearFiles('token-alpha', { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha' + }) + const linear = await loadClientModule({ + encryptionAvailable: true, + decryptString: () => { + throw new Error('not encrypted') + } + }) + + await expect(linear.testConnection('legacy')).resolves.toMatchObject({ + ok: true, + workspace: { id: 'org-alpha', organizationName: 'Alpha' } + }) + + expect(linearClientMock).toHaveBeenCalledWith({ apiKey: 'token-alpha' }) + }) + + it('does not pass encrypted safeStorage bytes to the Linear SDK when encryption is unavailable', async () => { + const tokenPath = join(tempHome, '.orca', 'linear-token.enc') + writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha' + }) + const linear = await loadClientModule({ encryptionAvailable: false }) + + await expect(linear.testConnection('legacy')).resolves.toEqual({ + ok: false, + error: + 'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.' + }) + + expect(linearClientMock).not.toHaveBeenCalled() + expect(existsSync(tokenPath)).toBe(true) + expect(linear.getStatus()).toMatchObject({ + connected: true, + credentialError: + 'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.', + workspaces: [{ id: 'legacy' }] + }) + }) + + it('does not clear the Linear token when safeStorage decryption fails', async () => { + const tokenPath = join(tempHome, '.orca', 'linear-token.enc') + writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha' + }) + const linear = await loadClientModule({ + encryptionAvailable: true, + decryptString: () => { + throw new Error('userCanceledErr') + } + }) + + await expect(linear.testConnection('legacy')).resolves.toEqual({ + ok: false, + error: + 'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.' + }) + + expect(linearClientMock).not.toHaveBeenCalled() + expect(existsSync(tokenPath)).toBe(true) + expect(linear.getStatus()).toMatchObject({ + connected: true, + credentialError: + 'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.', + workspaces: [{ id: 'legacy' }] + }) + }) + + it('clears the recorded credential error after Keychain access is approved', async () => { + let keychainApproved = false + writeLegacyLinearToken(Buffer.from([0x76, 0x31, 0x30, 0xff, 0xfe]), { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha' + }) + const linear = await loadClientModule({ + encryptionAvailable: true, + decryptString: () => { + if (!keychainApproved) { + throw new Error('userCanceledErr') + } + return 'token-alpha' + } + }) + + await expect(linear.testConnection('legacy')).resolves.toEqual({ + ok: false, + error: + 'Could not decrypt saved Linear credential. Approve Keychain access or reconnect Linear.' + }) + expect(linear.getStatus().credentialError).toContain('Could not decrypt') + + keychainApproved = true + await expect(linear.testConnection('legacy')).resolves.toMatchObject({ + ok: true, + workspace: { id: 'org-alpha', organizationName: 'Alpha' } + }) + expect(linear.getStatus().credentialError).toBeUndefined() + }) + + it('treats empty Linear token files as missing credentials', async () => { + writeLegacyLinearToken(Buffer.alloc(0), { + displayName: 'Ada', + email: 'ada@example.com', + organizationName: 'Alpha' + }) + const linear = await loadClientModule({ encryptionAvailable: false }) + + await expect(linear.testConnection('legacy')).resolves.toEqual({ + ok: false, + error: 'No API key stored.' + }) + + expect(linearClientMock).not.toHaveBeenCalled() + expect(linear.getStatus()).toMatchObject({ connected: false }) + }) }) diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index f32d96a4a..56d8a9a5a 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -6,6 +6,11 @@ import { LinearClient, AuthenticationLinearError } from '@linear/sdk' import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs' import { homedir } from 'os' import { join } from 'path' +import { + CredentialDecryptionError, + credentialFileHasContent, + readStoredCredentialToken +} from '../integration-credential-file' import type { LinearConnectionStatus, LinearViewer, @@ -58,6 +63,9 @@ export type LinearClientForWorkspace = { } let cachedTokens = new Map() +// Why: decrypt failures are recorded per workspace so getStatus can explain +// failing reads without re-touching the keychain on every status poll. +const credentialErrors = new Map() let cachedLegacyViewer: LinearViewer | null = null let legacyViewerLoadedFromDisk = false let cachedWorkspaceFile: LinearWorkspaceFile | null = null @@ -327,6 +335,7 @@ function saveWorkspaceToken(workspaceId: string, apiKey: string): void { const tokenPath = getWorkspaceTokenPath(workspaceId) writeEncryptedToken(tokenPath, apiKey) cachedTokens.set(workspaceId, apiKey) + credentialErrors.delete(workspaceId) } // Backward-compatible export for the legacy single-workspace storage path. @@ -352,12 +361,17 @@ export function loadToken(options: { force?: boolean; workspaceId?: string } = { } try { const raw = readFileSync(tokenPath) - const token = safeStorage.isEncryptionAvailable() - ? safeStorage.decryptString(raw) - : raw.toString('utf-8') - cachedTokens.set(workspaceId, token) + const token = readStoredCredentialToken('Linear', raw) + if (token) { + cachedTokens.set(workspaceId, token) + } + credentialErrors.delete(workspaceId) return token - } catch { + } catch (error) { + if (error instanceof CredentialDecryptionError) { + credentialErrors.set(workspaceId, error.message) + throw error + } return null } } @@ -369,11 +383,12 @@ export function hasStoredToken(workspaceId?: string): boolean { if (cachedTokens.has(workspaceId)) { return true } - return existsSync(getWorkspaceTokenPath(workspaceId)) + return credentialFileHasContent(getWorkspaceTokenPath(workspaceId)) } function clearTokenFile(workspaceId: string): void { cachedTokens.delete(workspaceId) + credentialErrors.delete(workspaceId) try { unlinkSync(getWorkspaceTokenPath(workspaceId)) } catch { @@ -388,6 +403,7 @@ export function clearToken(workspaceId?: string): void { clearTokenFile(workspace.id) } cachedTokens = new Map() + credentialErrors.clear() cachedLegacyViewer = null legacyViewerLoadedFromDisk = false cachedWorkspaceFile = emptyWorkspaceFile() @@ -594,12 +610,17 @@ export function getStatus(): LinearConnectionStatus { state.workspaces[0] ?? null + const credentialError = state.workspaces + .map((workspace) => credentialErrors.get(workspace.id)) + .find((message) => message !== undefined) + return { connected: state.workspaces.length > 0, viewer: activeWorkspace, workspaces: state.workspaces, activeWorkspaceId: state.activeWorkspaceId, - selectedWorkspaceId: state.selectedWorkspaceId + selectedWorkspaceId: state.selectedWorkspaceId, + ...(credentialError ? { credentialError } : {}) } } @@ -612,7 +633,13 @@ export async function testConnection( if (!resolvedWorkspaceId) { return { ok: false, error: 'No API key stored.' } } - const token = loadToken({ force: true, workspaceId: resolvedWorkspaceId }) + let token: string | null + try { + token = loadToken({ force: true, workspaceId: resolvedWorkspaceId }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Test failed' + return { ok: false, error: message } + } if (!token) { return { ok: false, error: 'No API key stored.' } } diff --git a/src/main/linear/issues.test.ts b/src/main/linear/issues.test.ts index 9c4e643b2..e5f7db492 100644 --- a/src/main/linear/issues.test.ts +++ b/src/main/linear/issues.test.ts @@ -1,15 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LinearClientForWorkspace } from './client' +import { credentialDecryptionMessage } from '../../shared/integration-credential-errors' const rawRequest = vi.fn() const getClients = vi.fn() const clearToken = vi.fn() +const isAuthError = vi.fn() vi.mock('./client', () => ({ acquire: vi.fn().mockResolvedValue(undefined), release: vi.fn(), getClients: (...args: unknown[]) => getClients(...args), - isAuthError: vi.fn().mockReturnValue(false), + isAuthError: (...args: unknown[]) => isAuthError(...args), clearToken: (...args: unknown[]) => clearToken(...args) })) @@ -87,6 +89,7 @@ function datedIssues(prefix: string, count: number, startMs: number, startIndex describe('Linear issue queries', () => { beforeEach(() => { vi.clearAllMocks() + isAuthError.mockReturnValue(false) getClients.mockReturnValue([makeEntry()]) }) @@ -167,6 +170,20 @@ describe('Linear issue queries', () => { }) }) + it('surfaces Linear credential decrypt errors on active issue reads and mutations', async () => { + const error = new Error(credentialDecryptionMessage('Linear')) + getClients.mockImplementation(() => { + throw error + }) + const { createIssue, listIssues, searchIssues } = await import('./issues') + + await expect(searchIssues('bug', 20, 'workspace-1')).rejects.toThrow(error.message) + await expect(listIssues('all', 20, 'workspace-1')).rejects.toThrow(error.message) + await expect(createIssue('team-1', 'Fix auth', undefined, 'workspace-1')).rejects.toThrow( + error.message + ) + }) + it('marks plain list results as having more when Linear has a next page', async () => { rawRequest.mockResolvedValueOnce({ data: { issues: { nodes: [rawIssue('LIN-1')], pageInfo: { hasNextPage: true } } } diff --git a/src/main/linear/projects.test.ts b/src/main/linear/projects.test.ts index c64c23971..f760b25b3 100644 --- a/src/main/linear/projects.test.ts +++ b/src/main/linear/projects.test.ts @@ -1,15 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LinearClientForWorkspace } from './client' +import { credentialDecryptionMessage } from '../../shared/integration-credential-errors' const rawRequest = vi.fn() const getClients = vi.fn() const clearToken = vi.fn() +const isAuthError = vi.fn() vi.mock('./client', () => ({ acquire: vi.fn().mockResolvedValue(undefined), release: vi.fn(), getClients: (...args: unknown[]) => getClients(...args), - isAuthError: vi.fn().mockReturnValue(false), + isAuthError: (...args: unknown[]) => isAuthError(...args), clearToken: (...args: unknown[]) => clearToken(...args) })) @@ -144,9 +146,20 @@ describe('Linear project queries', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + isAuthError.mockReturnValue(false) getClients.mockReturnValue([makeEntry()]) }) + it('surfaces Linear credential decrypt errors on active project metadata reads', async () => { + const error = new Error(credentialDecryptionMessage('Linear')) + getClients.mockImplementation(() => { + throw error + }) + const { listProjects } = await import('./projects') + + await expect(listProjects(undefined, 20, 'workspace-1', true)).rejects.toThrow(error.message) + }) + it('lets manual project issue refresh bypass older in-flight reads', async () => { const staleRequest = deferred>() const refreshRequest = deferred>() diff --git a/src/main/linear/teams.test.ts b/src/main/linear/teams.test.ts index 0ce71536b..9c9e17f14 100644 --- a/src/main/linear/teams.test.ts +++ b/src/main/linear/teams.test.ts @@ -1,14 +1,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LinearClientForWorkspace } from './client' +import { credentialDecryptionMessage } from '../../shared/integration-credential-errors' const getClients = vi.fn() const clearToken = vi.fn() +const isAuthError = vi.fn() vi.mock('./client', () => ({ acquire: vi.fn().mockResolvedValue(undefined), release: vi.fn(), getClients: (...args: unknown[]) => getClients(...args), - isAuthError: vi.fn().mockReturnValue(false), + isAuthError: (...args: unknown[]) => isAuthError(...args), clearToken: (...args: unknown[]) => clearToken(...args) })) @@ -65,6 +67,7 @@ function makeEntry( describe('Linear teams', () => { beforeEach(() => { vi.clearAllMocks() + isAuthError.mockReturnValue(false) }) it('fetches every page of teams for a workspace', async () => { @@ -97,4 +100,14 @@ describe('Linear teams', () => { { id: 'team-b', workspaceId: 'workspace-2', workspaceName: 'Beta' } ]) }) + + it('surfaces Linear credential decrypt errors on active team reads', async () => { + const error = new Error(credentialDecryptionMessage('Linear')) + getClients.mockImplementation(() => { + throw error + }) + const { listTeams } = await import('./teams') + + await expect(listTeams('workspace-1')).rejects.toThrow(error.message) + }) }) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index e1480a4dd..f7f68eaf7 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -3735,11 +3735,12 @@ export default function TaskPage(): React.JSX.Element { ? linearCustomViewContentsLoading : linearLoading const activeLinearIssueError = - selectedLinearProject && linearProjectTab === 'issues' + linearStatus.credentialError ?? + (selectedLinearProject && linearProjectTab === 'issues' ? linearProjectIssuesError : selectedLinearCustomView?.model === 'issue' ? linearCustomViewContentsError - : linearError + : linearError) const activeLinearIssueCollectionErrors = selectedLinearProject && linearProjectTab === 'issues' ? linearProjectIssuesResult.errors @@ -8337,9 +8338,9 @@ export default function TaskPage(): React.JSX.Element { className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek" style={{ scrollbarGutter: 'stable' }} > - {jiraError ? ( + {(jiraStatus.credentialError ?? jiraError) ? (
- {jiraError} + {jiraStatus.credentialError ?? jiraError}
) : null} @@ -8354,7 +8355,10 @@ export default function TaskPage(): React.JSX.Element { ) : null} - {!jiraLoading && jiraIssues.length === 0 && !jiraError ? ( + {!jiraLoading && + jiraIssues.length === 0 && + !jiraError && + !jiraStatus.credentialError ? (

{translate('auto.components.TaskPage.eba87f2edb', 'No Jira issues found')} diff --git a/src/renderer/src/components/settings/IntegrationsPane.tsx b/src/renderer/src/components/settings/IntegrationsPane.tsx index ed6f9b47a..e99a671b3 100644 --- a/src/renderer/src/components/settings/IntegrationsPane.tsx +++ b/src/renderer/src/components/settings/IntegrationsPane.tsx @@ -770,6 +770,12 @@ export function IntegrationsPane(): React.JSX.Element { 'Add Linear access to browse and link issues.' )}

+ {linearStatus.credentialError ? ( +

+ + {linearStatus.credentialError} +

+ ) : null}
{linearStatus.connected ? (
diff --git a/src/renderer/src/store/slices/jira.test.ts b/src/renderer/src/store/slices/jira.test.ts new file mode 100644 index 000000000..6d117f9b7 --- /dev/null +++ b/src/renderer/src/store/slices/jira.test.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import type { AppState } from '../types' +import type { JiraIssue } from '../../../../shared/types' +import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' +import { createJiraSlice } from './jira' + +const jiraGetIssue = vi.fn() +const jiraListIssues = vi.fn() +const jiraSearchIssues = vi.fn() +const jiraStatus = vi.fn() + +vi.mock('@/runtime/runtime-jira-client', () => ({ + jiraConnect: vi.fn(), + jiraDisconnect: vi.fn(), + jiraGetIssue: (...args: unknown[]) => jiraGetIssue(...args), + jiraListIssues: (...args: unknown[]) => jiraListIssues(...args), + jiraSearchIssues: (...args: unknown[]) => jiraSearchIssues(...args), + jiraSelectSite: vi.fn(), + jiraStatus: (...args: unknown[]) => jiraStatus(...args), + jiraTestConnection: vi.fn() +})) + +function createTestStore() { + return create()( + (...a) => + ({ + settings: null, + ...createJiraSlice(...a) + }) as AppState + ) +} + +function issue(key: string): JiraIssue { + return { + id: key, + key, + title: key, + url: `https://example.atlassian.net/browse/${key}`, + siteId: 'site-1', + siteName: 'Example Jira', + project: { id: '10000', key: 'ALP', name: 'Alpha', siteId: 'site-1' }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: '1', name: 'Todo', categoryKey: 'new', categoryName: 'To Do' }, + labels: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z' + } +} + +describe('createJiraSlice credential errors', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('serves fresh Jira cache without reading credentials', async () => { + const store = createTestStore() + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' }, + jiraSearchCache: { + 'site-1::list::assigned::30': { data: [issue('ALP-1')], fetchedAt: Date.now() } + } + }) + + await expect(store.getState().listJiraIssues('assigned', 30)).resolves.toMatchObject([ + { key: 'ALP-1' } + ]) + + expect(jiraListIssues).not.toHaveBeenCalled() + }) + + it('returns an empty list and surfaces the credential error in status on Jira decrypt errors', async () => { + const store = createTestStore() + const error = new Error(credentialDecryptionMessage('Jira')) + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' } + }) + jiraStatus.mockResolvedValue({ + connected: true, + viewer: null, + selectedSiteId: 'site-1', + credentialError: error.message + }) + jiraSearchIssues.mockRejectedValueOnce(error) + + await expect(store.getState().searchJiraIssues('project = ALP', 30)).resolves.toEqual([]) + await vi.waitFor(() => { + expect(store.getState().jiraStatus.credentialError).toBe(error.message) + }) + }) + + it('returns null and refreshes status on Jira decrypt errors during detail refresh', async () => { + const store = createTestStore() + const error = new Error(credentialDecryptionMessage('Jira')) + store.setState({ + jiraStatus: { connected: true, viewer: null, selectedSiteId: 'site-1' }, + jiraIssueCache: { + 'site-1::ALP-1': { data: issue('ALP-1'), fetchedAt: 1 } + } + }) + jiraStatus.mockResolvedValue({ + connected: true, + viewer: null, + selectedSiteId: 'site-1', + credentialError: error.message + }) + jiraGetIssue.mockRejectedValueOnce(error) + + await expect(store.getState().fetchJiraIssue('ALP-1', 'site-1')).resolves.toBeNull() + expect(jiraStatus).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/slices/jira.ts b/src/renderer/src/store/slices/jira.ts index d7a5d1815..40a80db70 100644 --- a/src/renderer/src/store/slices/jira.ts +++ b/src/renderer/src/store/slices/jira.ts @@ -11,6 +11,7 @@ import type { JiraViewer } from '../../../../shared/types' import type { CacheEntry } from './github' +import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' import { jiraConnect, jiraDisconnect, @@ -99,6 +100,7 @@ export const createJiraSlice: StateCreator = (set, const prev = get().jiraStatus if ( prev.connected !== status.connected || + prev.credentialError !== status.credentialError || prev.viewer?.email !== status.viewer?.email || getSelectedSiteId(prev) !== getSelectedSiteId(status) || (prev.sites?.length ?? 0) !== (status.sites?.length ?? 0) @@ -187,7 +189,9 @@ export const createJiraSlice: StateCreator = (set, }) .catch((error) => { console.warn('[jira] fetchJiraIssue failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkJiraConnection() + } else if (looksLikeAuthError(error)) { set({ jiraStatus: { connected: false, viewer: null } }) } return null @@ -222,7 +226,9 @@ export const createJiraSlice: StateCreator = (set, }) .catch((error) => { console.warn('[jira] searchJiraIssues failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkJiraConnection() + } else if (looksLikeAuthError(error)) { set({ jiraStatus: { connected: false, viewer: null } }) } return [] @@ -257,7 +263,9 @@ export const createJiraSlice: StateCreator = (set, }) .catch((error) => { console.warn('[jira] listJiraIssues failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkJiraConnection() + } else if (looksLikeAuthError(error)) { set({ jiraStatus: { connected: false, viewer: null } }) } return [] diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index cd131d884..6f40e1adc 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -12,6 +12,7 @@ import type { LinearTeam, LinearViewer } from '../../../../shared/types' +import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' import { createLinearSlice } from './linear' const linearStatus = vi.fn() @@ -195,6 +196,45 @@ describe('createLinearSlice caching', () => { ).resolves.toMatchObject({ items: [{ id: 'LIN-CACHED' }] }) }) + it('returns an empty list and refreshes status on Linear decrypt errors during list reads', async () => { + const store = createTestStore() + const error = new Error(credentialDecryptionMessage('Linear')) + store.setState({ + linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' }, + linearListCache: { + 'workspace-1::list::all::36': { data: { items: [issue('LIN-CACHED')] }, fetchedAt: 1 } + } + }) + linearStatus.mockResolvedValue({ + connected: true, + viewer: null, + credentialError: error.message + }) + linearListIssues.mockRejectedValueOnce(error) + + await expect( + store.getState().listLinearIssues('all', 36, { force: true }) + ).resolves.toMatchObject({ items: [] }) + expect(linearStatus).toHaveBeenCalled() + }) + + it('returns an empty list and refreshes status on Linear decrypt errors during searches', async () => { + const store = createTestStore() + const error = new Error(credentialDecryptionMessage('Linear')) + store.setState({ + linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' } + }) + linearStatus.mockResolvedValue({ + connected: true, + viewer: null, + credentialError: error.message + }) + linearSearchIssues.mockRejectedValueOnce(error) + + await expect(store.getState().searchLinearIssues('bug', 36)).resolves.toEqual([]) + expect(linearStatus).toHaveBeenCalled() + }) + it('surfaces scoped project issue failures alongside cached rows', async () => { const store = createTestStore() store.setState({ @@ -216,6 +256,33 @@ describe('createLinearSlice caching', () => { expect(linearListProjectIssues.mock.calls[0][4]).toEqual({ force: true }) }) + it('surfaces Linear decrypt errors as workspace errors on project issue reads', async () => { + const store = createTestStore() + const error = new Error(credentialDecryptionMessage('Linear')) + store.setState({ + linearProjectIssueCache: { + 'workspace-1::project-issues::project-1::20': { + data: { items: [issue('LIN-CACHED')] }, + fetchedAt: 1 + } + } + }) + linearStatus.mockResolvedValue({ + connected: true, + viewer: null, + credentialError: error.message + }) + linearListProjectIssues.mockRejectedValueOnce(error) + + await expect( + store.getState().listLinearProjectIssues('project-1', 'workspace-1', 20, { force: true }) + ).resolves.toMatchObject({ + items: [{ id: 'LIN-CACHED' }], + errors: [{ message: error.message }] + }) + expect(linearStatus).toHaveBeenCalled() + }) + it('falls back to the largest smaller cached project issue limit when expansion fails', async () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 35711a031..eb078d53c 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -19,6 +19,7 @@ import type { } from '../../../../shared/types' import type { CacheEntry } from './github' import { clampLinearIssueListLimit } from '../../../../shared/linear-issue-read-limits' +import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' import { linearConnect, @@ -198,6 +199,7 @@ function linearWorkspaceSignature(workspace: LinearWorkspace): string { function linearStatusScopeSignature(status: LinearConnectionStatus): string { return JSON.stringify({ connected: status.connected, + credentialError: status.credentialError ?? null, activeWorkspaceId: status.activeWorkspaceId ?? null, selectedWorkspaceId: getSelectedWorkspaceId(status), viewer: status.viewer @@ -710,7 +712,7 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] fetchLinearIssue failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { void get().checkLinearConnection(true) } return null @@ -793,7 +795,7 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] searchLinearIssues failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { if (!shouldRefreshStatusAfterRead(workspaceId)) { void get().checkLinearConnection(true) } @@ -857,7 +859,7 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearIssues failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { if (!shouldRefreshStatusAfterRead(workspaceId)) { void get().checkLinearConnection(true) } @@ -920,7 +922,7 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearTeams failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) { if (!shouldRefreshStatusAfterRead(resolvedWorkspaceId)) { void get().checkLinearConnection(true) } @@ -986,7 +988,9 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearProjects failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkLinearConnection(true) + } else if (looksLikeAuthError(error)) { set({ linearStatus: { connected: false, viewer: null } }) } const fallback = @@ -1039,7 +1043,9 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] fetchLinearProject failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkLinearConnection(true) + } else if (looksLikeAuthError(error)) { set({ linearStatus: { connected: false, viewer: null } }) } if (options?.force) { @@ -1107,7 +1113,9 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearProjectIssues failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkLinearConnection(true) + } else if (looksLikeAuthError(error)) { set({ linearStatus: { connected: false, viewer: null } }) } const fallback = @@ -1173,7 +1181,9 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearCustomViews failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkLinearConnection(true) + } else if (looksLikeAuthError(error)) { set({ linearStatus: { connected: false, viewer: null } }) } const fallback = @@ -1227,7 +1237,9 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] fetchLinearCustomView failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkLinearConnection(true) + } else if (looksLikeAuthError(error)) { set({ linearStatus: { connected: false, viewer: null } }) } if (options?.force) { @@ -1295,7 +1307,9 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearCustomViewIssues failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkLinearConnection(true) + } else if (looksLikeAuthError(error)) { set({ linearStatus: { connected: false, viewer: null } }) } const fallback = @@ -1354,7 +1368,9 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearCustomViewProjects failed:', error) - if (looksLikeAuthError(error)) { + if (isIntegrationCredentialDecryptionError(error)) { + void get().checkLinearConnection(true) + } else if (looksLikeAuthError(error)) { set({ linearStatus: { connected: false, viewer: null } }) } const fallback = diff --git a/src/shared/integration-credential-errors.ts b/src/shared/integration-credential-errors.ts new file mode 100644 index 000000000..3a2cc5561 --- /dev/null +++ b/src/shared/integration-credential-errors.ts @@ -0,0 +1,15 @@ +export type IntegrationCredentialService = 'Linear' | 'Jira' + +export function credentialDecryptionMessage(service: IntegrationCredentialService): string { + return `Could not decrypt saved ${service} credential. Approve Keychain access or reconnect ${service}.` +} + +// Why: decrypt errors cross IPC/RPC boundaries where only the message +// survives serialization, so detection matches on the canonical message. +export function isIntegrationCredentialDecryptionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return ( + message.includes(credentialDecryptionMessage('Linear')) || + message.includes(credentialDecryptionMessage('Jira')) + ) +} diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 2ce42a6d4..8ce07fde4 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -21,6 +21,9 @@ export type JiraConnectionStatus = { sites?: JiraSite[] activeSiteId?: string | null selectedSiteId?: JiraSiteSelection | null + // Set when a stored token file exists but could not be decrypted, so the + // UI can explain reads failing while the connection still looks saved. + credentialError?: string } export type JiraProject = { diff --git a/src/shared/types.ts b/src/shared/types.ts index 57b051498..2957a7808 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1161,6 +1161,9 @@ export type LinearConnectionStatus = { workspaces?: LinearWorkspace[] activeWorkspaceId?: string | null selectedWorkspaceId?: LinearWorkspaceSelection | null + // Set when a stored token file exists but could not be decrypted, so the + // UI can explain reads failing while the connection still looks saved. + credentialError?: string } export type LinearIssue = {