diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 5970e79f..1815e73a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -71,7 +71,7 @@ body: id: extension-version attributes: label: Extension version - placeholder: "Example: v1.2.0" + placeholder: "Example: vX.Y.Z" - type: dropdown id: mode diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 9eaab1d9..50669354 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -24,6 +24,8 @@ jobs: build: name: Build website runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ github.token }} steps: - name: Checkout diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 7458a336..7244d8c0 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -26,6 +26,8 @@ jobs: name: Build preview site if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ github.token }} steps: - name: Checkout production site diff --git a/entrypoints/popup/components/DisabledInlineSitesPanel.tsx b/entrypoints/popup/components/DisabledInlineSitesPanel.tsx new file mode 100644 index 00000000..e31076ad --- /dev/null +++ b/entrypoints/popup/components/DisabledInlineSitesPanel.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from "react"; +import { t } from "../../../lib/i18n"; +import { filterDisabledInlineSites } from "src/utils/inlineSiteSettings"; +import Button from "./Button"; +import Input from "./Input"; + +const INLINE_SITES_PAGE_SIZE = 5; + +interface DisabledInlineSitesPanelProps { + sites: string[]; + onEnable: (site: string) => Promise; +} + +/** Renders searchable, paginated controls for websites with the inline helper disabled. */ +export default function DisabledInlineSitesPanel({ + sites, + onEnable, +}: DisabledInlineSitesPanelProps) { + const [query, setQuery] = useState(""); + const [page, setPage] = useState(1); + const filteredSites = filterDisabledInlineSites(sites, query); + const pageCount = Math.max( + 1, + Math.ceil(filteredSites.length / INLINE_SITES_PAGE_SIZE), + ); + const currentPage = Math.min(page, pageCount); + const startIndex = (currentPage - 1) * INLINE_SITES_PAGE_SIZE; + const visibleSites = filteredSites.slice( + startIndex, + startIndex + INLINE_SITES_PAGE_SIZE, + ); + + useEffect(() => { + if (page > pageCount) setPage(pageCount); + }, [page, pageCount]); + + /** Updates the search query and returns to the first page. */ + const handleQueryChange = (value: string) => { + setQuery(value); + setPage(1); + }; + + /** Shows the previous disabled-sites page. */ + const showPreviousPage = () => setPage((current) => Math.max(1, current - 1)); + + /** Shows the next disabled-sites page. */ + const showNextPage = () => + setPage((current) => Math.min(pageCount, current + 1)); + + if (sites.length === 0) { + return ( +

+ {t("noDisabledSites")} +

+ ); + } + + return ( +
+ {sites.length > INLINE_SITES_PAGE_SIZE && ( + + )} + + {visibleSites.length === 0 ? ( +

+ {t("noResultsFound")} +

+ ) : ( +
+ {visibleSites.map((site) => ( +
+ + {site} + + +
+ ))} +
+ )} + + {pageCount > 1 && ( +
+ + + {currentPage} / {pageCount} + + +
+ )} +
+ ); +} diff --git a/entrypoints/popup/components/Settings.tsx b/entrypoints/popup/components/Settings.tsx index 3e2b60a3..ef9555ad 100644 --- a/entrypoints/popup/components/Settings.tsx +++ b/entrypoints/popup/components/Settings.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback } from "react"; import Toggle from "./Toggle"; import Button from "./Button"; import Input from "./Input"; +import DisabledInlineSitesPanel from "./DisabledInlineSitesPanel"; import { Select, SelectContent, @@ -16,6 +17,7 @@ import { BouncyAccordion } from "src/components/motion/bouncy-accordion"; import { AnimatedBadge } from "src/components/motion/animated-badge"; import { getAccountStorageKey, validateEmail } from "../utils"; import { t } from "../../../lib/i18n"; +import { APP_VERSION } from "src/version"; import { INLINE_DISABLED_SITES_KEY, parseDisabledInlineSites, @@ -80,6 +82,59 @@ const DEFAULT_SETTINGS: AppSettings = { const TOAST_DURATION = 2000; const CHANGELOG: ChangelogEntry[] = [ + { + version: "1.3.2", + date: "2026-07-24", + changes: [ + { + type: "Added", + items: [ + "Added generated-manifest scope verification with regression coverage for Chrome and Firefox-style permissions", + ], + }, + { + type: "Changed", + items: [ + "Aligned WXT development and production URL-scope handling", + "Restricted development builds to the configured site allowlist while preserving all-site production support", + ], + }, + { + type: "Fixed", + items: [ + "Restored inline popup availability in production builds", + "Prevented unrelated content scripts and broad development URL patterns from passing manifest validation", + ], + }, + ], + }, + { + version: "1.3.1", + date: "2026-07-20", + changes: [ + { + type: "Added", + items: [ + "Added Edge and Opera release packages with multi-browser installation guidance", + "Added an interactive product tour and richer landing-page previews", + ], + }, + { + type: "Changed", + items: [ + "Improved active email handling across the popup, inline helper, and context menus", + "Optimized inline popup behavior and displayed the active base email", + ], + }, + { + type: "Fixed", + items: [ + "Improved user feedback when disabling the inline helper or saving aliases", + "Fixed context-menu caching, history loading, injected icon cleanup, and development-site configuration", + ], + }, + ], + }, { version: "1.3.0", date: "2026-07-13", @@ -670,7 +725,7 @@ export default function Settings({ const [editingAccountId, setEditingAccountId] = useState(null); const [editingLabel, setEditingLabel] = useState(""); const [editingEmail, setEditingEmail] = useState(""); - const [version, setVersion] = useState("1.3.0"); + const [version, setVersion] = useState(APP_VERSION); const [showAddAccount, setShowAddAccount] = useState(false); const [newAccountEmail, setNewAccountEmail] = useState(""); const [newAccountLabel, setNewAccountLabel] = useState(""); @@ -1293,33 +1348,10 @@ export default function Settings({

{t("inlineDisabledSitesDescription")}

- {disabledInlineSites.length === 0 ? ( -

- {t("noDisabledSites")} -

- ) : ( -
- {disabledInlineSites.map((site) => ( -
- - {site} - - -
- ))} -
- )} + ), }, diff --git a/package.json b/package.json index 4ad38ed6..74a6f5f8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gmail-alias-toolkit", "description": "Generate and manage Gmail aliases with plus addressing and presets", "private": true, - "version": "1.3.1", + "version": "1.3.2", "author": "dev@eplus.dev", "license": "MIT", "homepage_url": "https://eplus.dev", diff --git a/src/utils/inlineSiteSettings.ts b/src/utils/inlineSiteSettings.ts index fd080721..baa1928f 100644 --- a/src/utils/inlineSiteSettings.ts +++ b/src/utils/inlineSiteSettings.ts @@ -22,3 +22,14 @@ export function parseDisabledInlineSites(value: unknown): string[] { ), ).sort((a, b) => a.localeCompare(b)); } + +/** Filters disabled-site hostnames using the same normalization as stored values. */ +export function filterDisabledInlineSites( + sites: string[], + query: string, +): string[] { + const normalizedQuery = normalizeSiteHostname(query); + if (!normalizedQuery) return sites; + + return sites.filter((site) => site.includes(normalizedQuery)); +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 00000000..346f93df --- /dev/null +++ b/src/version.ts @@ -0,0 +1,6 @@ +import packageMetadata from "../package.json"; + +const injectedVersion = import.meta.env.VITE_APP_VERSION?.trim(); + +/** Application version from the web build or root package metadata. */ +export const APP_VERSION = injectedVersion || packageMetadata.version; diff --git a/tests/utils/inlineSiteSettings.test.ts b/tests/utils/inlineSiteSettings.test.ts index 13253124..4fbca33b 100644 --- a/tests/utils/inlineSiteSettings.test.ts +++ b/tests/utils/inlineSiteSettings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + filterDisabledInlineSites, normalizeSiteHostname, parseDisabledInlineSites, } from "../../src/utils/inlineSiteSettings"; @@ -24,4 +25,19 @@ describe("inline site settings", () => { it("handles invalid legacy storage values", () => { expect(parseDisabledInlineSites({ site: "voidzero.dev" })).toEqual([]); }); + + it("filters disabled sites with normalized partial hostnames", () => { + const sites = ["accounts.google.com", "github.com", "mail.google.com"]; + + expect(filterDisabledInlineSites(sites, " WWW.GOOGLE. ")).toEqual([ + "accounts.google.com", + "mail.google.com", + ]); + }); + + it("returns all disabled sites for an empty search", () => { + const sites = ["github.com", "typeform.com"]; + + expect(filterDisabledInlineSites(sites, " ")).toBe(sites); + }); }); diff --git a/tests/version.test.ts b/tests/version.test.ts new file mode 100644 index 00000000..a425de09 --- /dev/null +++ b/tests/version.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { APP_VERSION } from "../src/version"; + +const readSource = (relativePath: string) => + readFileSync(resolve(process.cwd(), relativePath), "utf8"); + +describe("application version", () => { + it("uses the root package version as the single source of truth", () => { + const packageMetadata = JSON.parse(readSource("package.json")) as { + version: string; + }; + + expect(APP_VERSION).toBe(packageMetadata.version); + }); + + it("does not hardcode the current version in the landing page", () => { + const source = readSource("web/src/App.tsx"); + + expect(source).toContain("APP_VERSION"); + expect(source).not.toMatch(/\bv\d+\.\d+\.\d+\b/); + }); + + it("does not use a hardcoded settings fallback version", () => { + const source = readSource("entrypoints/popup/components/Settings.tsx"); + + expect(source).toContain("APP_VERSION"); + expect(source).not.toMatch(/useState\("\d+\.\d+\.\d+"\)/); + }); +}); diff --git a/tests/webReleaseVersion.test.ts b/tests/webReleaseVersion.test.ts new file mode 100644 index 00000000..a68f3bfa --- /dev/null +++ b/tests/webReleaseVersion.test.ts @@ -0,0 +1,66 @@ +import packageMetadata from "../package.json"; +import { describe, expect, it, vi } from "vitest"; +import { + LATEST_RELEASE_API_URL, + normalizeReleaseVersion, + resolveLatestReleaseVersion, +} from "../web/release-version"; + +/** Creates a minimal fetch implementation for release-version tests. */ +function createFetchResponse(payload: unknown, ok = true): typeof fetch { + return vi.fn(async () => + ({ + ok, + json: async () => payload, + }) as Response, + ) as unknown as typeof fetch; +} + +describe("GitHub release version", () => { + it("normalizes a release tag with a leading v", () => { + expect(normalizeReleaseVersion("v1.3.2")).toBe("1.3.2"); + }); + + it("accepts semantic versions with prerelease metadata", () => { + expect(normalizeReleaseVersion("1.4.0-beta.1")).toBe("1.4.0-beta.1"); + }); + + it("rejects invalid release tags", () => { + expect(normalizeReleaseVersion("latest")).toBeNull(); + expect(normalizeReleaseVersion(null)).toBeNull(); + }); + + it("uses the latest published GitHub release tag", async () => { + const fetcher = createFetchResponse({ tag_name: "v1.4.0" }); + + await expect(resolveLatestReleaseVersion(fetcher)).resolves.toBe("1.4.0"); + expect(fetcher).toHaveBeenCalledWith( + LATEST_RELEASE_API_URL, + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: "application/vnd.github+json", + }), + }), + ); + }); + + it("falls back to package metadata when GitHub has no usable release", async () => { + const invalidTagFetcher = createFetchResponse({ tag_name: "latest" }); + const failedResponseFetcher = createFetchResponse({}, false); + const rejectedFetcher = vi + .fn(async () => { + throw new Error("offline"); + }) + .mockName("rejectedFetch") as unknown as typeof fetch; + + await expect(resolveLatestReleaseVersion(invalidTagFetcher)).resolves.toBe( + packageMetadata.version, + ); + await expect(resolveLatestReleaseVersion(failedResponseFetcher)).resolves.toBe( + packageMetadata.version, + ); + await expect(resolveLatestReleaseVersion(rejectedFetcher)).resolves.toBe( + packageMetadata.version, + ); + }); +}); diff --git a/web/release-version.ts b/web/release-version.ts new file mode 100644 index 00000000..763e136f --- /dev/null +++ b/web/release-version.ts @@ -0,0 +1,37 @@ +import packageMetadata from "../package.json"; + +export const LATEST_RELEASE_API_URL = + "https://api.github.com/repos/ePlus-DEV/gmail-alias-toolkit/releases/latest"; + +const SEMVER_PATTERN = + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +/** Converts a GitHub release tag such as v1.3.2 into an application version. */ +export function normalizeReleaseVersion(tagName: unknown): string | null { + if (typeof tagName !== "string") return null; + + const version = tagName.trim().replace(/^v/i, ""); + return SEMVER_PATTERN.test(version) ? version : null; +} + +/** Resolves the latest published GitHub release, falling back to package metadata. */ +export async function resolveLatestReleaseVersion( + fetcher: typeof fetch = fetch, +): Promise { + try { + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + const token = process.env.GITHUB_TOKEN?.trim(); + if (token) headers.Authorization = `Bearer ${token}`; + + const response = await fetcher(LATEST_RELEASE_API_URL, { headers }); + if (!response.ok) return packageMetadata.version; + + const payload = (await response.json()) as { tag_name?: unknown }; + return normalizeReleaseVersion(payload.tag_name) ?? packageMetadata.version; + } catch { + return packageMetadata.version; + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index b4db82a5..9ebfb9ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { motion } from "framer-motion"; import extensionIconUrl from "../../assets/icon.png?url"; import { ThemeToggle } from "src/components/motion/theme-toggle"; +import { APP_VERSION } from "src/version"; import { PhaseTwoSections } from "./PhaseTwoSections"; import { ArrowRight, @@ -1236,7 +1237,7 @@ function MockSettingsPanelHeader({ - v1.3.0 + v{APP_VERSION} ); diff --git a/web/vite.config.ts b/web/vite.config.ts index 63832274..fd82a626 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,16 +1,24 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; import { fileURLToPath, URL } from "node:url"; +import { resolveLatestReleaseVersion } from "./release-version"; const basePath = process.env.VITE_BASE_PATH ?? "/gmail-alias-toolkit/"; -export default defineConfig({ - base: basePath, - plugins: [react()], - resolve: { - alias: { - src: fileURLToPath(new URL("../src", import.meta.url)), +export default defineConfig(async () => { + const releaseVersion = await resolveLatestReleaseVersion(); + + return { + base: basePath, + plugins: [react()], + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify(releaseVersion), + }, + resolve: { + alias: { + src: fileURLToPath(new URL("../src", import.meta.url)), + }, + dedupe: ["react", "react-dom"], }, - dedupe: ["react", "react-dom"], - }, + }; });