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/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 74a611d3..e7d3cd8c 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,15 @@ 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; + let replaceAbortCtrl: AbortController | undefined; + // Save indicator const [showSaved, setShowSaved] = createSignal(false); let saveTimer: ReturnType | undefined; @@ -244,6 +255,59 @@ 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; + } + + replaceAbortCtrl = new AbortController(); + 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", + }, + signal: replaceAbortCtrl.signal, + }); + 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) { + if (err instanceof DOMException && err.name === "AbortError") return; + Sentry.captureException(err, { tags: { source: "pat-replace" } }); + setReplacePatError("Network error — please try again"); + } finally { + setReplaceSubmitting(false); + } + } + function handleSignOut() { clearAuth(); navigate("/login"); @@ -1208,10 +1272,73 @@ 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) => { if (!r.ok) { void r.body?.cancel(); return null; } return r.json() as Promise; }) + .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..249db2b7 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,251 @@ 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 + expect(screen.queryByRole("button", { name: "Replace token" })).toBeNull(); + + // Reopen and verify input was cleared + await user.click(screen.getByRole("button", { name: "Replace" })); + const reopenedInput = screen.getByLabelText(/new personal access token/i) as HTMLInputElement; + expect(reopenedInput.value).toBe(""); + }); + + 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(); + }); + + 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", () => { it("shows 'OAuth' when authMethod is 'oauth'", () => { renderSettings(); diff --git a/tests/stores/auth.test.ts b/tests/stores/auth.test.ts index e1cc3616..a4ab1084 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 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", })); - // Token unchanged — non-null newValue means no sign-out occurred 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.setAuthFromPat("ghs_abc", { login: "origuser", avatar_url: "https://avatars.githubusercontent.com/u/1", name: "Orig" }); + expect(mod.user()?.login).toBe("origuser"); + + window.dispatchEvent(new StorageEvent("storage", { + key: "github-tracker:auth-token", + newValue: "ghs_replacement", + })); + + await new Promise((r) => setTimeout(r, 50)); + + // user() preserved from before — fetch failure does not clear it + expect(mod.user()?.login).toBe("origuser"); + // 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");