diff --git a/PRIVACY.md b/PRIVACY.md index 2bcccee..84cd214 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy policy — Clay Slip -_Last updated: 2026-05-13_ +_Last updated: 2026-05-13 (per-site permissions model)_ Clay Slip is a developer tool. It runs entirely on your device, in your browser. **It does not collect, transmit, sell, or share any personal data.** @@ -33,7 +33,7 @@ You can clear everything from the extension's **Options** page (Reset preference ## What the extension reads from the page -To do its job, the content script reads: +The content script only runs on sites you have explicitly granted access to via Chrome's per-site permission prompt (see "Permissions" below). On those granted sites, it reads: - The `data-uri` and `data-editable` attributes that Clay sites set on rendered components. - Standard `` metadata (``, `<meta>` tags, `<link rel="canonical">`, JSON-LD) for the SEO tab. @@ -59,14 +59,25 @@ All of these requests target the Clay site you are already browsing (or another ## Permissions and why each is requested +### Required at install (minimum) + | Permission | Why it's requested | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `activeTab` | Used by the Screenshot feature: when you click _Screenshot_ on a selected component, the service worker calls `chrome.tabs.captureVisibleTab` and crops the result to the component's bounding box. The PNG is written to your clipboard and discarded — never uploaded. | | `storage` | Persists the user-controlled state described in the table above. Local-only. | | `clipboardWrite` | Implements the panel's _Copy URI_, _Copy as cURL/fetch()/CSS_, _Share_, _Export_, and _Screenshot_ actions. Each clipboard write is initiated by an explicit user click. | -| `<all_urls>` | The content script must run on every page so it can detect Clay-rendered pages by reading the `data-uri` attribute on `<html>`. On non-Clay pages the extension exits immediately without reading or modifying anything else. | -The extension does **not** request `cookies`, `webRequest`, `webNavigation`, `history`, `bookmarks`, `identity`, `notifications`, `geolocation`, or any other sensitive permission. +The extension declares **zero required `host_permissions`**. On a fresh install it can read or modify nothing on any website until you opt in. + +### Granted by you, per-site, at runtime + +| Permission | Why it's requested | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `optional_host_permissions: ["<all_urls>"]` | Declares the _upper bound_ of hosts the extension is later allowed to ask for. The extension does **not** auto-grab any host. From **Options → Allowed sites** (or the toolbar popup's _Allow on this site_ button), you can grant access to specific hostnames; Chrome shows its native consent prompt for each one. Revocation is one click away from the same UI, or from `chrome://extensions → Site access`. | + +Granted hosts are visible at any time under `chrome://extensions → Clay Slip → Site access`. The content script only ever runs on sites you have explicitly enabled. + +The extension does **not** request `cookies`, `webRequest`, `webNavigation`, `history`, `bookmarks`, `identity`, `notifications`, `geolocation`, `tabs`, `scripting`, or any other sensitive permission. --- diff --git a/README.md b/README.md index 503a900..7259ad8 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Clay annotates rendered HTML with `data-uri` attributes on every component, page ## Highlights - **Manifest V3** Chrome extension built with TypeScript, React, Vite, and `@crxjs/vite-plugin` +- **Zero-broad-permissions install** — ships with no host access; users grant specific Clay deployments per-site through a native Chrome consent prompt (Options → _Allowed sites_, or one click from the toolbar popup) - **Shadow-DOM panel** that never collides with host page styles - **Component tree + find-on-page** — live filter dims non-matches on the page, <kbd>Enter</kbd> cycles through them, <kbd>Esc</kbd> clears - **Inline JSON preview** so you don't need to open a new tab to read component data @@ -46,6 +47,9 @@ Then in Chrome: 1. Visit `chrome://extensions` 2. Enable **Developer mode** (top right) 3. Click **Load unpacked** and select the `dist/` directory +4. Open the extension's **Options** page → **Allowed sites** → add the hostnames of the Clay deployments you want to inspect (e.g. `www.thecut.com`). Chrome will show a native consent prompt for each one. + + Clay Slip ships with **no host access by default** — the toolbar icon's popup also has a one-click "Allow on this site" button if you'd rather grant from a Clay tab. For live development with HMR: @@ -90,6 +94,17 @@ Reload the extension in `chrome://extensions` after switching between `dev` and ## Configuration +### Allowed sites (host permissions) + +Clay Slip declares **no required host permissions** in its manifest. On a fresh install it has access to nothing. To enable inspection on a site, add it from one of two places: + +- **Options page → Allowed sites**: type the bare hostname (e.g. `www.thecut.com`) and click _Grant access_. Chrome will pop a native consent dialog. Once granted, the content script auto-injects on every page of that origin. +- **Toolbar popup**: click the Clay Slip icon on any page and click _Allow on this site_. This is the fastest way to onboard a Clay tab you're already on. + +Once you start filling out **Site host mappings** (next section) the Options page will show a **Pending** strip listing any mapping hostnames that are not yet granted, with a one-click _Grant all_ button. + +Revoke at any time from the same UI, or from Chrome's _Manage extensions → Site access_ panel. + ### Site host mappings The **Site host mappings** section in the options page is a per-instance lookup table mapping each brand to its hostnames per environment. With it configured, the panel renders a **View on:** pill row on every Clay page so you can jump to the equivalent URL on a different env, and the **Share** button gains a **▾** picker for cross-env share links. Empty by default — every fork populates its own. @@ -123,11 +138,12 @@ src/ │ ├── styles.css # Shadow-scoped styles │ ├── components/ # Tabs, tree, JSON viewer, diff, breadcrumb… │ └── hooks/ # Drag, theme, shortcuts, selection -├── popup/ # "Not a Clay page" popup (active until a page sends CLAY_DETECTED) -├── options/ # Full options page (env hosts, dock + width, intensity, recents, shortcuts) +├── popup/ # Toolbar popup (grant access on this site / not-a-Clay-page state) +├── options/ # Full options page (allowed sites, env hosts, dock + width, intensity, recents, shortcuts) └── lib/ # Pure utilities ├── clay-uri.ts # URI parsing + buildUrl/buildEditorUrl/buildShareLink + copy-as helpers ├── clipboard.ts # Modern + legacy clipboard + ├── permissions.ts # chrome.permissions wrappers (request / list / remove granted hosts) ├── storage.ts # User preferences in chrome.storage.sync ├── annotations.ts # Sticky notes per component URI ├── recents.ts # Recently viewed components history diff --git a/package.json b/package.json index c7424ff..639b89a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clay-slip", - "version": "2.0.2", + "version": "2.1.0", "description": "Modern devtools for Clay CMS pages: visualize component boundaries, inspect data, and navigate the page/layout hierarchy.", "private": true, "type": "module", diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts new file mode 100644 index 0000000..37833b2 --- /dev/null +++ b/src/lib/permissions.ts @@ -0,0 +1,109 @@ +/** + * User-controlled host-permission management. + * + * Clay Slip ships with **no** required host permissions. The user adds + * specific Clay hostnames from the Options page; each addition triggers a + * native Chrome consent prompt. Only the hosts they explicitly grant get + * the content script registered against them. + * + * The chrome.permissions API works in `origin` patterns + * (e.g. `https://www.example.com/*`); this module hides that detail and + * exposes a clean bare-hostname API everywhere else in the codebase. + */ + +const HOST_PATTERN = /^[a-z0-9.-]+(?::\d+)?$/i; + +/** Bare hostname (e.g. `www.thecut.com` or `localhost:3001`). */ +export type Host = string; + +/** + * Convert a list of bare hosts into the `chrome.permissions.origins` shape. + * Each host expands to **both** http and https patterns so a localhost dev + * box and an https prod site work without bespoke handling. + */ +export function originsFor(hosts: readonly Host[]): string[] { + const out: string[] = []; + for (const host of hosts) { + if (!HOST_PATTERN.test(host)) continue; + out.push(`https://${host}/*`, `http://${host}/*`); + } + return out; +} + +/** + * Inverse of {@link originsFor}. Pulls the bare hostname out of an + * `https?://host/*` pattern. Returns `null` for patterns we don't recognize + * (e.g. `<all_urls>`). + */ +export function hostFromOrigin(origin: string): Host | null { + const m = origin.match(/^https?:\/\/([^/]+)\/\*?$/); + return m && m[1] ? m[1] : null; +} + +/** + * The de-duplicated set of bare hosts the user has granted access to. + * Returns `[]` if `chrome.permissions` is unavailable (test envs). + */ +export async function listGrantedHosts(): Promise<readonly Host[]> { + if (!chrome?.permissions?.getAll) return []; + const perms = await chrome.permissions.getAll(); + const hosts = new Set<Host>(); + for (const origin of perms.origins ?? []) { + const host = hostFromOrigin(origin); + if (host) hosts.add(host); + } + return [...hosts].sort(); +} + +/** + * Prompt the user to grant access to `host`. Returns `true` only if Chrome + * actually granted it (i.e. the user clicked Allow). Must be called from a + * user-gesture context — typically a button click handler in the Options + * page or popup. + */ +export async function requestHostPermission(host: Host): Promise<boolean> { + if (!chrome?.permissions?.request) return false; + const origins = originsFor([host]); + if (origins.length === 0) return false; + return chrome.permissions.request({ origins }); +} + +/** Revoke previously granted access to `host`. */ +export async function removeHostPermission(host: Host): Promise<boolean> { + if (!chrome?.permissions?.remove) return false; + const origins = originsFor([host]); + if (origins.length === 0) return false; + return chrome.permissions.remove({ origins }); +} + +/** Check whether `host` is currently granted (cheap; no UI). */ +export async function hasHostPermission(host: Host): Promise<boolean> { + if (!chrome?.permissions?.contains) return false; + const origins = originsFor([host]); + if (origins.length === 0) return false; + return chrome.permissions.contains({ origins }); +} + +/** + * Pull the bare hostname out of a full URL string. Convenience for + * popup/service-worker code that has a `tab.url` to work with. + */ +export function hostFromUrl(url: string | undefined): Host | null { + if (!url) return null; + try { + const u = new URL(url); + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null; + return u.host; + } catch { + return null; + } +} + +/** + * Validate that a string looks like a bare hostname suitable for + * {@link requestHostPermission}. Use in form validation before showing the + * grant button. + */ +export function isValidHost(host: string): boolean { + return HOST_PATTERN.test(host.trim()); +} diff --git a/src/manifest.ts b/src/manifest.ts index 6122be6..3d8fb8d 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -47,6 +47,13 @@ export default defineManifest({ type: 'module', }, + // The static content_scripts entry below declares the *maximum* scope + // (`<all_urls>`), but Chrome only auto-injects on origins where the user + // has granted host access. Because we declare zero required + // `host_permissions` and the broad pattern lives in + // `optional_host_permissions`, on a fresh install the script runs + // nowhere. Once the user grants `https://example.com/*` from the Options + // page, Chrome auto-injects on that host from then on. content_scripts: [ { matches: ['<all_urls>'], @@ -57,9 +64,16 @@ export default defineManifest({ // Permissions are deliberately minimal — see PRIVACY.md for the // per-permission justification used in the Chrome Web Store listing: - // activeTab → captureVisibleTab for the Screenshot feature - // storage → user prefs (sync) + annotations/recents (local) + // activeTab → captureVisibleTab for the Screenshot feature + // storage → user prefs (sync) + annotations/recents (local) // clipboardWrite → all "Copy to clipboard" panel actions permissions: ['activeTab', 'storage', 'clipboardWrite'], - host_permissions: ['<all_urls>'], + + // Required host access at install time: NONE. + // The user grants specific origins from the Options page; Chrome shows + // a native consent prompt on each addition. This keeps Clay Slip out of + // the "Broad Host Permissions" review queue while still letting the + // tool work on any Clay deployment the user chooses to point it at. + host_permissions: [], + optional_host_permissions: ['<all_urls>'], }); diff --git a/src/options/Options.tsx b/src/options/Options.tsx index b636d9e..9135eb6 100644 --- a/src/options/Options.tsx +++ b/src/options/Options.tsx @@ -1,4 +1,12 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import clayIconUrl from '@/assets/clay-icon.png?inline'; +import { + isValidHost, + listGrantedHosts, + removeHostPermission, + requestHostPermission, + type Host, +} from '@/lib/permissions'; import { loadPreferences, savePreferences } from '@/lib/storage'; import { clearRecents } from '@/lib/recents'; import { emptyMapping } from '@/lib/site-host'; @@ -28,12 +36,31 @@ const PANEL_POSITIONS: Array<{ value: PanelPosition; label: string }> = [ export function Options() { const [prefs, setPrefs] = useState<UserPreferences>(DEFAULT_PREFERENCES); const [saved, setSaved] = useState(false); + const [grantedHosts, setGrantedHosts] = useState<readonly Host[]>([]); + const [newHost, setNewHost] = useState(''); + const [grantBusy, setGrantBusy] = useState(false); const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null); + // Async version for use in event handlers (where the lint rule about + // calling setState inside useEffect doesn't apply). The useEffect below + // uses the bare .then() form to mirror the loadPreferences pattern and + // keep react-hooks/set-state-in-effect happy. + const refreshGrantedHosts = useCallback(async () => { + setGrantedHosts(await listGrantedHosts()); + }, []); + useEffect(() => { loadPreferences().then(setPrefs); + listGrantedHosts().then(setGrantedHosts); + // Keep the list live if the user grants/revokes from the popup or + // accepts a Chrome consent prompt while the page is open. + const onChange = () => listGrantedHosts().then(setGrantedHosts); + chrome.permissions?.onAdded.addListener(onChange); + chrome.permissions?.onRemoved.addListener(onChange); return () => { if (savedTimer.current) clearTimeout(savedTimer.current); + chrome.permissions?.onAdded.removeListener(onChange); + chrome.permissions?.onRemoved.removeListener(onChange); }; }, []); @@ -80,14 +107,158 @@ export function Options() { }) ); + // Hostnames the user has put into a site mapping but hasn't yet granted + // permission for. Surfaced as a "Grant access" suggestion strip so the + // two halves of the configuration stay in sync. + const pendingMappingHosts = useMemo(() => { + const granted = new Set(grantedHosts); + const out = new Set<Host>(); + for (const m of prefs.siteHosts) { + for (const env of SITE_ENV_ORDER) { + const host = m.hosts[env]; + if (host && !granted.has(host)) out.add(host); + } + } + return [...out].sort(); + }, [grantedHosts, prefs.siteHosts]); + + const grantHost = async (host: Host) => { + if (!isValidHost(host)) return; + setGrantBusy(true); + try { + const ok = await requestHostPermission(host); + if (ok) await refreshGrantedHosts(); + setNewHost(''); + } finally { + setGrantBusy(false); + } + }; + + const revokeHost = async (host: Host) => { + setGrantBusy(true); + try { + const ok = await removeHostPermission(host); + if (ok) await refreshGrantedHosts(); + } finally { + setGrantBusy(false); + } + }; + + const grantAllPending = async () => { + setGrantBusy(true); + try { + // Request one host at a time so the user sees a per-host prompt and + // can decline individually. Stop on the first denial. + for (const host of pendingMappingHosts) { + const granted = await requestHostPermission(host); + if (!granted) break; + } + await refreshGrantedHosts(); + } finally { + setGrantBusy(false); + } + }; + return ( <div className="options"> <header className="options-header"> - <div className="options-logo">S</div> + <img className="options-logo" src={clayIconUrl} alt="" aria-hidden="true" /> <h1>Clay Slip Settings</h1> {saved && <span className="options-saved">Saved</span>} </header> + <section className="options-section"> + <h2>Allowed sites</h2> + <p className="options-section-help"> + Clay Slip ships with <strong>no</strong> site access by default. Add the hostnames of your + Clay deployments here — Chrome will show a native permission prompt for each one. The + extension only runs on sites you’ve explicitly granted. + </p> + + {grantedHosts.length === 0 && ( + <p className="options-empty"> + No sites granted yet. Add one below or click the toolbar icon on a Clay page and grant + access from there. + </p> + )} + + {grantedHosts.length > 0 && ( + <ul className="options-host-list"> + {grantedHosts.map((host) => ( + <li key={host} className="options-host-row"> + <code className="options-host-name">{host}</code> + <button + type="button" + className="options-secondary" + onClick={() => void revokeHost(host)} + disabled={grantBusy} + title={`Revoke Clay Slip's access to ${host}`} + > + Revoke + </button> + </li> + ))} + </ul> + )} + + <div className="options-row"> + <div className="options-label"> + <span>Add a site</span> + <span className="options-help"> + Bare hostname like <code>www.thecut.com</code> (no <code>https://</code>, no path). + </span> + </div> + <form + className="options-add-host" + onSubmit={(e) => { + e.preventDefault(); + void grantHost(newHost.trim()); + }} + > + <input + type="text" + placeholder="www.example.com" + value={newHost} + onChange={(e) => setNewHost(e.target.value)} + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" + /> + <button + type="submit" + className="options-secondary" + disabled={grantBusy || !isValidHost(newHost.trim())} + > + {grantBusy ? 'Working…' : 'Grant access'} + </button> + </form> + </div> + + {pendingMappingHosts.length > 0 && ( + <div className="options-pending"> + <p className="options-pending-text"> + <strong>{pendingMappingHosts.length}</strong> host + {pendingMappingHosts.length === 1 ? '' : 's'} from your site mappings below + {pendingMappingHosts.length === 1 ? ' is' : ' are'} not granted yet:{' '} + {pendingMappingHosts.map((h, i) => ( + <span key={h}> + {i > 0 && ', '} + <code>{h}</code> + </span> + ))} + </p> + <button + type="button" + className="options-secondary" + onClick={() => void grantAllPending()} + disabled={grantBusy} + > + Grant all + </button> + </div> + )} + </section> + <section className="options-section"> <h2>Appearance</h2> diff --git a/src/options/options.css b/src/options/options.css index 8db476f..cc543fa 100644 --- a/src/options/options.css +++ b/src/options/options.css @@ -48,15 +48,11 @@ body { } .options-logo { - width: 28px; - height: 28px; + width: 32px; + height: 32px; border-radius: 6px; - background: var(--accent); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; + display: block; + object-fit: contain; } .options-saved { @@ -260,3 +256,85 @@ kbd { color: var(--accent); border-color: var(--border); } + +/* ============================================================ + Allowed-sites section + ============================================================ */ + +.options-host-list { + list-style: none; + margin: 12px 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.options-host-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; +} + +.options-host-name { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + word-break: break-all; +} + +.options-add-host { + display: flex; + gap: 6px; + flex: 1; + min-width: 0; +} + +.options-add-host input[type='text'] { + flex: 1; + min-width: 0; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; + font-size: 13px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.options-add-host input[type='text']:focus { + outline: none; + border-color: var(--accent); +} + +.options-pending { + margin-top: 12px; + padding: 10px 12px; + background: var(--bg); + border: 1px dashed var(--border); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.options-pending-text { + margin: 0; + font-size: 12px; + color: var(--text-muted); + flex: 1; + min-width: 200px; +} + +.options-pending-text code { + font-size: 11px; + background: rgba(127, 127, 127, 0.15); + padding: 1px 4px; + border-radius: 3px; +} diff --git a/src/popup/Popup.tsx b/src/popup/Popup.tsx index 2d154f1..bc2f6d0 100644 --- a/src/popup/Popup.tsx +++ b/src/popup/Popup.tsx @@ -1,20 +1,139 @@ +import { useEffect, useState } from 'react'; +import clayIconUrl from '@/assets/clay-icon.png?inline'; +import { + hasHostPermission, + hostFromUrl, + removeHostPermission, + requestHostPermission, +} from '@/lib/permissions'; + +type State = + | { kind: 'loading' } + | { kind: 'unsupported'; tabId?: number } // chrome://, about:, file:, etc. + | { kind: 'needsGrant'; tabId: number; host: string } + | { kind: 'grantedNonClay'; tabId: number; host: string }; + export function Popup() { + const [state, setState] = useState<State>({ kind: 'loading' }); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void resolveActiveTab().then(setState); + }, []); + + async function onGrant() { + if (state.kind !== 'needsGrant') return; + setBusy(true); + const granted = await requestHostPermission(state.host); + if (granted) { + // Reload the tab so the now-allowed content script auto-injects. + await chrome.tabs.reload(state.tabId).catch(() => undefined); + window.close(); + } else { + setBusy(false); + } + } + + async function onRevoke() { + if (state.kind !== 'grantedNonClay') return; + setBusy(true); + const ok = await removeHostPermission(state.host); + if (ok) setState({ ...state, kind: 'needsGrant' }); + setBusy(false); + } + return ( <div className="popup"> - <div className="popup-logo">S</div> - <h1 className="popup-title">No Clay components found</h1> - <p className="popup-body"> - This page does not appear to be powered by Clay. Open a Clay page and click the toolbar icon - to inspect components. - </p> + <img className="popup-logo" src={clayIconUrl} alt="" aria-hidden="true" /> + + {state.kind === 'loading' && <p className="popup-body">Loading…</p>} + + {state.kind === 'unsupported' && ( + <> + <h1 className="popup-title">Clay Slip can’t run here</h1> + <p className="popup-body"> + This page uses an internal browser scheme (<code className="popup-mono">chrome://</code> + , <code className="popup-mono">file://</code>, extension stores, etc.) that extensions + can’t access. + </p> + </> + )} + + {state.kind === 'needsGrant' && ( + <> + <h1 className="popup-title">Allow Clay Slip on this site?</h1> + <p className="popup-body"> + Clay Slip needs your permission to run on{' '} + <code className="popup-mono">{state.host}</code>. You’ll see a Chrome prompt — + click + <strong> Allow</strong> to enable inspection on every page of this site. + </p> + <button + type="button" + className="popup-button popup-button-primary" + onClick={onGrant} + disabled={busy} + > + {busy ? 'Granting…' : `Allow on ${state.host}`} + </button> + <p className="popup-fineprint"> + You can revoke access any time from{' '} + <a + className="popup-link" + href="#" + onClick={(e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage().catch(() => undefined); + }} + > + Settings + </a> + . + </p> + </> + )} + + {state.kind === 'grantedNonClay' && ( + <> + <h1 className="popup-title">No Clay components here</h1> + <p className="popup-body"> + This page on <code className="popup-mono">{state.host}</code> doesn’t appear to be + powered by Clay. Open a Clay page and the panel will appear automatically. + </p> + <button + type="button" + className="popup-button" + onClick={onRevoke} + disabled={busy} + title={`Revoke Clay Slip's access to ${state.host}`} + > + {busy ? 'Revoking…' : `Revoke access to ${state.host}`} + </button> + </> + )} + <a className="popup-link" - href="https://github.com/clay/clay" + href="https://github.com/clay/clay-devtools" target="_blank" rel="noreferrer noopener" > - Learn about Clay → + About Clay Slip → </a> </div> ); } + +async function resolveActiveTab(): Promise<State> { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + const host = hostFromUrl(tab?.url); + if (!tab?.id || !host) return { kind: 'unsupported', tabId: tab?.id }; + const granted = await hasHostPermission(host); + return granted + ? { kind: 'grantedNonClay', tabId: tab.id, host } + : { kind: 'needsGrant', tabId: tab.id, host }; + } catch { + return { kind: 'unsupported' }; + } +} diff --git a/src/popup/popup.css b/src/popup/popup.css index a02b956..f4fa20a 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -8,7 +8,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #ffffff; color: #0f172a; - width: 280px; + width: 320px; } @media (prefers-color-scheme: dark) { @@ -19,22 +19,16 @@ body { } .popup { - padding: 18px 20px; + padding: 18px 20px 16px; text-align: center; } .popup-logo { - width: 36px; - height: 36px; - margin: 0 auto 12px; - border-radius: 8px; - background: #e22c2c; - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: 18px; + width: 40px; + height: 40px; + margin: 0 auto 10px; + display: block; + object-fit: contain; } .popup-title { @@ -47,7 +41,58 @@ body { margin: 0 0 12px; font-size: 12px; line-height: 1.5; - opacity: 0.75; + opacity: 0.8; +} + +.popup-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + background: rgba(127, 127, 127, 0.15); + border-radius: 4px; + padding: 1px 5px; +} + +.popup-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + border-radius: 6px; + border: 1px solid currentColor; + background: transparent; + color: inherit; + cursor: pointer; + margin: 0 0 10px; + font-family: inherit; +} + +.popup-button:hover:not(:disabled) { + background: rgba(127, 127, 127, 0.1); +} + +.popup-button:disabled { + opacity: 0.5; + cursor: progress; +} + +.popup-button-primary { + background: #e22c2c; + border-color: #e22c2c; + color: #fff; +} + +.popup-button-primary:hover:not(:disabled) { + background: #c92626; + border-color: #c92626; +} + +.popup-fineprint { + margin: 0 0 8px; + font-size: 11px; + opacity: 0.65; } .popup-link { diff --git a/tests/lib/permissions.test.ts b/tests/lib/permissions.test.ts new file mode 100644 index 0000000..0af6c2f --- /dev/null +++ b/tests/lib/permissions.test.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + hasHostPermission, + hostFromOrigin, + hostFromUrl, + isValidHost, + listGrantedHosts, + originsFor, + removeHostPermission, + requestHostPermission, +} from '@/lib/permissions'; + +interface PermsMock { + origins: string[]; + getAll: ReturnType<typeof vi.fn>; + request: ReturnType<typeof vi.fn>; + remove: ReturnType<typeof vi.fn>; + contains: ReturnType<typeof vi.fn>; +} + +function setupChromeMock(initialOrigins: string[] = []): PermsMock { + const perms: PermsMock = { + origins: [...initialOrigins], + getAll: vi.fn(async () => ({ permissions: [], origins: [...perms.origins] })), + request: vi.fn(async ({ origins }: { origins: string[] }) => { + perms.origins.push(...origins); + return true; + }), + remove: vi.fn(async ({ origins }: { origins: string[] }) => { + perms.origins = perms.origins.filter((o) => !origins.includes(o)); + return true; + }), + contains: vi.fn(async ({ origins }: { origins: string[] }) => + origins.every((o) => perms.origins.includes(o)) + ), + }; + (globalThis as { chrome?: unknown }).chrome = { permissions: perms }; + return perms; +} + +beforeEach(() => { + setupChromeMock(); +}); + +describe('originsFor', () => { + it('expands a bare host into both http and https patterns', () => { + expect(originsFor(['www.thecut.com'])).toEqual([ + 'https://www.thecut.com/*', + 'http://www.thecut.com/*', + ]); + }); + + it('preserves an explicit port (localhost dev box)', () => { + expect(originsFor(['localhost:3001'])).toEqual([ + 'https://localhost:3001/*', + 'http://localhost:3001/*', + ]); + }); + + it('drops invalid host patterns silently', () => { + expect(originsFor(['not a host', 'www.ok.com'])).toEqual([ + 'https://www.ok.com/*', + 'http://www.ok.com/*', + ]); + }); +}); + +describe('hostFromOrigin', () => { + it('extracts the bare host from a wildcard pattern', () => { + expect(hostFromOrigin('https://www.thecut.com/*')).toBe('www.thecut.com'); + expect(hostFromOrigin('http://localhost:3001/*')).toBe('localhost:3001'); + }); + + it('returns null for opaque or unrecognized patterns', () => { + expect(hostFromOrigin('<all_urls>')).toBeNull(); + expect(hostFromOrigin('chrome://settings')).toBeNull(); + }); +}); + +describe('hostFromUrl', () => { + it('returns the host of an http(s) URL', () => { + expect(hostFromUrl('https://www.thecut.com/article/foo')).toBe('www.thecut.com'); + expect(hostFromUrl('http://localhost:3001/page')).toBe('localhost:3001'); + }); + + it('returns null for non-http(s) schemes', () => { + expect(hostFromUrl('chrome://newtab')).toBeNull(); + expect(hostFromUrl('about:blank')).toBeNull(); + expect(hostFromUrl(undefined)).toBeNull(); + expect(hostFromUrl('not a url')).toBeNull(); + }); +}); + +describe('isValidHost', () => { + it('accepts conventional hostnames', () => { + expect(isValidHost('www.thecut.com')).toBe(true); + expect(isValidHost('localhost')).toBe(true); + expect(isValidHost('localhost:3001')).toBe(true); + }); + + it('rejects strings that are clearly not hostnames', () => { + expect(isValidHost('https://www.thecut.com')).toBe(false); + expect(isValidHost('www.thecut.com/path')).toBe(false); + expect(isValidHost('www thecut com')).toBe(false); + expect(isValidHost('')).toBe(false); + }); +}); + +describe('listGrantedHosts', () => { + it('returns the de-duplicated, sorted set of granted hosts', async () => { + setupChromeMock([ + 'https://www.thecut.com/*', + 'http://www.thecut.com/*', + 'https://www.vulture.com/*', + ]); + expect(await listGrantedHosts()).toEqual(['www.thecut.com', 'www.vulture.com']); + }); + + it('returns [] when chrome.permissions is unavailable', async () => { + (globalThis as { chrome?: unknown }).chrome = undefined; + expect(await listGrantedHosts()).toEqual([]); + }); +}); + +describe('requestHostPermission', () => { + it('asks chrome.permissions.request and returns the user decision', async () => { + const perms = setupChromeMock(); + perms.request.mockResolvedValueOnce(true); + expect(await requestHostPermission('www.thecut.com')).toBe(true); + expect(perms.request).toHaveBeenCalledWith({ + origins: ['https://www.thecut.com/*', 'http://www.thecut.com/*'], + }); + }); + + it('returns false (without prompting) for invalid host strings', async () => { + const perms = setupChromeMock(); + expect(await requestHostPermission('not a host')).toBe(false); + expect(perms.request).not.toHaveBeenCalled(); + }); +}); + +describe('removeHostPermission', () => { + it('asks chrome.permissions.remove for both http and https patterns', async () => { + const perms = setupChromeMock(['https://www.thecut.com/*', 'http://www.thecut.com/*']); + expect(await removeHostPermission('www.thecut.com')).toBe(true); + expect(perms.remove).toHaveBeenCalledWith({ + origins: ['https://www.thecut.com/*', 'http://www.thecut.com/*'], + }); + }); +}); + +describe('hasHostPermission', () => { + it('returns true only when both http and https are granted', async () => { + setupChromeMock(['https://www.thecut.com/*', 'http://www.thecut.com/*']); + expect(await hasHostPermission('www.thecut.com')).toBe(true); + }); + + it('returns false if only one of the two is granted', async () => { + setupChromeMock(['https://www.thecut.com/*']); + expect(await hasHostPermission('www.thecut.com')).toBe(false); + }); +});