diff --git a/apps/web/components/jira/jira-import-bar.tsx b/apps/web/components/jira/jira-import-bar.tsx index 8a7d7218a6..82f8d8fe34 100644 --- a/apps/web/components/jira/jira-import-bar.tsx +++ b/apps/web/components/jira/jira-import-bar.tsx @@ -9,6 +9,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { getJiraTicket } from "@/lib/api/domains/jira-api"; import type { JiraTicket } from "@/lib/types/jira"; import { JIRA_KEY_RE } from "./jira-ticket-common"; +import { useJiraAvailable } from "./my-jira/use-jira-availability"; function extractKey(input: string): string | null { const match = input.toUpperCase().match(JIRA_KEY_RE); @@ -22,6 +23,7 @@ type JiraImportBarProps = { }; export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBarProps) { + const available = useJiraAvailable(workspaceId); const [open, setOpen] = useState(false); const [value, setValue] = useState(""); const [loading, setLoading] = useState(false); @@ -51,6 +53,8 @@ export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBar } }, [workspaceId, value, onImport]); + if (!available) return null; + return ( diff --git a/apps/web/components/jira/jira-link-button.tsx b/apps/web/components/jira/jira-link-button.tsx index 051abb1b74..1149151f0a 100644 --- a/apps/web/components/jira/jira-link-button.tsx +++ b/apps/web/components/jira/jira-link-button.tsx @@ -1,34 +1,16 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { IconLink } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { toast } from "sonner"; -import { getJiraConfig, getJiraTicket } from "@/lib/api/domains/jira-api"; +import { getJiraTicket } from "@/lib/api/domains/jira-api"; import { updateTask } from "@/lib/api/domains/kanban-api"; import { JIRA_KEY_RE } from "./jira-ticket-common"; - -function useJiraConfigured(workspaceId: string | null | undefined): boolean { - const [configured, setConfigured] = useState(false); - useEffect(() => { - if (!workspaceId) return; - let cancelled = false; - void getJiraConfig(workspaceId) - .then((cfg) => { - if (!cancelled) setConfigured(!!cfg && cfg.hasSecret); - }) - .catch(() => { - if (!cancelled) setConfigured(false); - }); - return () => { - cancelled = true; - }; - }, [workspaceId]); - return workspaceId ? configured : false; -} +import { useJiraAvailable } from "./my-jira/use-jira-availability"; type JiraLinkButtonProps = { taskId: string | null | undefined; @@ -40,7 +22,7 @@ type JiraLinkButtonProps = { // prepending the ticket key to its title ("PROJ-123: ..."). The existing // JiraTicketButton picks up the key automatically once the title is updated. export function JiraLinkButton({ taskId, workspaceId, taskTitle }: JiraLinkButtonProps) { - const configured = useJiraConfigured(workspaceId); + const available = useJiraAvailable(workspaceId); const [open, setOpen] = useState(false); const [value, setValue] = useState(""); const [loading, setLoading] = useState(false); @@ -74,7 +56,7 @@ export function JiraLinkButton({ taskId, workspaceId, taskTitle }: JiraLinkButto } }, [taskId, workspaceId, value, taskTitle]); - if (!configured || !taskId || !workspaceId) return null; + if (!available || !taskId || !workspaceId) return null; return ( diff --git a/apps/web/components/jira/my-jira/use-jira-availability.test.ts b/apps/web/components/jira/my-jira/use-jira-availability.test.ts new file mode 100644 index 0000000000..4231415ce5 --- /dev/null +++ b/apps/web/components/jira/my-jira/use-jira-availability.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { JiraConfig } from "@/lib/types/jira"; + +const getJiraConfigMock = vi.fn<[string], Promise>(); + +vi.mock("@/lib/api/domains/jira-api", () => ({ + getJiraConfig: (workspaceId: string) => getJiraConfigMock(workspaceId), +})); + +import { useJiraAvailable } from "./use-jira-availability"; + +function makeConfig(overrides: Partial): JiraConfig { + return { + workspaceId: "ws-1", + siteUrl: "https://example.atlassian.net", + email: "u@example.com", + authMethod: "api_token", + defaultProjectKey: "PROJ", + hasSecret: true, + lastOk: true, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("useJiraAvailable", () => { + beforeEach(() => { + window.localStorage.clear(); + getJiraConfigMock.mockReset(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + it("returns false without a workspace id", () => { + const { result } = renderHook(() => useJiraAvailable(undefined)); + expect(result.current).toBe(false); + expect(getJiraConfigMock).not.toHaveBeenCalled(); + }); + + it("returns true when enabled, configured, and auth is healthy", async () => { + getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); + const { result } = renderHook(() => useJiraAvailable("ws-1")); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("returns false when the workspace toggle is disabled and never probes", async () => { + window.localStorage.setItem("kandev:jira:enabled:ws-1:v1", "false"); + getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); + const { result } = renderHook(() => useJiraAvailable("ws-1")); + // Disabled toggle should short-circuit the auth probe entirely. The + // `loaded` guard inside the hook keeps the fetch from racing on first render. + await waitFor(() => expect(result.current).toBe(false)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(getJiraConfigMock).not.toHaveBeenCalled(); + expect(result.current).toBe(false); + }); + + it("clears stale auth when the workspace switches", async () => { + getJiraConfigMock.mockImplementation(async (id: string) => + id === "ws-1" + ? makeConfig({ workspaceId: "ws-1", hasSecret: true, lastOk: true }) + : makeConfig({ workspaceId: "ws-2", hasSecret: false, lastOk: false }), + ); + const { result, rerender } = renderHook( + ({ id }: { id: string | undefined }) => useJiraAvailable(id), + { initialProps: { id: "ws-1" as string | undefined } }, + ); + await waitFor(() => expect(result.current).toBe(true)); + rerender({ id: "ws-2" }); + // The previous workspace's `true` must not leak into the new one even + // for the brief window before the new probe lands. + expect(result.current).toBe(false); + await waitFor(() => expect(result.current).toBe(false)); + }); + + it("returns false when no secret is configured", async () => { + getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: false, lastOk: true })); + const { result } = renderHook(() => useJiraAvailable("ws-1")); + await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); + + it("returns false when the most recent auth probe failed", async () => { + getJiraConfigMock.mockResolvedValue( + makeConfig({ hasSecret: true, lastOk: false, lastError: "401 Unauthorized" }), + ); + const { result } = renderHook(() => useJiraAvailable("ws-1")); + await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); + + it("returns false when the config request rejects", async () => { + getJiraConfigMock.mockRejectedValue(new Error("network down")); + const { result } = renderHook(() => useJiraAvailable("ws-1")); + await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); + + it("does not flicker between poll ticks while auth stays healthy", async () => { + vi.useFakeTimers(); + try { + getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); + const seen: boolean[] = []; + const { result } = renderHook(() => { + const v = useJiraAvailable("ws-1"); + seen.push(v); + return v; + }); + // Wait for the first probe to resolve and flip the value to true. + await vi.waitFor(() => expect(result.current).toBe(true)); + const beforeTick = [...seen]; + // Advance past one 90s poll. If the hook reset auth at the start of + // every tick, we'd observe a false in `seen` between this tick and + // the next probe response. + await vi.advanceTimersByTimeAsync(95_000); + expect(result.current).toBe(true); + const newRenders = seen.slice(beforeTick.length); + expect(newRenders).not.toContain(false); + } finally { + vi.useRealTimers(); + } + }); + + it("returns false when no config exists yet (backend 204)", async () => { + getJiraConfigMock.mockResolvedValue(null); + const { result } = renderHook(() => useJiraAvailable("ws-1")); + await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); + expect(result.current).toBe(false); + }); +}); diff --git a/apps/web/components/jira/my-jira/use-jira-availability.ts b/apps/web/components/jira/my-jira/use-jira-availability.ts new file mode 100644 index 0000000000..1b0191ac7d --- /dev/null +++ b/apps/web/components/jira/my-jira/use-jira-availability.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getJiraConfig } from "@/lib/api/domains/jira-api"; +import { useJiraEnabled } from "./use-jira-enabled"; + +// The backend poller probes Jira credentials roughly every 90s. Refreshing +// at the same cadence keeps the UI no more than ~one cycle stale. +const JIRA_STATUS_REFRESH_MS = 90_000; + +// Reads the backend-recorded auth health for a workspace. Returns true only +// when a config exists, has a secret, and the most recent probe succeeded. +// Pass `undefined` to short-circuit (no fetch, no interval). +// State is tagged with the workspace it was probed for so a workspace switch +// invalidates stale results synchronously at read time — no reset needed, +// which keeps the 90s poll tick from flickering the UI between request +// dispatch and response. +type AuthState = { workspaceId: string | undefined; authed: boolean }; + +export function useJiraAuthed(workspaceId: string | undefined): boolean { + const [state, setState] = useState({ workspaceId: undefined, authed: false }); + useEffect(() => { + if (!workspaceId) return; + let cancelled = false; + async function refresh() { + try { + const cfg = await getJiraConfig(workspaceId!); + if (cancelled) return; + setState({ workspaceId, authed: !!cfg?.hasSecret && !!cfg.lastOk }); + } catch { + if (!cancelled) setState({ workspaceId, authed: false }); + } + } + void refresh(); + const id = setInterval(() => void refresh(), JIRA_STATUS_REFRESH_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [workspaceId]); + return state.workspaceId === workspaceId && state.authed; +} + +// Combined check for showing Jira UI: the workspace toggle is on AND the +// backend reports a configured, healthy connection. +export function useJiraAvailable(workspaceId: string | null | undefined): boolean { + const ws = workspaceId ?? undefined; + // `loaded` flips true after the localStorage read settles; gating the probe + // on it avoids a wasted fetch on the first render when the toggle is off. + const { enabled, loaded } = useJiraEnabled(ws); + const authed = useJiraAuthed(enabled && loaded ? ws : undefined); + return enabled && authed; +} diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 2187ee2a95..e6f8a034a0 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -16,9 +16,7 @@ import { IconBrandGithub, IconTicket, } from "@tabler/icons-react"; -import { useEffect, useState } from "react"; -import { getJiraConfig } from "@/lib/api/domains/jira-api"; -import { useJiraEnabled } from "@/components/jira/my-jira/use-jira-enabled"; +import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability"; import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; import { ReleaseNotesButton } from "../release-notes/release-notes-button"; import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog"; @@ -56,41 +54,6 @@ const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [ { value: "list", icon: IconList, label: "List" }, ]; -// How often to refresh the cached config so the indicator picks up new probe -// results from the backend poller. The backend probes every 90s; refreshing at -// the same cadence keeps the indicator no more than ~one cycle stale. -const JIRA_STATUS_REFRESH_MS = 90_000; - -// Reads the backend-recorded auth health for a workspace. The actual probing -// happens server-side (jira.Poller); this hook just polls getJiraConfig to -// pick up the latest result. Returns false until a config has been loaded so -// we don't briefly flash a green check on first mount. -function useJiraAuthed(workspaceId: string | undefined): boolean { - const [authed, setAuthed] = useState(false); - useEffect(() => { - // The return-value branch below covers the no-workspace case; calling - // setAuthed here would trip the no-setState-in-effect lint rule. - if (!workspaceId) return; - let cancelled = false; - async function refresh() { - try { - const cfg = await getJiraConfig(workspaceId!); - if (cancelled) return; - setAuthed(!!cfg?.hasSecret && !!cfg.lastOk); - } catch { - if (!cancelled) setAuthed(false); - } - } - void refresh(); - const id = setInterval(() => void refresh(), JIRA_STATUS_REFRESH_MS); - return () => { - cancelled = true; - clearInterval(id); - }; - }, [workspaceId]); - return workspaceId ? authed : false; -} - function GitHubTopbarButton() { const { status } = useGitHubStatus(); if (!status?.authenticated) return null; @@ -109,11 +72,8 @@ function GitHubTopbarButton() { } function JiraTopbarButton({ workspaceId }: { workspaceId: string | undefined }) { - const { enabled } = useJiraEnabled(workspaceId); - // Skip the keep-alive probe entirely when Jira is disabled — passing - // undefined short-circuits the hook's effect. - const authed = useJiraAuthed(enabled ? workspaceId : undefined); - if (!enabled || !authed) return null; + const available = useJiraAvailable(workspaceId); + if (!available) return null; return (