From ed67fc137831bbc964f7babff2b6c9cf8337346c Mon Sep 17 00:00:00 2001 From: yjouini Date: Thu, 18 Jun 2026 16:15:33 +0200 Subject: [PATCH] feat(extension): configure PII entity types and custom regex from options page Wire the Chrome extension options page to the new server-side PII config endpoints introduced in #509/#513: - Entity types: GET/POST /api/pii/entities ({available, disabled}), unchecking a type adds it to the disabled (passthrough) set. - Custom patterns: GET/POST /api/pii/regexes, whole-set replacement on each add/remove; custom names appear as selectable entity types. Config is stored server-side, so /api/pii/check needs no per-request label list and background.js is unchanged. Closes #458, Closes #459 Co-Authored-By: Claude Opus 4.8 --- .changeset/extension-pii-config-wiring.md | 5 + chrome-extension/options.css | 137 +++++++++++- chrome-extension/options.html | 67 ++++++ chrome-extension/options.js | 257 ++++++++++++++++++++++ docs/06-chrome-extension.md | 4 +- 5 files changed, 468 insertions(+), 2 deletions(-) create mode 100644 .changeset/extension-pii-config-wiring.md diff --git a/.changeset/extension-pii-config-wiring.md b/.changeset/extension-pii-config-wiring.md new file mode 100644 index 00000000..15b48816 --- /dev/null +++ b/.changeset/extension-pii-config-wiring.md @@ -0,0 +1,5 @@ +--- +"kiji-privacy-proxy": minor +--- + +Chrome extension: configure PII entity types and custom regex patterns from the options page, wired to the `/api/pii/entities` and `/api/pii/regexes` endpoints. diff --git a/chrome-extension/options.css b/chrome-extension/options.css index 881b9dcb..7952ffde 100644 --- a/chrome-extension/options.css +++ b/chrome-extension/options.css @@ -79,7 +79,9 @@ body { min-height: 100vh; padding: 48px 24px; display: flex; - justify-content: center; + flex-direction: column; + align-items: center; + gap: 24px; } .card { @@ -307,3 +309,136 @@ input[type="url"]:focus, .save-error { color: var(--err); } + +/* ---------- Label grid (PII types) ---------- */ + +.label-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 8px; + margin-bottom: 20px; +} + +.label-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-family: var(--mono); + color: var(--text); + cursor: pointer; + padding: 6px 8px; + border-radius: var(--radius-sm); + background: var(--bg-subtle); + user-select: none; +} + +.label-item input[type="checkbox"] { + accent-color: var(--brand); + width: 14px; + height: 14px; + flex-shrink: 0; + cursor: pointer; +} + +.label-loading { + font-size: 12px; + color: var(--text-muted); +} + +/* ---------- Custom patterns ---------- */ + +.pattern-form { + margin-bottom: 16px; +} + +.pattern-form-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.pattern-form-row .input-mono { + flex: 1; + min-width: 120px; +} + +.pattern-preview-row { + display: flex; + gap: 8px; + align-items: center; +} + +.pattern-preview-row .input-mono { + flex: 1; +} + +.pattern-preview-result { + font-size: 12px; + font-family: var(--mono); + color: var(--text-muted); + white-space: nowrap; +} + +.pattern-error { + color: var(--err); + min-height: 1.2em; +} + +.pattern-list { + display: flex; + flex-direction: column; + gap: 6px; + border-top: 0.5px solid var(--border); + padding-top: 16px; +} + +.pattern-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: var(--bg-subtle); + border-radius: var(--radius-sm); + font-size: 12px; + flex-wrap: wrap; +} + +.pattern-name { + font-family: var(--mono); + font-weight: 600; + color: var(--brand); + flex-shrink: 0; +} + +.pattern-regex-val { + font-family: var(--mono); + color: var(--code-text); + flex: 1; + word-break: break-all; +} + +.btn-danger { + padding: 5px 10px; + background: transparent; + color: var(--err); + border: 0.5px solid var(--err); + border-radius: var(--radius-sm); + font-size: 12px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s ease; +} + +.btn-danger:hover { + background: rgba(226, 75, 74, 0.1); +} + +.pattern-row .input-mono { + padding: 5px 8px; + font-size: 12px; + flex: 1; + min-width: 80px; +} diff --git a/chrome-extension/options.html b/chrome-extension/options.html index 48ddcda3..67472d3f 100644 --- a/chrome-extension/options.html +++ b/chrome-extension/options.html @@ -73,6 +73,73 @@ + +
+
+
+ PII entity types + Uncheck types you want to leave unmasked +
+ Disable all +
+
+
+ Loading entity types… +
+
+ + +
+
+
+ +
+
+
+ Custom patterns + Domain-specific regex rules applied on top of the model +
+
+
+
+
+ + + +
+
+ + +
+

