diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index 93dfde4..96fb51d 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -114,10 +114,21 @@ "validatingToken": { "message": "Validating token…" }, "tokenValid": { - "message": "Valid — authenticated as $user$", - "placeholders": { "user": { "content": "$1", "example": "octocat" } } + "message": "Saved — $user$ · $expiration$", + "placeholders": { + "user": { "content": "$1", "example": "octocat" }, + "expiration": { "content": "$2", "example": "expires 2026-07-08" } + } + }, + "tokenExpirationDate": { + "message": "expires $date$", + "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } + }, + "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" }, - "tokenInvalid": { "message": "Invalid token — authentication failed" }, "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 e27fec3..fbd736e 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -114,10 +114,21 @@ "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" } + } + }, + "tokenExpirationDate": { + "message": "有效期至 $date$", + "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } + }, + "tokenExpirationNone": { "message": "无过期时间" }, + "tokenInvalid": { "message": "无效令牌 — 未保存" }, + "tokenStoredInvalid": { + "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 2b96822..8fa4ce4 100644 --- a/src/_locales/zh_TW/messages.json +++ b/src/_locales/zh_TW/messages.json @@ -114,10 +114,21 @@ "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" } + } + }, + "tokenExpirationDate": { + "message": "有效期至 $date$", + "placeholders": { "date": { "content": "$1", "example": "2026-07-08" } } + }, + "tokenExpirationNone": { "message": "無過期時間" }, + "tokenInvalid": { "message": "無效權杖 — 未儲存" }, + "tokenStoredInvalid": { + "message": "已儲存的權杖無效 — 請更新權杖,否則需要權杖的功能無法使用" }, - "tokenInvalid": { "message": "無效權杖 — 驗證失敗" }, "tokenValidationFailed": { "message": "驗證失敗(HTTP $status$)", "placeholders": { "status": { "content": "$1", "example": "500" } } diff --git a/src/lib/i18n.test.ts b/src/lib/i18n.test.ts index ac08dcc..bb6ca37 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"])).toBe( + "Saved — octocat · expires 2026-07-08", + ); 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..b4aa697 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", @@ -23,14 +23,10 @@ interface ChromeStub { } function buildDom(): void { - const checkboxes = FEATURE_KEYS.map( - (k) => ``, - ).join(""); + const checkboxes = FEATURE_KEYS.map((k) => ``).join(""); document.body.innerHTML = `
- -
@@ -84,12 +80,23 @@ const $ = (id: string) => document.getElementById(id) as describe("options page", () => { afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); 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,31 +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("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(); + 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" }); - expect($("status").textContent).toBe("quota exceeded"); - expect($("status").className).toContain("error"); + await vi.waitFor(() => + expect($("tokenStatus").textContent).toBe( + "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 () => { @@ -134,6 +133,30 @@ 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("Saved — octocat · expires 2026-07-08"); + }); + it("filters feature items and hides groups with no match while searching", async () => { await loadOptions({}); const input = $("searchInput"); @@ -150,11 +173,14 @@ 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( - 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,8 +188,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("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 d4563db..3c6ac6a 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(); }); @@ -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,10 +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; + storedToken = result.githubToken.trim(); + tokenInput.value = storedToken; + localeReady.then(() => validateToken(storedToken, { invalidMessageKey: "tokenStoredInvalid" })); } for (const key of FEATURE_KEYS) { const checkbox = document.getElementById(key) as HTMLInputElement; @@ -55,16 +55,56 @@ 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; + +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 + ? t("tokenExpirationDate", expirationDate) + : t("tokenExpirationNone"); + tokenStatus.className = "token-status valid"; + tokenStatus.textContent = t("tokenValid", [user, expirationText]); +} + +function persistToken(token: string, run: number) { + if (token === storedToken) return; -async function validateToken(token: string) { + 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, invalidMessageKey = "tokenInvalid" }: TokenValidationOptions = {}, +) { + 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"); @@ -74,14 +114,20 @@ 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; + 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"; @@ -89,45 +135,27 @@ async function validateToken(token: string) { lastValidatedToken = ""; } } catch { + if (run !== validationRun) return; tokenStatus.className = "token-status invalid"; tokenStatus.textContent = t("tokenNetworkError"); lastValidatedToken = ""; } } -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(), { shouldPersist: 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(), { shouldPersist: 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