From fbe2410fdb8527fa5c2f47d0fa540d302d1cbb38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 28 Apr 2026 11:50:30 +0100 Subject: [PATCH] feat(web): hide Jira buttons when disabled or auth failing (#725) * feat(web): hide Jira buttons when disabled or auth failing Hide the Jira import button on the task creation dialog and the "Link Jira" button on the task top bar when the workspace toggle is off, no Jira config exists, or the most recent backend auth probe failed. Centralizes the gating logic in a shared useJiraAvailable hook. * fix(web): address PR review feedback on Jira availability gate - Gate the auth probe on `loaded` from useJiraEnabled so it never fires when the toggle starts disabled - Reset cached auth on workspace change so the previous workspace's `true` can't briefly leak into the new one - JiraTopbarButton now consumes the shared useJiraAvailable hook instead of replicating the two-step gate inline * fix(web): apply prettier formatting to use-jira-availability.ts * fix(web): tag Jira auth state with workspaceId to avoid poll-tick flicker Storing `{workspaceId, authed}` together and filtering at read time invalidates stale auth synchronously when the workspace switches, removing the need for a setAuthed(false) reset that was previously firing on every 90s poll tick. Adds a fake-timer test that asserts no false render slips through across a poll tick while auth stays healthy. --------- Co-authored-by: Kandev Agent --- apps/web/components/jira/jira-import-bar.tsx | 4 + apps/web/components/jira/jira-link-button.tsx | 28 +--- .../my-jira/use-jira-availability.test.ts | 134 ++++++++++++++++++ .../jira/my-jira/use-jira-availability.ts | 53 +++++++ apps/web/components/kanban/kanban-header.tsx | 46 +----- 5 files changed, 199 insertions(+), 66 deletions(-) create mode 100644 apps/web/components/jira/my-jira/use-jira-availability.test.ts create mode 100644 apps/web/components/jira/my-jira/use-jira-availability.ts 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 (