+
+ +
+ Loading patterns… +
+
+
diff --git a/chrome-extension/options.js b/chrome-extension/options.js index 606cfb52..1c5a6194 100644 --- a/chrome-extension/options.js +++ b/chrome-extension/options.js @@ -122,4 +122,261 @@ document.addEventListener("DOMContentLoaded", () => { }, 2000); } } + + async function getBackendUrl() { + const { backendUrl } = await chrome.storage.sync.get({ + backendUrl: DEFAULT_API_BASE, + }); + return (backendUrl || DEFAULT_API_BASE).replace(/\/+$/, ""); + } + + // ── PII entity types ────────────────────────────────────────────────────── + // GET /api/pii/entities → { available, disabled }. Checked = masked; the + // unchecked types are POSTed back as the `disabled` (passthrough) set. The + // backend stores this server-side, so /api/pii/check needs no per-request + // label list. + + const labelGrid = document.getElementById("label-grid"); + const saveLabelBtn = document.getElementById("save-labels-btn"); + const labelsStatus = document.getElementById("labels-status"); + const toggleAllLink = document.getElementById("toggle-all"); + + async function loadLabels() { + const base = await getBackendUrl(); + let available = []; + let disabled = []; + try { + const resp = await fetch(`${base}/api/pii/entities`); + if (resp.ok) { + const data = await resp.json(); + available = data.available || []; + disabled = data.disabled || []; + } + } catch { + // backend not reachable — grid stays empty + } + + if (available.length === 0) { + labelGrid.innerHTML = + 'Backend unreachable — start the proxy first.'; + return; + } + + const disabledSet = new Set(disabled); + + labelGrid.innerHTML = ""; + for (const label of available) { + const item = document.createElement("label"); + item.className = "label-item"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.value = label; + cb.checked = !disabledSet.has(label); + item.appendChild(cb); + item.appendChild(document.createTextNode(label)); + labelGrid.appendChild(item); + } + + updateToggleAllText(); + } + + function updateToggleAllText() { + const checkboxes = labelGrid.querySelectorAll("input[type=checkbox]"); + const anyChecked = Array.from(checkboxes).some((cb) => cb.checked); + toggleAllLink.textContent = anyChecked ? "Disable all" : "Enable all"; + } + + toggleAllLink.addEventListener("click", (e) => { + e.preventDefault(); + const checkboxes = labelGrid.querySelectorAll("input[type=checkbox]"); + const anyChecked = Array.from(checkboxes).some((cb) => cb.checked); + checkboxes.forEach((cb) => { + cb.checked = !anyChecked; + }); + updateToggleAllText(); + }); + + labelGrid.addEventListener("change", updateToggleAllText); + + saveLabelBtn.addEventListener("click", async () => { + const checkboxes = labelGrid.querySelectorAll("input[type=checkbox]"); + const disabled = Array.from(checkboxes) + .filter((cb) => !cb.checked) + .map((cb) => cb.value); + const base = await getBackendUrl(); + try { + const resp = await fetch(`${base}/api/pii/entities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ disabled }), + }); + if (!resp.ok) throw new Error((await resp.text()).slice(0, 200)); + showLabelStatus("Saved.", false); + } catch (e) { + showLabelStatus(`Failed to save: ${e.message}`, true); + } + }); + + function showLabelStatus(text, isError) { + labelsStatus.textContent = text; + labelsStatus.className = isError + ? "save-status save-error" + : "save-status save-success"; + if (!isError) { + setTimeout(() => { + labelsStatus.textContent = ""; + labelsStatus.className = "save-status"; + }, 2000); + } + } + + loadLabels(); + + // ── Custom patterns ─────────────────────────────────────────────────────── + // GET /api/pii/regexes → { regexes: [{ name, pattern }] }. POST replaces the + // whole set, so Add/Remove rebuild the local list and re-POST it. Custom + // names become maskable entity types, so the label grid is refreshed on + // every change. + + const patternForm = document.getElementById("pattern-form"); + const patternName = document.getElementById("pattern-name"); + const patternRegex = document.getElementById("pattern-regex"); + const patternSample = document.getElementById("pattern-sample"); + const patternPreviewResult = document.getElementById( + "pattern-preview-result" + ); + const patternRegexError = document.getElementById("pattern-regex-error"); + const patternList = document.getElementById("pattern-list"); + + let regexes = []; + + function validateRegex(value) { + if (!value) return null; + try { + new RegExp(value); + return null; + } catch (e) { + return e.message; + } + } + + function updatePreview() { + const regexVal = patternRegex.value.trim(); + const sample = patternSample.value; + const error = validateRegex(regexVal); + + patternRegexError.textContent = error || ""; + + if (!error && regexVal && sample) { + const re = new RegExp(regexVal, "g"); + const matches = sample.match(re) || []; + patternPreviewResult.textContent = + matches.length > 0 + ? `✓ ${matches.length} match${matches.length > 1 ? "es" : ""}: ${matches.join(", ")}` + : "No matches"; + patternPreviewResult.style.color = + matches.length > 0 ? "var(--ok)" : "var(--text-muted)"; + } else { + patternPreviewResult.textContent = ""; + } + } + + patternRegex.addEventListener("input", updatePreview); + patternSample.addEventListener("input", updatePreview); + + async function loadPatterns() { + const base = await getBackendUrl(); + try { + const resp = await fetch(`${base}/api/pii/regexes`); + if (!resp.ok) throw new Error(); + const data = await resp.json(); + regexes = data.regexes || []; + } catch { + regexes = []; + } + renderPatterns(); + } + + // Replace the whole pattern set on the backend, then refresh the label grid + // so new/removed custom names appear in the entity-type toggles. + async function savePatterns() { + const base = await getBackendUrl(); + const resp = await fetch(`${base}/api/pii/regexes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ regexes }), + }); + if (!resp.ok) throw new Error((await resp.text()).slice(0, 200)); + const data = await resp.json(); + regexes = data.regexes || regexes; + renderPatterns(); + loadLabels(); + } + + function renderPatterns() { + if (regexes.length === 0) { + patternList.innerHTML = + '

