From 6891cbb80a2de1e7671a697a0f79e0c7a6c703ad Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 8 Jul 2026 15:00:10 +0800 Subject: [PATCH 1/5] fix: validate token on options load --- src/_locales/en/messages.json | 12 ++++++++++-- src/_locales/zh_CN/messages.json | 12 ++++++++++-- src/_locales/zh_TW/messages.json | 12 ++++++++++-- src/lib/i18n.test.ts | 5 ++++- src/options.test.ts | 28 ++++++++++++++++++++++++---- src/options.ts | 24 +++++++++++++++++++++--- 6 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 93dfde4..648d47c 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -114,9 +114,17 @@ "validatingToken": { "message": "Validating token…" }, "tokenValid": { - "message": "Valid — authenticated as $user$", - "placeholders": { "user": { "content": "$1", "example": "octocat" } } + "message": "Valid — authenticated as $user$ · $expiration$", + "placeholders": { + "user": { "content": "$1", "example": "octocat" }, + "expiration": { "content": "$2", "example": "expires 2026-07-08 12:00:00 UTC" } + } + }, + "tokenExpirationDate": { + "message": "expires $date$", + "placeholders": { "date": { "content": "$1", "example": "2026-07-08 12:00:00 UTC" } } }, + "tokenExpirationNone": { "message": "no expiration date" }, "tokenInvalid": { "message": "Invalid token — authentication failed" }, "tokenValidationFailed": { "message": "Validation failed (HTTP $status$)", diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index e27fec3..6ae9eb7 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -114,9 +114,17 @@ "validatingToken": { "message": "正在验证令牌…" }, "tokenValid": { - "message": "有效 — 已认证为 $user$", - "placeholders": { "user": { "content": "$1", "example": "octocat" } } + "message": "有效 — 已认证为 $user$ · $expiration$", + "placeholders": { + "user": { "content": "$1", "example": "octocat" }, + "expiration": { "content": "$2", "example": "有效期至 2026-07-08 12:00:00 UTC" } + } + }, + "tokenExpirationDate": { + "message": "有效期至 $date$", + "placeholders": { "date": { "content": "$1", "example": "2026-07-08 12:00:00 UTC" } } }, + "tokenExpirationNone": { "message": "无过期时间" }, "tokenInvalid": { "message": "无效令牌 — 认证失败" }, "tokenValidationFailed": { "message": "验证失败(HTTP $status$)", diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json index 2b96822..fcc98ea 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -114,9 +114,17 @@ "validatingToken": { "message": "正在驗證權杖…" }, "tokenValid": { - "message": "有效 — 已驗證為 $user$", - "placeholders": { "user": { "content": "$1", "example": "octocat" } } + "message": "有效 — 已驗證為 $user$ · $expiration$", + "placeholders": { + "user": { "content": "$1", "example": "octocat" }, + "expiration": { "content": "$2", "example": "有效期至 2026-07-08 12:00:00 UTC" } + } + }, + "tokenExpirationDate": { + "message": "有效期至 $date$", + "placeholders": { "date": { "content": "$1", "example": "2026-07-08 12:00:00 UTC" } } }, + "tokenExpirationNone": { "message": "無過期時間" }, "tokenInvalid": { "message": "無效權杖 — 驗證失敗" }, "tokenValidationFailed": { "message": "驗證失敗(HTTP $status$)", diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts index ac08dcc..c8924df 100644 --- a/src/lib/i18n.test.ts +++ b/src/lib/i18n.test.ts @@ -26,11 +26,14 @@ describe("i18n", () => { it("substitutes positional placeholders in both locales", () => { setLocale("en"); - expect(t("tokenValid", "octocat")).toBe("Valid — authenticated as octocat"); + expect(t("tokenValid", ["octocat", "expires 2026-07-08 12:00:00 UTC"])).toBe( + "Valid — authenticated as octocat · expires 2026-07-08 12:00:00 UTC", + ); expect(t("diffStatsTitleWithFiles", ["1,234", "56", "3 files"])).toBe( "1,234 additions, 56 deletions across 3 files", ); setLocale("zh_CN"); + expect(t("tokenExpirationNone")).toBe("无过期时间"); expect(t("commitTagTitle", "v1.0.0")).toBe("Tag: v1.0.0"); }); diff --git a/src/options.test.ts b/src/options.test.ts index 02fb123..a7ca18c 100644 --- a/src/options.test.ts +++ b/src/options.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; const FEATURE_KEYS = [ "feature-pr-branch-names", @@ -89,7 +89,17 @@ describe("options page", () => { document.body.innerHTML = ""; }); - it("reflects stored settings into the form on load", async () => { + it("reflects stored settings and validates a stored token on load", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { "GitHub-Authentication-Token-Expiration": "2026-07-08 12:00:00 UTC" }, + }), + ), + ); + await loadOptions({ githubToken: "ghp_abc", "feature-default-sort": false }); expect($("token").value).toBe("ghp_abc"); @@ -97,6 +107,11 @@ describe("options page", () => { expect($("feature-pr-branch-names").checked).toBe(true); expect($("feature-default-sort").checked).toBe(false); expect($("footer").innerHTML).toContain("9.9.9"); + await vi.waitFor(() => + expect($("tokenStatus").textContent).toBe( + "Valid — authenticated as octocat · expires 2026-07-08 12:00:00 UTC", + ), + ); }); it("persists the token and every flag when Save is clicked", async () => { @@ -154,7 +169,10 @@ describe("options page", () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( - new Response(JSON.stringify({ login: "octocat" }), { status: 200 }), + new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { "GitHub-Authentication-Token-Expiration": "2026-07-08 12:00:00 UTC" }, + }), ), ); @@ -162,7 +180,9 @@ describe("options page", () => { token.value = "ghp_valid"; token.dispatchEvent(new Event("blur")); await vi.waitFor(() => - expect($("tokenStatus").textContent).toBe("Valid — authenticated as octocat"), + expect($("tokenStatus").textContent).toBe( + "Valid — authenticated as octocat · expires 2026-07-08 12:00:00 UTC", + ), ); expect($("tokenStatus").className).toContain("valid"); }); diff --git a/src/options.ts b/src/options.ts index d4563db..ae925a1 100644 --- a/src/options.ts +++ b/src/options.ts @@ -3,7 +3,7 @@ import { t, localizePage, initLocale, setLocale, LOCALE_KEY, type LocalePref } f const langSelect = document.getElementById("langSelect") as HTMLSelectElement | null; // Resolve the stored locale preference, reflect it in the picker, then localize. -initLocale().then((pref) => { +const localeReady = initLocale().then((pref) => { if (langSelect) langSelect.value = pref; localizePage(); }); @@ -48,6 +48,7 @@ const FEATURE_KEYS = [ chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (result) => { if (result.githubToken) { tokenInput.value = result.githubToken; + localeReady.then(() => validateToken(result.githubToken?.trim() ?? "")); } for (const key of FEATURE_KEYS) { const checkbox = document.getElementById(key) as HTMLInputElement; @@ -57,11 +58,22 @@ chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (resu // --- Token validation on blur --- let lastValidatedToken = ""; +let validationRun = 0; + +function renderValidTokenStatus(user: string, expiration: string | null) { + const expirationText = expiration + ? t("tokenExpirationDate", expiration) + : t("tokenExpirationNone"); + tokenStatus.className = "token-status valid"; + tokenStatus.textContent = t("tokenValid", [user, expirationText]); +} async function validateToken(token: string) { + const run = ++validationRun; if (!token) { tokenStatus.className = "token-status"; tokenStatus.textContent = ""; + lastValidatedToken = ""; return; } if (token === lastValidatedToken) return; @@ -74,10 +86,15 @@ async function validateToken(token: string) { headers: { Authorization: `Bearer ${token}` }, }); + if (run !== validationRun) return; + if (response.ok) { const user = await response.json(); - tokenStatus.className = "token-status valid"; - tokenStatus.textContent = t("tokenValid", user.login); + if (run !== validationRun) return; + renderValidTokenStatus( + user.login, + response.headers.get("GitHub-Authentication-Token-Expiration")?.trim() || null, + ); lastValidatedToken = token; } else if (response.status === 401) { tokenStatus.className = "token-status invalid"; @@ -89,6 +106,7 @@ async function validateToken(token: string) { lastValidatedToken = ""; } } catch { + if (run !== validationRun) return; tokenStatus.className = "token-status invalid"; tokenStatus.textContent = t("tokenNetworkError"); lastValidatedToken = ""; From 3355b105d521d8ca71f08ad077b5ddc4e649c975 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 8 Jul 2026 15:07:27 +0800 Subject: [PATCH 2/5] fix: autosave validated token --- src/options.test.ts | 57 ++++++++++++++++---------------- src/options.ts | 79 +++++++++++++++++++++++---------------------- static/options.html | 22 ------------- 3 files changed, 69 insertions(+), 89 deletions(-) diff --git a/src/options.test.ts b/src/options.test.ts index a7ca18c..d7c54c5 100644 --- a/src/options.test.ts +++ b/src/options.test.ts @@ -29,8 +29,6 @@ function buildDom(): void { document.body.innerHTML = `
- -
@@ -84,6 +82,7 @@ const $ = (id: string) => document.getElementById(id) as describe("options page", () => { afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); document.body.innerHTML = ""; @@ -114,31 +113,6 @@ describe("options page", () => { ); }); - it("persists the token and every flag when Save is clicked", async () => { - const chrome = await loadOptions({}); - $("token").value = " ghp_new "; - $("feature-commit-tags").checked = false; - - $("save").click(); - - expect(chrome.set).toHaveBeenCalledTimes(1); - const saved = chrome.set.mock.calls[0][0] as Record; - expect(saved.githubToken).toBe("ghp_new"); // trimmed - expect(saved["feature-commit-tags"]).toBe(false); - expect(saved["feature-pr-branch-names"]).toBe(true); - expect($("status").textContent).toBe("Saved!"); - }); - - it("surfaces a storage error from Save", async () => { - const chrome = await loadOptions({}); - chrome.lastError = { message: "quota exceeded" }; - - $("save").click(); - - expect($("status").textContent).toBe("quota exceeded"); - expect($("status").className).toContain("error"); - }); - it("auto-saves a single flag when its checkbox is toggled", async () => { const chrome = await loadOptions({}); const box = $("feature-release-tab"); @@ -149,6 +123,32 @@ describe("options page", () => { expect(chrome.set).toHaveBeenCalledWith({ "feature-release-tab": false }); }); + it("auto-saves a valid token after input validation", async () => { + vi.useFakeTimers(); + const chrome = await loadOptions({}); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers: { "GitHub-Authentication-Token-Expiration": "2026-07-08 12:00:00 UTC" }, + }), + ), + ); + + const token = $("token"); + token.value = " ghp_valid "; + token.dispatchEvent(new Event("input")); + await vi.advanceTimersByTimeAsync(600); + + await vi.waitFor(() => + expect(chrome.set).toHaveBeenCalledWith({ githubToken: "ghp_valid" }, expect.any(Function)), + ); + expect($("tokenStatus").textContent).toBe( + "Valid — authenticated as octocat · expires 2026-07-08 12:00:00 UTC", + ); + }); + it("filters feature items and hides groups with no match while searching", async () => { await loadOptions({}); const input = $("searchInput"); @@ -165,7 +165,7 @@ describe("options page", () => { }); it("validates the token against the GitHub API on blur", async () => { - await loadOptions({}); + const chrome = await loadOptions({}); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( @@ -185,5 +185,6 @@ describe("options page", () => { ), ); expect($("tokenStatus").className).toContain("valid"); + expect(chrome.set).toHaveBeenCalledWith({ githubToken: "ghp_valid" }, expect.any(Function)); }); }); diff --git a/src/options.ts b/src/options.ts index ae925a1..adc6fc1 100644 --- a/src/options.ts +++ b/src/options.ts @@ -18,10 +18,6 @@ langSelect?.addEventListener("change", () => { const tokenInput = document.getElementById("token") as HTMLInputElement; const tokenStatus = document.getElementById("tokenStatus") as HTMLDivElement; -const saveBtn = document.getElementById("save") as HTMLButtonElement; -// Not named `status`: as a top-level script var that would shadow the DOM -// global `window.status` (a string), breaking `.className`/`.textContent`. -const statusEl = document.getElementById("status") as HTMLDivElement; interface StoredSettings { githubToken?: string; @@ -44,11 +40,14 @@ const FEATURE_KEYS = [ "feature-contributor-card", ] as const; +let storedToken = ""; + // --- Load saved settings --- chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (result) => { if (result.githubToken) { - tokenInput.value = result.githubToken; - localeReady.then(() => validateToken(result.githubToken?.trim() ?? "")); + storedToken = result.githubToken.trim(); + tokenInput.value = storedToken; + localeReady.then(() => validateToken(storedToken)); } for (const key of FEATURE_KEYS) { const checkbox = document.getElementById(key) as HTMLInputElement; @@ -56,9 +55,10 @@ chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (resu } }); -// --- Token validation on blur --- +// --- Token validation and auto-save --- let lastValidatedToken = ""; let validationRun = 0; +let tokenValidationTimer: number | undefined; function renderValidTokenStatus(user: string, expiration: string | null) { const expirationText = expiration @@ -68,15 +68,34 @@ function renderValidTokenStatus(user: string, expiration: string | null) { tokenStatus.textContent = t("tokenValid", [user, expirationText]); } -async function validateToken(token: string) { +function persistToken(token: string, run: number) { + if (token === storedToken) return; + + chrome.storage.local.set({ githubToken: token }, () => { + if (run !== validationRun) return; + if (chrome.runtime.lastError) { + tokenStatus.className = "token-status invalid"; + tokenStatus.textContent = chrome.runtime.lastError.message ?? t("saveFailed"); + lastValidatedToken = ""; + return; + } + storedToken = token; + }); +} + +async function validateToken(token: string, shouldPersist = false) { const run = ++validationRun; if (!token) { tokenStatus.className = "token-status"; tokenStatus.textContent = ""; lastValidatedToken = ""; + if (shouldPersist) persistToken("", run); + return; + } + if (token === lastValidatedToken) { + if (shouldPersist) persistToken(token, run); return; } - if (token === lastValidatedToken) return; tokenStatus.className = "token-status checking"; tokenStatus.textContent = t("validatingToken"); @@ -96,6 +115,7 @@ async function validateToken(token: string) { response.headers.get("GitHub-Authentication-Token-Expiration")?.trim() || null, ); lastValidatedToken = token; + if (shouldPersist) persistToken(token, run); } else if (response.status === 401) { tokenStatus.className = "token-status invalid"; tokenStatus.textContent = t("tokenInvalid"); @@ -113,39 +133,20 @@ async function validateToken(token: string) { } } -tokenInput.addEventListener("blur", () => { - validateToken(tokenInput.value.trim()); -}); - -// --- Save button --- -let statusTimer: number | undefined; - -function showStatus(kind: "success" | "error", message: string) { - statusEl.className = `status ${kind}`; - statusEl.textContent = message; - if (statusTimer) clearTimeout(statusTimer); - statusTimer = window.setTimeout(() => { - statusEl.className = "status"; - statusEl.textContent = ""; - }, 2000); +function scheduleTokenValidation() { + if (tokenValidationTimer) clearTimeout(tokenValidationTimer); + tokenValidationTimer = window.setTimeout(() => { + validateToken(tokenInput.value.trim(), true); + }, 600); } -saveBtn.addEventListener("click", () => { - const token = tokenInput.value.trim(); - const settings: Record = { githubToken: token }; - - for (const key of FEATURE_KEYS) { - const checkbox = document.getElementById(key) as HTMLInputElement; - settings[key] = checkbox.checked; +tokenInput.addEventListener("input", scheduleTokenValidation); +tokenInput.addEventListener("blur", () => { + if (tokenValidationTimer) { + clearTimeout(tokenValidationTimer); + tokenValidationTimer = undefined; } - - chrome.storage.local.set(settings, () => { - if (chrome.runtime.lastError) { - showStatus("error", chrome.runtime.lastError.message ?? t("saveFailed")); - } else { - showStatus("success", t("saved")); - } - }); + validateToken(tokenInput.value.trim(), true); }); // --- Auto-save feature toggles on change --- diff --git a/static/options.html b/static/options.html index 025ff3f..5cfc66a 100644 --- a/static/options.html +++ b/static/options.html @@ -214,24 +214,6 @@ display: block; color: #656d76; } - .save-row { - display: flex; - align-items: center; - gap: 12px; - margin-top: 12px; - } - .status { - font-size: 13px; - display: none; - } - .status.success { - display: block; - color: #1a7f37; - } - .status.error { - display: block; - color: #cf222e; - } button { padding: 6px 16px; background: #2da44e; @@ -469,10 +451,6 @@

Settings

-
- -
-

⬆️ A token is required for accessing private repositories. The plugin itself will never From 7a0cbe283b9b4146cb8508070e062c41e10d1410 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 8 Jul 2026 15:12:29 +0800 Subject: [PATCH 3/5] fix: simplify token saved status --- src/_locales/en/messages.json | 6 +++--- src/_locales/zh_CN/messages.json | 6 +++--- src/_locales/zh_TW/messages.json | 6 +++--- src/lib/i18n.test.ts | 4 ++-- src/options.test.ts | 6 +++--- src/options.ts | 5 +++-- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 648d47c..b8abbac 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -114,15 +114,15 @@ "validatingToken": { "message": "Validating token…" }, "tokenValid": { - "message": "Valid — authenticated as $user$ · $expiration$", + "message": "Saved — $user$ · $expiration$", "placeholders": { "user": { "content": "$1", "example": "octocat" }, - "expiration": { "content": "$2", "example": "expires 2026-07-08 12:00:00 UTC" } + "expiration": { "content": "$2", "example": "expires 2026-07-08" } } }, "tokenExpirationDate": { "message": "expires $date$", - "placeholders": { "date": { "content": "$1", "example": "2026-07-08 12:00:00 UTC" } } + "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } }, "tokenExpirationNone": { "message": "no expiration date" }, "tokenInvalid": { "message": "Invalid token — authentication failed" }, diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index 6ae9eb7..546a8d2 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -114,15 +114,15 @@ "validatingToken": { "message": "正在验证令牌…" }, "tokenValid": { - "message": "有效 — 已认证为 $user$ · $expiration$", + "message": "已保存 — $user$ · $expiration$", "placeholders": { "user": { "content": "$1", "example": "octocat" }, - "expiration": { "content": "$2", "example": "有效期至 2026-07-08 12:00:00 UTC" } + "expiration": { "content": "$2", "example": "有效期至 2026-07-08" } } }, "tokenExpirationDate": { "message": "有效期至 $date$", - "placeholders": { "date": { "content": "$1", "example": "2026-07-08 12:00:00 UTC" } } + "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } }, "tokenExpirationNone": { "message": "无过期时间" }, "tokenInvalid": { "message": "无效令牌 — 认证失败" }, diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json index fcc98ea..33cf810 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -114,15 +114,15 @@ "validatingToken": { "message": "正在驗證權杖…" }, "tokenValid": { - "message": "有效 — 已驗證為 $user$ · $expiration$", + "message": "已儲存 — $user$ · $expiration$", "placeholders": { "user": { "content": "$1", "example": "octocat" }, - "expiration": { "content": "$2", "example": "有效期至 2026-07-08 12:00:00 UTC" } + "expiration": { "content": "$2", "example": "有效期至 2026-07-08" } } }, "tokenExpirationDate": { "message": "有效期至 $date$", - "placeholders": { "date": { "content": "$1", "example": "2026-07-08 12:00:00 UTC" } } + "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } }, "tokenExpirationNone": { "message": "無過期時間" }, "tokenInvalid": { "message": "無效權杖 — 驗證失敗" }, diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts index c8924df..bb6ca37 100644 --- a/src/lib/i18n.test.ts +++ b/src/lib/i18n.test.ts @@ -26,8 +26,8 @@ describe("i18n", () => { it("substitutes positional placeholders in both locales", () => { setLocale("en"); - expect(t("tokenValid", ["octocat", "expires 2026-07-08 12:00:00 UTC"])).toBe( - "Valid — authenticated as octocat · expires 2026-07-08 12:00:00 UTC", + expect(t("tokenValid", ["octocat", "expires 2026-07-08"])).toBe( + "Saved — octocat · expires 2026-07-08", ); expect(t("diffStatsTitleWithFiles", ["1,234", "56", "3 files"])).toBe( "1,234 additions, 56 deletions across 3 files", diff --git a/src/options.test.ts b/src/options.test.ts index d7c54c5..4487f5f 100644 --- a/src/options.test.ts +++ b/src/options.test.ts @@ -108,7 +108,7 @@ describe("options page", () => { expect($("footer").innerHTML).toContain("9.9.9"); await vi.waitFor(() => expect($("tokenStatus").textContent).toBe( - "Valid — authenticated as octocat · expires 2026-07-08 12:00:00 UTC", + "Saved — octocat · expires 2026-07-08", ), ); }); @@ -145,7 +145,7 @@ describe("options page", () => { expect(chrome.set).toHaveBeenCalledWith({ githubToken: "ghp_valid" }, expect.any(Function)), ); expect($("tokenStatus").textContent).toBe( - "Valid — authenticated as octocat · expires 2026-07-08 12:00:00 UTC", + "Saved — octocat · expires 2026-07-08", ); }); @@ -181,7 +181,7 @@ describe("options page", () => { token.dispatchEvent(new Event("blur")); await vi.waitFor(() => expect($("tokenStatus").textContent).toBe( - "Valid — authenticated as octocat · expires 2026-07-08 12:00:00 UTC", + "Saved — octocat · expires 2026-07-08", ), ); expect($("tokenStatus").className).toContain("valid"); diff --git a/src/options.ts b/src/options.ts index adc6fc1..f8e022e 100644 --- a/src/options.ts +++ b/src/options.ts @@ -61,8 +61,9 @@ let validationRun = 0; let tokenValidationTimer: number | undefined; function renderValidTokenStatus(user: string, expiration: string | null) { - const expirationText = expiration - ? t("tokenExpirationDate", expiration) + const expirationDate = expiration?.match(/^\d{4}-\d{2}-\d{2}/)?.[0] ?? null; + const expirationText = expirationDate + ? t("tokenExpirationDate", expirationDate) : t("tokenExpirationNone"); tokenStatus.className = "token-status valid"; tokenStatus.textContent = t("tokenValid", [user, expirationText]); From 84d6e44d057f28dc1696bcfa69774b49e7d2b2ce Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 8 Jul 2026 15:14:02 +0800 Subject: [PATCH 4/5] fix: clarify invalid token status --- src/_locales/en/messages.json | 2 +- src/_locales/zh_CN/messages.json | 2 +- src/_locales/zh_TW/messages.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index b8abbac..c619d54 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -125,7 +125,7 @@ "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } }, "tokenExpirationNone": { "message": "no expiration date" }, - "tokenInvalid": { "message": "Invalid token — authentication failed" }, + "tokenInvalid": { "message": "Invalid token — not saved" }, "tokenValidationFailed": { "message": "Validation failed (HTTP $status$)", "placeholders": { "status": { "content": "$1", "example": "500" } } diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index 546a8d2..ba1dae0 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -125,7 +125,7 @@ "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } }, "tokenExpirationNone": { "message": "无过期时间" }, - "tokenInvalid": { "message": "无效令牌 — 认证失败" }, + "tokenInvalid": { "message": "无效令牌 — 未保存" }, "tokenValidationFailed": { "message": "验证失败(HTTP $status$)", "placeholders": { "status": { "content": "$1", "example": "500" } } diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json index 33cf810..ad437d0 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -125,7 +125,7 @@ "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } }, "tokenExpirationNone": { "message": "無過期時間" }, - "tokenInvalid": { "message": "無效權杖 — 驗證失敗" }, + "tokenInvalid": { "message": "無效權杖 — 未儲存" }, "tokenValidationFailed": { "message": "驗證失敗(HTTP $status$)", "placeholders": { "status": { "content": "$1", "example": "500" } } From 8b28dd54025b7a0f7d2d8b42c06f5c7ac0fcb557 Mon Sep 17 00:00:00 2001 From: shawn Date: Wed, 8 Jul 2026 15:32:30 +0800 Subject: [PATCH 5/5] fix: clarify invalid saved token status --- src/_locales/en/messages.json | 3 +++ src/_locales/zh_CN/messages.json | 3 +++ src/_locales/zh_TW/messages.json | 3 +++ src/options.test.ts | 26 ++++++++++++++++---------- src/options.ts | 18 +++++++++++++----- 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index c619d54..96fb51d 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -126,6 +126,9 @@ }, "tokenExpirationNone": { "message": "no expiration date" }, "tokenInvalid": { "message": "Invalid token — not saved" }, + "tokenStoredInvalid": { + "message": "Saved token is invalid — update it to keep token-only features working" + }, "tokenValidationFailed": { "message": "Validation failed (HTTP $status$)", "placeholders": { "status": { "content": "$1", "example": "500" } } diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index ba1dae0..fbd736e 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -126,6 +126,9 @@ }, "tokenExpirationNone": { "message": "无过期时间" }, "tokenInvalid": { "message": "无效令牌 — 未保存" }, + "tokenStoredInvalid": { + "message": "已保存的令牌无效 — 请更新令牌,否则需要令牌的功能无法使用" + }, "tokenValidationFailed": { "message": "验证失败(HTTP $status$)", "placeholders": { "status": { "content": "$1", "example": "500" } } diff --git a/src/_locales/zh_TW/messages.json b/src/_locales/zh_TW/messages.json index ad437d0..8fa4ce4 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -126,6 +126,9 @@ }, "tokenExpirationNone": { "message": "無過期時間" }, "tokenInvalid": { "message": "無效權杖 — 未儲存" }, + "tokenStoredInvalid": { + "message": "已儲存的權杖無效 — 請更新權杖,否則需要權杖的功能無法使用" + }, "tokenValidationFailed": { "message": "驗證失敗(HTTP $status$)", "placeholders": { "status": { "content": "$1", "example": "500" } } diff --git a/src/options.test.ts b/src/options.test.ts index 4487f5f..b4aa697 100644 --- a/src/options.test.ts +++ b/src/options.test.ts @@ -23,9 +23,7 @@ interface ChromeStub { } function buildDom(): void { - const checkboxes = FEATURE_KEYS.map( - (k) => ``, - ).join(""); + const checkboxes = FEATURE_KEYS.map((k) => ``).join(""); document.body.innerHTML = `

@@ -106,11 +104,23 @@ describe("options page", () => { expect($("feature-pr-branch-names").checked).toBe(true); expect($("feature-default-sort").checked).toBe(false); expect($("footer").innerHTML).toContain("9.9.9"); + await vi.waitFor(() => + expect($("tokenStatus").textContent).toBe("Saved — octocat · expires 2026-07-08"), + ); + }); + + it("shows stored invalid token copy without clearing the saved token", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 401 }))); + const chrome = await loadOptions({ githubToken: "ghp_revoked" }); + await vi.waitFor(() => expect($("tokenStatus").textContent).toBe( - "Saved — octocat · expires 2026-07-08", + "Saved token is invalid — update it to keep token-only features working", ), ); + expect($("token").value).toBe("ghp_revoked"); + expect(chrome.store.githubToken).toBe("ghp_revoked"); + expect(chrome.set).not.toHaveBeenCalled(); }); it("auto-saves a single flag when its checkbox is toggled", async () => { @@ -144,9 +154,7 @@ describe("options page", () => { await vi.waitFor(() => expect(chrome.set).toHaveBeenCalledWith({ githubToken: "ghp_valid" }, expect.any(Function)), ); - expect($("tokenStatus").textContent).toBe( - "Saved — octocat · expires 2026-07-08", - ); + expect($("tokenStatus").textContent).toBe("Saved — octocat · expires 2026-07-08"); }); it("filters feature items and hides groups with no match while searching", async () => { @@ -180,9 +188,7 @@ describe("options page", () => { token.value = "ghp_valid"; token.dispatchEvent(new Event("blur")); await vi.waitFor(() => - expect($("tokenStatus").textContent).toBe( - "Saved — octocat · expires 2026-07-08", - ), + expect($("tokenStatus").textContent).toBe("Saved — octocat · expires 2026-07-08"), ); expect($("tokenStatus").className).toContain("valid"); expect(chrome.set).toHaveBeenCalledWith({ githubToken: "ghp_valid" }, expect.any(Function)); diff --git a/src/options.ts b/src/options.ts index f8e022e..3c6ac6a 100644 --- a/src/options.ts +++ b/src/options.ts @@ -47,7 +47,7 @@ chrome.storage.local.get(["githubToken", ...FEATURE_KEYS], (resu if (result.githubToken) { storedToken = result.githubToken.trim(); tokenInput.value = storedToken; - localeReady.then(() => validateToken(storedToken)); + localeReady.then(() => validateToken(storedToken, { invalidMessageKey: "tokenStoredInvalid" })); } for (const key of FEATURE_KEYS) { const checkbox = document.getElementById(key) as HTMLInputElement; @@ -60,6 +60,11 @@ let lastValidatedToken = ""; let validationRun = 0; let tokenValidationTimer: number | undefined; +interface TokenValidationOptions { + shouldPersist?: boolean; + invalidMessageKey?: "tokenInvalid" | "tokenStoredInvalid"; +} + function renderValidTokenStatus(user: string, expiration: string | null) { const expirationDate = expiration?.match(/^\d{4}-\d{2}-\d{2}/)?.[0] ?? null; const expirationText = expirationDate @@ -84,7 +89,10 @@ function persistToken(token: string, run: number) { }); } -async function validateToken(token: string, shouldPersist = false) { +async function validateToken( + token: string, + { shouldPersist = false, invalidMessageKey = "tokenInvalid" }: TokenValidationOptions = {}, +) { const run = ++validationRun; if (!token) { tokenStatus.className = "token-status"; @@ -119,7 +127,7 @@ async function validateToken(token: string, shouldPersist = false) { if (shouldPersist) persistToken(token, run); } else if (response.status === 401) { tokenStatus.className = "token-status invalid"; - tokenStatus.textContent = t("tokenInvalid"); + tokenStatus.textContent = t(invalidMessageKey); lastValidatedToken = ""; } else { tokenStatus.className = "token-status invalid"; @@ -137,7 +145,7 @@ async function validateToken(token: string, shouldPersist = false) { function scheduleTokenValidation() { if (tokenValidationTimer) clearTimeout(tokenValidationTimer); tokenValidationTimer = window.setTimeout(() => { - validateToken(tokenInput.value.trim(), true); + validateToken(tokenInput.value.trim(), { shouldPersist: true }); }, 600); } @@ -147,7 +155,7 @@ tokenInput.addEventListener("blur", () => { clearTimeout(tokenValidationTimer); tokenValidationTimer = undefined; } - validateToken(tokenInput.value.trim(), true); + validateToken(tokenInput.value.trim(), { shouldPersist: true }); }); // --- Auto-save feature toggles on change ---