From 751e091d61b7e0293757b81f2e392b3abfd4dba7 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 7 May 2026 14:05:27 -0400 Subject: [PATCH 1/4] feat(settings): in-place PAT replacement Add a Replace Token form to Settings > Data > Authentication for PAT users. Validates new token against GitHub /user API before storing. Includes same-token guard, stale-form guard, focus management, and full a11y support (aria-expanded, aria-controls, aria-busy, aria-describedby). Extend the cross-tab StorageEvent handler to detect token replacement and sync across tabs via a lightweight /user fetch with generation counter for rapid-replacement safety. 14 new tests covering the replace-token flow and cross-tab sync. --- src/app/components/settings/SettingsPage.tsx | 128 +++++++++- src/app/stores/auth.ts | 16 ++ .../components/settings/SettingsPage.test.tsx | 220 +++++++++++++++++- tests/stores/auth.test.ts | 70 +++++- 4 files changed, 427 insertions(+), 7 deletions(-) diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 74a611d3..aa073bab 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -5,7 +5,9 @@ import { useNavigate } from "@solidjs/router"; import { config, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo, isActionsBasedTab } from "../../stores/config"; import type { Config } from "../../stores/config"; import { viewState, updateViewState } from "../../stores/view"; -import { clearAuth, jiraAuth, setJiraAuth, clearJiraConfigFull, isJiraAuthenticated } from "../../stores/auth"; +import { clearAuth, jiraAuth, setJiraAuth, clearJiraConfigFull, isJiraAuthenticated, token, setAuthFromPat } from "../../stores/auth"; +import type { GitHubUser } from "../../stores/auth"; +import { isValidPatFormat } from "../../lib/pat"; import { clearCache } from "../../stores/cache"; import { pushNotification } from "../../lib/errors"; import { buildOrgAccessUrl, buildJiraAuthorizeUrl } from "../../lib/oauth"; @@ -62,6 +64,14 @@ export default function SettingsPage() { typeof Notification !== "undefined" ? Notification.permission : "default" ); + // Replace token panel + const [showReplaceToken, setShowReplaceToken] = createSignal(false); + const [replacePatInput, setReplacePatInput] = createSignal(""); + const [replacePatError, setReplacePatError] = createSignal(null); + const [replaceSubmitting, setReplaceSubmitting] = createSignal(false); + let replaceInputRef: HTMLInputElement | undefined; + let replaceButtonRef: HTMLButtonElement | undefined; + // Save indicator const [showSaved, setShowSaved] = createSignal(false); let saveTimer: ReturnType | undefined; @@ -244,6 +254,56 @@ export default function SettingsPage() { window.location.reload(); } + async function handleReplaceToken() { + if (replaceSubmitting()) return; + setReplaceSubmitting(true); + setReplacePatError(null); + const trimmedToken = replacePatInput().trim(); + + if (trimmedToken === token()) { + setReplacePatError("This is already your current token"); + setReplaceSubmitting(false); + return; + } + + const validation = isValidPatFormat(trimmedToken); + if (!validation.valid) { + setReplacePatError(validation.error); + setReplaceSubmitting(false); + return; + } + + try { + const resp = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${trimmedToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + if (!showReplaceToken()) return; + if (!resp.ok) { + setReplacePatError( + resp.status === 401 + ? "Token is invalid — check that you entered it correctly" + : `GitHub returned ${resp.status} — try again later` + ); + return; + } + const userData = (await resp.json()) as GitHubUser; + setAuthFromPat(trimmedToken, userData); + setReplacePatInput(""); + setShowReplaceToken(false); + replaceButtonRef?.focus(); + pushNotification("pat-replace", "Token replaced successfully", "info"); + } catch (err) { + Sentry.captureException(err, { tags: { source: "pat-replace" } }); + setReplacePatError("Network error — please try again"); + } finally { + setReplaceSubmitting(false); + } + } + function handleSignOut() { clearAuth(); navigate("/login"); @@ -1208,10 +1268,72 @@ export default function SettingsPage() { {/* Authentication method */} - {config.authMethod === "pat" ? "Personal Access Token" : "OAuth"} +
+ {config.authMethod === "pat" ? "Personal Access Token" : "OAuth"} + + + +
+ +
+
{ e.preventDefault(); void handleReplaceToken(); }}> +
+ setReplacePatInput(e.currentTarget.value)} + /> + + + +
+ + +
+
+
+
+
{/* Clear cache */} { _setJiraAuth(null); }); +let _crossTabFetchGen = 0; + // Cross-tab auth sync: if another tab clears the token, this tab should also clear. // Uses expireToken() (not clearAuth()) to avoid wiping config/view that may still be valid. // Also syncs Jira auth across tabs — critical for rotating refresh tokens: a stale tab @@ -340,6 +342,20 @@ if (typeof window !== "undefined") { if (localStorage.getItem(AUTH_STORAGE_KEY) !== null) return; expireToken(); window.location.replace("/login"); + } else if (e.key === AUTH_STORAGE_KEY && e.newValue !== null && e.newValue !== _token()) { + _setToken(e.newValue); + const gen = ++_crossTabFetchGen; + const newToken = e.newValue; + fetch("https://api.github.com/user", { + headers: { ...VALIDATE_HEADERS, Authorization: `Bearer ${newToken}` }, + }) + .then((r) => r.ok ? r.json() as Promise : null) + .then((data) => { + if (data && _token() === newToken && _crossTabFetchGen === gen) { + setUser({ login: data.login, avatar_url: data.avatar_url, name: data.name }); + } + }) + .catch(() => {}); } if (e.key === JIRA_AUTH_STORAGE_KEY) { try { diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx index 34f5dc5c..36888273 100644 --- a/tests/components/settings/SettingsPage.test.tsx +++ b/tests/components/settings/SettingsPage.test.tsx @@ -26,10 +26,11 @@ vi.mock("../../../src/app/stores/auth", () => ({ clearAuth: vi.fn(), clearJiraAuth: vi.fn(), setJiraAuth: vi.fn(), + setAuthFromPat: vi.fn(), jiraAuth: () => null, isJiraAuthenticated: () => false, ensureJiraTokenValid: vi.fn(), - token: () => "fake-token", + token: vi.fn(() => "fake-token"), user: () => ({ login: "testuser", name: "Test User" }), onAuthCleared: vi.fn(), })); @@ -527,6 +528,223 @@ describe("SettingsPage — Data: Sign out", () => { // Theme application tests removed — theme is now handled by createEffect in App.tsx, not SettingsPage +describe("SettingsPage — replace token", () => { + beforeEach(() => { + // Ensure authMethod is "pat" for most tests; individual tests override as needed + updateConfig({ authMethod: "pat" }); + // Reset token mock to return "fake-token" (default) + vi.mocked(authStore.token).mockReturnValue("fake-token"); + }); + + afterEach(() => { + vi.mocked(authStore.token).mockReturnValue("fake-token"); + }); + + it("Replace button is visible when authMethod is pat", () => { + renderSettings(); + screen.getByRole("button", { name: "Replace" }); + }); + + it("Replace button is NOT in DOM when authMethod is oauth", () => { + updateConfig({ authMethod: "oauth" }); + renderSettings(); + expect(screen.queryByRole("button", { name: "Replace" })).toBeNull(); + }); + + it("clicking Replace opens the form; clicking Cancel hides it and clears input", async () => { + const user = userEvent.setup(); + renderSettings(); + + const replaceBtn = screen.getByRole("button", { name: "Replace" }); + await user.click(replaceBtn); + + // Form is now visible + screen.getByRole("button", { name: "Replace token" }); + const input = screen.getByLabelText(/new personal access token/i); + await user.type(input, "ghp_someinput"); + + const cancelBtn = screen.getByRole("button", { name: "Cancel" }); + await user.click(cancelBtn); + + // Form is gone and input is cleared + expect(screen.queryByRole("button", { name: "Replace token" })).toBeNull(); + }); + + it("shows format error and does not fetch when token has wrong format", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + renderSettings(); + + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: "bad-token" } }); + + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await waitFor(() => { + screen.getByText(/token should start with ghp_/i); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("shows same-token error and does not fetch when entering current token", async () => { + const currentToken = "ghp_existingtoken123456789012345678901234"; + vi.mocked(authStore.token).mockReturnValue(currentToken); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + renderSettings(); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: currentToken } }); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await waitFor(() => { + screen.getByText(/this is already your current token/i); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("calls setAuthFromPat and closes form on successful replacement", async () => { + const newToken = "ghp_newtoken9876543210abcdefghijklmnopqrst"; + const userData = { login: "newuser", avatar_url: "https://avatars.githubusercontent.com/u/1", name: "New User" }; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(userData), + } as Response); + + renderSettings(); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: newToken } }); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await waitFor(() => { + expect(authStore.setAuthFromPat).toHaveBeenCalledWith(newToken, userData); + }); + // Form closed + expect(screen.queryByRole("button", { name: "Replace token" })).toBeNull(); + }); + + it("shows invalid-token error on 401 response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + status: 401, + json: () => Promise.resolve({}), + } as Response); + + renderSettings(); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: "ghp_newtoken9876543210abcdefghijklmnopqrst" } }); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await waitFor(() => { + screen.getByText(/token is invalid — check that you entered it correctly/i); + }); + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + }); + + it("shows network error message when fetch throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new TypeError("Failed to fetch")); + + renderSettings(); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: "ghp_newtoken9876543210abcdefghijklmnopqrst" } }); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await waitFor(() => { + screen.getByText(/network error — please try again/i); + }); + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + }); + + it("shows 'GitHub returned 500' error and does not call setAuthFromPat on 500 response", async () => { + const { pushNotification } = await import("../../../src/app/lib/errors"); + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: false, + status: 500, + json: () => Promise.resolve({}), + } as Response); + + renderSettings(); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: "ghp_newtoken9876543210abcdefghijklmnopqrst" } }); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await waitFor(() => { + screen.getByText(/GitHub returned 500/i); + }); + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + expect(pushNotification).not.toHaveBeenCalled(); + }); + + it("calls pushNotification with pat-replace on successful replacement", async () => { + const { pushNotification } = await import("../../../src/app/lib/errors"); + const newToken = "ghp_newtoken9876543210abcdefghijklmnopqrst"; + const userData = { login: "newuser", avatar_url: "https://avatars.githubusercontent.com/u/1", name: "New User" }; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(userData), + } as Response); + + renderSettings(); + fireEvent.click(screen.getByRole("button", { name: "Replace" })); + + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: newToken } }); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await waitFor(() => { + expect(pushNotification).toHaveBeenCalledWith("pat-replace", "Token replaced successfully", "info"); + }); + }); + + it("stale-form guard: setAuthFromPat not called when Cancel clicked before fetch resolves", async () => { + let resolveFetch!: (v: Response) => void; + vi.spyOn(globalThis, "fetch").mockReturnValueOnce( + new Promise((r) => { resolveFetch = r; }) + ); + + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole("button", { name: "Replace" })); + + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: "ghp_newtoken9876543210abcdefghijklmnopqrst" } }); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + // Cancel while fetch is in-flight + const cancelBtn = screen.getByRole("button", { name: "Cancel" }); + await user.click(cancelBtn); + + // Form is closed immediately on Cancel + expect(screen.queryByRole("button", { name: "Replace token" })).toBeNull(); + expect(screen.queryByLabelText(/new personal access token/i)).toBeNull(); + + // Now resolve the fetch + resolveFetch({ + ok: true, + status: 200, + json: () => Promise.resolve({ login: "newuser", avatar_url: "https://avatars.githubusercontent.com/u/1", name: "New User" }), + } as Response); + + // Wait for microtasks to flush + await new Promise((r) => setTimeout(r, 50)); + + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + // Form remains closed — no error or success state leaked through + expect(screen.queryByRole("button", { name: "Replace token" })).toBeNull(); + }); +}); + describe("SettingsPage — Auth method display", () => { it("shows 'OAuth' when authMethod is 'oauth'", () => { renderSettings(); diff --git a/tests/stores/auth.test.ts b/tests/stores/auth.test.ts index e1cc3616..9d9b11d8 100644 --- a/tests/stores/auth.test.ts +++ b/tests/stores/auth.test.ts @@ -501,18 +501,82 @@ describe("cross-tab auth sync", () => { expect(mod.token()).toBe("ghs_abc"); }); - it("does not call expireToken when newValue is not null", () => { + it("updates token signal when another tab writes a different non-null value", () => { mod.setAuth({ access_token: "ghs_abc" }); window.dispatchEvent(new StorageEvent("storage", { key: "github-tracker:auth-token", - newValue: "ghs_new-token", + newValue: "ghs_replacement", })); - // Token unchanged — non-null newValue means no sign-out occurred + // Token updated to the replacement value + expect(mod.token()).toBe("ghs_replacement"); + }); + + it("same-value StorageEvent is a no-op (dedup guard)", () => { + mod.setAuth({ access_token: "ghs_abc" }); + + window.dispatchEvent(new StorageEvent("storage", { + key: "github-tracker:auth-token", + newValue: "ghs_abc", + })); + + // Same value — no change expected expect(mod.token()).toBe("ghs_abc"); }); + it("fires /user fetch on token replacement and updates user() on success", async () => { + const userData = { login: "replaceduser", avatar_url: "https://avatars.githubusercontent.com/u/2", name: "Replaced" }; + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(userData), + }); + vi.stubGlobal("fetch", fetchMock); + + mod.setAuth({ access_token: "ghs_abc" }); + + window.dispatchEvent(new StorageEvent("storage", { + key: "github-tracker:auth-token", + newValue: "ghs_replacement", + })); + + // Token is updated synchronously + expect(mod.token()).toBe("ghs_replacement"); + + // Wait for microtasks from fire-and-forget fetch to settle + await new Promise((r) => setTimeout(r, 50)); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/user", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer ghs_replacement", + }), + }) + ); + expect(mod.user()?.login).toBe("replaceduser"); + }); + + it("user preserved when /user fetch fails after token replacement", async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + vi.stubGlobal("fetch", fetchMock); + + mod.setAuth({ access_token: "ghs_abc" }); + + window.dispatchEvent(new StorageEvent("storage", { + key: "github-tracker:auth-token", + newValue: "ghs_replacement", + })); + + await new Promise((r) => setTimeout(r, 50)); + + // user() was never set (no prior validateToken), so it remains null — no crash + expect(mod.user()).toBeNull(); + // Token still updated synchronously + expect(mod.token()).toBe("ghs_replacement"); + }); + it("does not call expireToken when no token is set in memory", () => { // No setAuth call — token signal is null, guard `_token()` prevents action localStorageMock.removeItem("github-tracker:auth-token"); From cc1cfba34f30eaefef20969a0bd23c056c926d06 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 7 May 2026 14:06:37 -0400 Subject: [PATCH 2/4] docs: token replacement in user guide Adds a note under Personal Access Token Sign-In explaining how to replace an expired or rotated PAT via Settings. --- docs/USER_GUIDE.md | 2 ++ .../components/settings/SettingsPage.test.tsx | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 70954c3b..697ccd97 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -74,6 +74,8 @@ Two token formats are accepted: The token is validated against the GitHub API before being stored. It is saved permanently in your browser's `localStorage` — you will not need to re-enter it on revisit. +To replace your token later (e.g., after expiration or rotation), go to **Settings > Data > Authentication** and click **Replace**. The new token is validated before it replaces the old one. Other open tabs update automatically. + ### Repository Selection After signing in, the onboarding wizard asks you to select repositories to track. Search by name or browse by organization. You can change your selection at any time in **Settings > Repositories**. diff --git a/tests/components/settings/SettingsPage.test.tsx b/tests/components/settings/SettingsPage.test.tsx index 36888273..efc62d51 100644 --- a/tests/components/settings/SettingsPage.test.tsx +++ b/tests/components/settings/SettingsPage.test.tsx @@ -743,6 +743,29 @@ describe("SettingsPage — replace token", () => { // Form remains closed — no error or success state leaked through expect(screen.queryByRole("button", { name: "Replace token" })).toBeNull(); }); + + it("stale-form guard: no error shown when Cancel clicked before error response resolves", async () => { + let resolveFetch!: (v: Response) => void; + vi.spyOn(globalThis, "fetch").mockReturnValueOnce( + new Promise((r) => { resolveFetch = r; }) + ); + + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole("button", { name: "Replace" })); + + const input = screen.getByLabelText(/new personal access token/i); + fireEvent.input(input, { target: { value: "ghp_newtoken9876543210abcdefghijklmnopqrst" } }); + fireEvent.click(screen.getByRole("button", { name: "Replace token" })); + + await user.click(screen.getByRole("button", { name: "Cancel" })); + + resolveFetch({ ok: false, status: 401 } as Response); + await new Promise((r) => setTimeout(r, 50)); + + expect(authStore.setAuthFromPat).not.toHaveBeenCalled(); + expect(screen.queryByText(/token is invalid/i)).toBeNull(); + }); }); describe("SettingsPage — Auth method display", () => { From c4f6e6fea25673f1bb6290593dfe690ef5eb5c63 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Thu, 7 May 2026 14:46:32 -0400 Subject: [PATCH 3/4] fix(settings): abort in-flight fetch on Cancel, fix test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AbortController to handleReplaceToken — Cancel aborts the in-flight /user fetch, immediately resetting replaceSubmitting via the catch/finally chain. Prevents the form from staying locked when the user cancels during slow API responses. Fixes auth.test.ts: user-preserved test now sets a real user before StorageEvent to prove preservation, not just null-stays-null. --- src/app/components/settings/SettingsPage.tsx | 7 ++++++- tests/stores/auth.test.ts | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index aa073bab..e7d3cd8c 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -71,6 +71,7 @@ export default function SettingsPage() { const [replaceSubmitting, setReplaceSubmitting] = createSignal(false); let replaceInputRef: HTMLInputElement | undefined; let replaceButtonRef: HTMLButtonElement | undefined; + let replaceAbortCtrl: AbortController | undefined; // Save indicator const [showSaved, setShowSaved] = createSignal(false); @@ -273,6 +274,7 @@ export default function SettingsPage() { return; } + replaceAbortCtrl = new AbortController(); try { const resp = await fetch("https://api.github.com/user", { headers: { @@ -280,6 +282,7 @@ export default function SettingsPage() { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }, + signal: replaceAbortCtrl.signal, }); if (!showReplaceToken()) return; if (!resp.ok) { @@ -297,6 +300,7 @@ export default function SettingsPage() { replaceButtonRef?.focus(); pushNotification("pat-replace", "Token replaced successfully", "info"); } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") return; Sentry.captureException(err, { tags: { source: "pat-replace" } }); setReplacePatError("Network error — please try again"); } finally { @@ -1282,6 +1286,7 @@ export default function SettingsPage() { if (opening) { setTimeout(() => replaceInputRef?.focus(), 0); } else { + replaceAbortCtrl?.abort(); setReplacePatInput(""); setReplacePatError(null); } }} @@ -1324,7 +1329,7 @@ export default function SettingsPage() {