No custom patterns yet.

'; + return; + } + + patternList.innerHTML = ""; + regexes.forEach((p, idx) => { + const row = document.createElement("div"); + row.className = "pattern-row"; + row.innerHTML = ` + ${escHtml(p.name)} + ${escHtml(p.pattern)} + + `; + patternList.appendChild(row); + }); + + patternList.querySelectorAll("[data-action=delete]").forEach((btn) => { + btn.addEventListener("click", async () => { + const idx = Number(btn.dataset.idx); + const removed = regexes[idx]; + regexes = regexes.filter((_, i) => i !== idx); + try { + await savePatterns(); + } catch (e) { + regexes.splice(idx, 0, removed); + renderPatterns(); + alert(`Failed to delete pattern: ${e.message}`); + } + }); + }); + } + + patternForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const name = patternName.value.trim().toUpperCase(); + const pattern = patternRegex.value.trim(); + + const error = validateRegex(pattern); + if (error) { + patternRegexError.textContent = error; + return; + } + + regexes.push({ name, pattern }); + try { + await savePatterns(); + patternForm.reset(); + patternPreviewResult.textContent = ""; + } catch (err) { + regexes.pop(); + patternRegexError.textContent = `Failed to save: ${err.message}`; + } + }); + + function escHtml(str) { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + loadPatterns(); }); diff --git a/docs/06-chrome-extension.md b/docs/06-chrome-extension.md index ae0eeb19..996d9abb 100644 --- a/docs/06-chrome-extension.md +++ b/docs/06-chrome-extension.md @@ -56,7 +56,7 @@ chrome-extension/ ├── content.js # Content script: intercepts input, calls API, shows PII modal ├── styles.css # Modal and toast styles (injected into target pages) ├── popup.html/js/css # Extension popup: connection status, stats -├── options.html/js/css# Settings page: backend URL, intercept domains +├── options.html/js/css# Settings page: backend URL, intercept domains, PII entity types, custom patterns └── icons/ # Extension icons (16, 48, 128) ``` @@ -73,6 +73,8 @@ All settings are accessible via the extension's options page (right-click extens - **Backend URL** — The Kiji Privacy Proxy server address (default: `http://localhost:8081`) - **Intercept domains** — URL match patterns where the extension is active (one per line) +- **PII entity types** — Checkbox grid of the entity types the active model can detect (fetched from `GET /api/pii/entities`). Unchecking a type adds it to the backend's `disabled` (passthrough) set so it is left unmasked; the selection is saved with `POST /api/pii/entities`. The set is stored server-side, so it also applies to the proxy pipeline and the Linux server. +- **Custom patterns** — Add named regular expressions (`name` + `pattern`) that are masked on top of the model's detections. A live preview tests the regex against sample text before saving. Patterns are read from `GET /api/pii/regexes` and the whole set is replaced with `POST /api/pii/regexes` on each add/remove. Each custom name also becomes a selectable entity type in the grid above. Default domains: ```