From 55694a605ee831594ae4b3e628ac22d32a33542e Mon Sep 17 00:00:00 2001 From: Simple Analytics Codex Date: Wed, 7 Oct 2026 15:30:17 +0200 Subject: [PATCH] Fix Webflow PR review findings --- README.md | 146 ++++++++++-------- data-client/app/api/auth/callback/route.ts | 16 +- data-client/app/api/auth/disconnect/route.ts | 73 +++++++++ .../app/lib/controllers/scriptController.ts | 46 ++---- data-client/app/lib/utils/database.ts | 97 +++++++++++- data-client/tests/security.test.ts | 33 +++- designer-extension/src/App.css | 5 + designer-extension/src/App.tsx | 4 +- .../src/components/ConfigPanel.tsx | 94 ++++++++--- designer-extension/src/hooks/useAuth.ts | 7 +- .../src/hooks/useInstallScript.ts | 3 - designer-extension/src/services/api.ts | 17 ++ designer-extension/src/types/types.ts | 1 - scripts/smoke.mjs | 2 + 14 files changed, 409 insertions(+), 135 deletions(-) create mode 100644 data-client/app/api/auth/disconnect/route.ts diff --git a/README.md b/README.md index d27fddb..0a2bb25 100644 --- a/README.md +++ b/README.md @@ -1,110 +1,130 @@ -# Simple Analytics — Webflow Hybrid App +# Simple Analytics Webflow App -A Webflow Hybrid App that lets site owners add [Simple Analytics](https://www.simpleanalytics.com/) to their Webflow sites directly from the Webflow Designer. It uses the Webflow Data API to inject the Simple Analytics tracking script via Custom Code, and the Designer API to authenticate users. +A Webflow Hybrid App that lets site owners add and configure +[Simple Analytics](https://www.simpleanalytics.com/) from the Webflow Designer. +The Designer Extension provides the interface; the Next.js data client handles +OAuth and manages the app's custom code through the Webflow Data API. ## Architecture -``` -designer-extension/ Vite + React frontend (runs inside Webflow Designer) - ├── src/App.tsx Entry point — auth gate + config panel - ├── src/hooks/useAuth.ts OAuth token exchange via ID tokens - └── src/services/api.ts Calls to the data-client API - -data-client/ Next.js backend (API + OAuth server) - ├── app/api/auth/ OAuth authorize / callback / token routes - ├── app/api/scripts/ Register + apply Simple Analytics script - └── app/lib/utils/ Database (SQLite), JWT, ngrok helper +```text +designer-extension/ Vite + React extension shown in Webflow Designer + src/App.tsx Authentication gate and configuration panel + src/hooks/useAuth.ts Designer ID-token exchange + src/services/api.ts Calls to the data-client API + +data-client/ Next.js API and OAuth service + app/api/auth/ OAuth, session, and disconnect routes + app/api/scripts/ Register, apply, inspect, and remove app scripts + app/lib/utils/database.ts Encrypted OAuth-token storage in Redis ``` ## Prerequisites -- Node.js 18+ -- A [Webflow](https://webflow.com) site and workspace -- A registered Webflow App (see Setup step 2) +- Node.js 24 or newer +- Redis 7 +- A Webflow site and workspace +- A registered Webflow App -## Setup +## Local setup -1. **Clone the repository:** +1. Clone this repository and install the locked dependencies: ```bash - git clone https://github.com/runclubs/Simple-Analytics-App.git - cd Simple-Analytics-App + git clone https://github.com/simpleanalytics/webflow-app.git + cd webflow-app + npm ci + npm run install:all ``` -2. **Install all dependencies:** +2. Start a local Redis instance. This example exposes Redis only on localhost: ```bash - npm install - npm run install:all + docker run --rm --name webflow-app-redis -p 127.0.0.1:6379:6379 redis:7.4.8 ``` - This installs the root dependencies (concurrently), plus all packages for both `data-client/` and `designer-extension/`. +3. Register a Webflow App and configure this redirect URI: -3. **Register a Webflow App** in [your Workspace settings](https://developers.webflow.com/v2.0.0/data/docs/register-an-app): + ```text + http://localhost:3001/api/auth/callback + ``` - - Redirect URI: `http://localhost:3001/api/auth/callback` - - Required scopes (all four must be enabled): - - `authorized_user:read` - - `sites:read` - - `custom_code:read` - - `custom_code:write` + Enable these scopes: - > **Note:** if you forget to enable `authorized_user:read`, the OAuth flow will fail with an `invalid_scope` error. + - `authorized_user:read` + - `sites:read` + - `custom_code:read` + - `custom_code:write` -4. **Create the environment file:** +4. Create the backend environment file: ```bash cp data-client/.env.example data-client/.env ``` - Open `data-client/.env` and fill in your credentials from the Webflow Dashboard (Workspace Settings → Apps & Integrations → Your App): + Set the Webflow client ID and secret. The checked-in defaults point the + backend at local Redis and the local Designer Extension. - | Variable | Required | Where to find it | - |---|---|---| - | `WEBFLOW_CLIENT_ID` | Yes | Your app's OAuth Client ID | - | `WEBFLOW_CLIENT_SECRET` | Yes | Your app's OAuth Client Secret | - | `NGROK_AUTH_TOKEN` | No | Only needed for external testing | - - The remaining variables (`PORT`, `DESIGNER_EXTENSION_URI`) have sensible defaults and can be left as-is. - - > **Important:** the `.env` file contains secrets and is git-ignored. The `.env.example` file in the repo serves as a reference for which variables are needed. Never commit actual API keys. - -5. **Start the app:** +5. Start both applications: ```bash npm run dev ``` - This starts both servers concurrently: - - **Data Client (backend):** `http://localhost:3001` - - **Designer Extension (frontend):** `http://localhost:1337` + - Data client: `http://localhost:3001` + - Designer Extension: `http://localhost:1337` -6. **Authorize the app** by navigating to `http://localhost:3001` in your browser — this redirects to the Webflow OAuth consent screen. Click **Authorize**. +6. Open `http://localhost:3001` to authorize the app, then launch the + development app from the Webflow Designer Apps panel. -7. **Open the Designer Extension** in the Webflow Designer: Apps panel → your app → **"Launch Development App"**. - -## Environment Variables +## Environment variables | Variable | Required | Description | |---|---|---| | `WEBFLOW_CLIENT_ID` | Yes | OAuth client ID from Webflow | -| `WEBFLOW_CLIENT_SECRET` | Yes | OAuth client secret from Webflow | -| `NGROK_AUTH_TOKEN` | No | For exposing localhost during development | -| `PORT` | No | Data client port (default: `3001`) | -| `DESIGNER_EXTENSION_URI` | No | Extension origin for CORS + postMessage (default: `http://localhost:1337`) | +| `WEBFLOW_CLIENT_SECRET` | Yes | OAuth client secret; also derives the at-rest encryption key | +| `REDIS_URL` | Yes | Redis connection URL | +| `APP_BASE_URL` | Yes | Public origin of the data client; its OAuth callback must match Webflow exactly | +| `DESIGNER_EXTENSION_URI` | Yes | Exact Designer Extension origin allowed by CORS and `postMessage` | +| `PORT` | No | Local data-client port; defaults to `3001` | +| `NGROK_AUTH_TOKEN` | No | Development tunnel credential | + +Keep `.env` files out of version control; they contain credentials. + +## Script ownership and uninstalling + +Webflow only exposes custom code owned by this app. It cannot detect a Simple +Analytics script added manually in Site settings or by another app. Remove any +existing manual installation before using this app to avoid duplicate pageviews. + +Before uninstalling the Webflow App, choose **Disconnect Webflow** in the +extension and confirm the action. This removes the app-owned site script, +deletes the stored site authorization, and revokes the Webflow grant when its +last connected site is removed. Publish the Webflow site afterward so script +removal takes effect. + +## Validation + +```bash +npm test --prefix data-client +npm run lint --prefix data-client +npm run lint --prefix designer-extension +npm run build +``` ## Security -- **Tokens encrypted at rest** — access tokens are AES-256-GCM encrypted in the SQLite database, keyed from `WEBFLOW_CLIENT_SECRET`. -- **Env validation** — the server refuses to start if required credentials are missing. -- **CORS** — the API only accepts requests from the configured `DESIGNER_EXTENSION_URI`. -- **Security headers** — `X-Content-Type-Options: nosniff` and `X-Frame-Options: DENY` on all API responses. -- **postMessage origin** — the OAuth callback targets the extension origin instead of `*`. +- OAuth tokens are encrypted with AES-256-GCM before Redis persistence. +- Designer sessions are short-lived, site-bound JWTs. +- OAuth callbacks use a one-time state value and the same configured redirect + URI for authorization and token exchange. +- API CORS is restricted to `DESIGNER_EXTENSION_URI`. +- Disconnect removes app-owned custom code and stored authorization data. -## Tech Stack +## Tech stack -- **Data Client:** [Next.js](https://nextjs.org/), [Webflow SDK](https://github.com/webflow/js-webflow-api), SQLite -- **Designer Extension:** [React](https://react.dev/), [Vite](https://vitejs.dev/), [Webflow Designer API](https://developers.webflow.com/designer/reference/introduction) +- Next.js, Redis, and the Webflow Data API +- React, Vite, and the Webflow Designer API ## License diff --git a/data-client/app/api/auth/callback/route.ts b/data-client/app/api/auth/callback/route.ts index 3644379..0e0478f 100644 --- a/data-client/app/api/auth/callback/route.ts +++ b/data-client/app/api/auth/callback/route.ts @@ -2,10 +2,19 @@ import { timingSafeEqual } from "node:crypto"; import { WebflowClient } from "webflow-api"; import { NextRequest, NextResponse } from "next/server"; import db, { getRedis } from "../../../lib/utils/database"; -import { extensionOrigin, requiredEnv } from "../../../lib/utils/config"; +import { appOrigin, extensionOrigin, requiredEnv } from "../../../lib/utils/config"; export const dynamic = "force-dynamic"; +export function exchangeAuthorizationCode(code: string) { + return WebflowClient.getAccessToken({ + clientId: requiredEnv("WEBFLOW_CLIENT_ID"), + clientSecret: requiredEnv("WEBFLOW_CLIENT_SECRET"), + redirectUri: `${appOrigin()}/api/auth/callback`, + code, + }); +} + export async function GET(request: NextRequest) { const state = request.nextUrl.searchParams.get("state"); const cookie = request.cookies.get("webflow_oauth_state")?.value; @@ -19,10 +28,7 @@ export async function GET(request: NextRequest) { const saved = await (await getRedis()).getDel(`oauth:${state}`); if (!saved) return NextResponse.json({ error: "Authorization expired. Please connect again." }, { status: 400 }); const { popup } = JSON.parse(saved); - const accessToken = await WebflowClient.getAccessToken({ - clientId: requiredEnv("WEBFLOW_CLIENT_ID"), - clientSecret: requiredEnv("WEBFLOW_CLIENT_SECRET"), code, - }); + const accessToken = await exchangeAuthorizationCode(code); const webflow = new WebflowClient({ accessToken }); const { sites = [] } = await webflow.sites.list(); await Promise.all(sites.map(site => db.insertSiteAuthorization(site.id, accessToken))); diff --git a/data-client/app/api/auth/disconnect/route.ts b/data-client/app/api/auth/disconnect/route.ts new file mode 100644 index 0000000..b6210c4 --- /dev/null +++ b/data-client/app/api/auth/disconnect/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { WebflowClient } from "webflow-api"; +import jwt from "../../../lib/utils/jwt"; +import db from "../../../lib/utils/database"; +import { requiredEnv } from "../../../lib/utils/config"; +import { removeSiteScript } from "../../../lib/controllers/scriptController"; + +export const dynamic = "force-dynamic"; + +export async function revokeAuthorization(accessToken: string): Promise { + const response = await fetch("https://webflow.com/oauth/revoke_authorization", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: requiredEnv("WEBFLOW_CLIENT_ID"), + client_secret: requiredEnv("WEBFLOW_CLIENT_SECRET"), + access_token: accessToken, + }), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error("Webflow authorization revocation failed"); +} + +/** + * POST /api/auth/disconnect + * + * Removes the app's custom code and stored authorization for one site. A + * shared Webflow grant is revoked only when no other site uses it. + */ +export async function POST(request: NextRequest) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + const siteId = (body as { siteId?: unknown } | null)?.siteId; + if (typeof siteId !== "string" || !siteId) { + return NextResponse.json({ error: "siteId is required" }, { status: 400 }); + } + + const accessToken = await jwt.verifyAuth(request, siteId); + if (!accessToken) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const webflow = new WebflowClient({ accessToken }); + await removeSiteScript(webflow, siteId); + const remainingSites = await db.removeSiteAuthorization(siteId, accessToken); + + if (remainingSites === 0) { + try { + await revokeAuthorization(accessToken); + } catch { + // Keep the local authorization usable so the user can retry cleanup. + await db.insertSiteAuthorization(siteId, accessToken); + return NextResponse.json( + { error: "Webflow could not be disconnected. Please try again." }, + { status: 502 } + ); + } + } + + return NextResponse.json({ success: true }); + } catch { + return NextResponse.json( + { error: "Failed to disconnect Webflow" }, + { status: 500 } + ); + } +} diff --git a/data-client/app/lib/controllers/scriptController.ts b/data-client/app/lib/controllers/scriptController.ts index e51b555..f1051ed 100644 --- a/data-client/app/lib/controllers/scriptController.ts +++ b/data-client/app/lib/controllers/scriptController.ts @@ -1,4 +1,4 @@ -import { WebflowClient } from "webflow-api"; +import { Webflow, WebflowClient } from "webflow-api"; const SCRIPT_DISPLAY_NAME = "Simple Analytics"; @@ -82,18 +82,16 @@ export async function applySiteScript( interface ScriptStatus { installed: boolean; version?: string; - duplicateDetected: boolean; } /** - * Check if a Simple Analytics script is already registered for a site, - * and detect duplicate SA scripts (e.g. manually added via Project Settings). + * Check if a Simple Analytics script registered by this app is applied to a site. + * Webflow does not expose custom code owned by other apps or added manually. */ export async function checkScriptStatus( webflow: WebflowClient, siteId: string ): Promise { - // Check registered scripts from our app const existingScripts = await webflow.scripts.list(siteId); const matching = (existingScripts.registeredScripts || []) .filter((s) => s.displayName === SCRIPT_DISPLAY_NAME) @@ -109,36 +107,7 @@ export async function checkScriptStatus( const installed = Boolean(installedScript); const version = installedScript?.version; - // Check for duplicate SA scripts on the site (e.g. manually added) - let duplicateDetected = false; - try { - const scripts = applied; - - // Count how many applied scripts reference simpleanalyticscdn in their source - // Our app registers scripts with displayName "Simple Analytics", - // so any OTHER script pointing to simpleanalyticscdn.com is a duplicate - const ourScriptIds = new Set(matching.map((s) => s.id)); - const otherSAScripts = scripts.filter((s: { id?: string }) => { - // If this script is one of ours, skip it - if (s.id && ourScriptIds.has(s.id)) return false; - - // Check registered scripts list for source URL - const registered = (existingScripts.registeredScripts || []).find( - (r) => r.id === s.id - ); - const reg = registered as Record | undefined; - if (typeof reg?.sourceUrl === "string" && reg.sourceUrl.includes("simpleanalyticscdn.com")) return true; - if (typeof reg?.source === "string" && reg.source.includes("simpleanalyticscdn.com")) return true; - - return false; - }); - - duplicateDetected = otherSAScripts.length > 0; - } catch { - // getCustomCode may fail if no custom code exists yet — that's fine - } - - return { installed, version, duplicateDetected }; + return { installed, version }; } /** @@ -148,5 +117,10 @@ export async function removeSiteScript( webflow: WebflowClient, siteId: string ): Promise { - await webflow.sites.scripts.deleteCustomCode(siteId); + try { + await webflow.sites.scripts.deleteCustomCode(siteId); + } catch (error) { + // Disconnect is idempotent when the app has no custom code on the site. + if (!(error instanceof Webflow.NotFoundError)) throw error; + } } diff --git a/data-client/app/lib/utils/database.ts b/data-client/app/lib/utils/database.ts index cb6cf40..63cdd02 100644 --- a/data-client/app/lib/utils/database.ts +++ b/data-client/app/lib/utils/database.ts @@ -1,5 +1,11 @@ import { createClient } from "redis"; -import { scryptSync, randomBytes, createCipheriv, createDecipheriv } from "node:crypto"; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + scryptSync, +} from "node:crypto"; import { requiredEnv } from "./config"; // Only ciphertext is persisted, including in Redis AOF/snapshots. @@ -22,6 +28,11 @@ export function decrypt(encoded: string): string { let client: ReturnType | undefined; let connecting: Promise | undefined; +function authorizationSitesKey(accessToken: string): string { + const fingerprint = createHash("sha256").update(accessToken).digest("hex"); + return `authorization:${fingerprint}:sites`; +} + export async function getRedis() { if (!client) { client = createClient({ @@ -41,7 +52,83 @@ export async function getRedis() { export async function insertSiteAuthorization(siteId: string, accessToken: string) { const db = await getRedis(); - await db.set(`site:${siteId}`, encrypt(accessToken)); + const siteKey = `site:${siteId}`; + const previous = await db.get(siteKey); + let previousSitesKey: string | undefined; + if (previous) { + try { + const previousToken = decrypt(previous); + if (previousToken !== accessToken) { + previousSitesKey = authorizationSitesKey(previousToken); + } + } catch { + // Replacing an unreadable entry repairs the site mapping. + } + } + const sitesKey = authorizationSitesKey(accessToken); + await db.eval( + `redis.call("SET", KEYS[1], ARGV[1]) + redis.call("SADD", KEYS[2], ARGV[2]) + if KEYS[3] then + redis.call("SREM", KEYS[3], ARGV[2]) + if redis.call("SCARD", KEYS[3]) == 0 then redis.call("DEL", KEYS[3]) end + end + return 1`, + { + keys: previousSitesKey + ? [siteKey, sitesKey, previousSitesKey] + : [siteKey, sitesKey], + arguments: [encrypt(accessToken), siteId], + } + ); +} + +export async function removeSiteAuthorization( + siteId: string, + accessToken: string +): Promise { + const db = await getRedis(); + const key = `site:${siteId}`; + const ciphertext = await db.get(key); + if (!ciphertext) throw new Error("Site authorization changed"); + + if (decrypt(ciphertext) !== accessToken) throw new Error("Site authorization changed"); + + // One Webflow grant can authorize multiple sites. Only revoke it upstream + // after its final site mapping has been removed. + const sitesKey = authorizationSitesKey(accessToken); + const result = await db.eval( + `if redis.call("GET", KEYS[1]) ~= ARGV[1] then return -1 end + redis.call("DEL", KEYS[1]) + redis.call("SREM", KEYS[2], ARGV[2]) + return redis.call("SCARD", KEYS[2])`, + { keys: [key, sitesKey], arguments: [ciphertext, siteId] } + ); + if (result === -1) throw new Error("Site authorization changed"); + let remainingSites = Number(result); + + if (remainingSites === 0) { + // Backfill the index for mappings written before disconnect support. + for await (const keys of db.scanIterator({ MATCH: "site:*", COUNT: 100 })) { + if (keys.length === 0) continue; + const values = await db.mGet(keys); + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (!value) continue; + try { + if (decrypt(value) === accessToken) { + await db.sAdd(sitesKey, keys[index].slice("site:".length)); + remainingSites += 1; + } + } catch { + // Ignore unrelated corrupt entries; they cannot contain this token. + } + } + } + } + + if (remainingSites === 0) await db.del(sitesKey); + return remainingSites; } export async function getAccessTokenFromSiteId(siteId: string): Promise { @@ -51,4 +138,8 @@ export async function getAccessTokenFromSiteId(siteId: string): Promise return decrypt(ciphertext); } -export default { insertSiteAuthorization, getAccessTokenFromSiteId }; +export default { + insertSiteAuthorization, + removeSiteAuthorization, + getAccessTokenFromSiteId, +}; diff --git a/data-client/tests/security.test.ts b/data-client/tests/security.test.ts index 17658bd..03d2902 100644 --- a/data-client/tests/security.test.ts +++ b/data-client/tests/security.test.ts @@ -3,15 +3,18 @@ import assert from "node:assert/strict"; import { runInNewContext } from "node:vm"; import { NextRequest } from "next/server"; import { SignJWT } from "jose"; +import { WebflowClient } from "webflow-api"; import db, { encrypt, decrypt } from "../app/lib/utils/database"; import jwt from "../app/lib/utils/jwt"; import { POST as exchange } from "../app/api/auth/token/route"; -import { GET as callback } from "../app/api/auth/callback/route"; +import { GET as callback, exchangeAuthorizationCode } from "../app/api/auth/callback/route"; +import { revokeAuthorization } from "../app/api/auth/disconnect/route"; import { generateScript, type ScriptConfig } from "../app/lib/utils/scriptGenerator"; import { checkScriptStatus } from "../app/lib/controllers/scriptController"; -import type { WebflowClient } from "webflow-api"; process.env.WEBFLOW_CLIENT_SECRET = "local-test-secret-not-used-in-production"; +process.env.WEBFLOW_CLIENT_ID = "local-test-client"; +process.env.APP_BASE_URL = "https://webflow-app.example"; afterEach(() => mock.restoreAll()); test("tokens are encrypted with random IVs and reject tampering", () => { @@ -67,6 +70,30 @@ test("OAuth rejects callbacks without a matching browser nonce before token exch assert.equal(fetchMock.mock.callCount(), 0); }); +test("OAuth token exchange repeats the exact configured callback URI", async () => { + const getAccessToken = mock.method(WebflowClient, "getAccessToken", async () => "access-token"); + assert.equal(await exchangeAuthorizationCode("authorization-code"), "access-token"); + assert.deepEqual(getAccessToken.mock.calls[0].arguments[0], { + clientId: "local-test-client", + clientSecret: "local-test-secret-not-used-in-production", + redirectUri: "https://webflow-app.example/api/auth/callback", + code: "authorization-code", + }); +}); + +test("Webflow revocation sends the grant only to the official endpoint", async () => { + const fetchMock = mock.method(globalThis, "fetch", async () => new Response(null, { status: 200 })); + await revokeAuthorization("access-token"); + assert.equal(fetchMock.mock.calls[0].arguments[0], "https://webflow.com/oauth/revoke_authorization"); + const options = fetchMock.mock.calls[0].arguments[1] as RequestInit; + assert.equal(options.method, "POST"); + assert.deepEqual(JSON.parse(options.body as string), { + client_id: "local-test-client", + client_secret: "local-test-secret-not-used-in-production", + access_token: "access-token", + }); +}); + const config: ScriptConfig = { trackPageviews: true, trackEvents: false, collectDNT: false, automatedEvents: true, hashMode: false, ignorePages: "", customDomain: "", overwriteDomain: "", @@ -89,5 +116,5 @@ test("script settings are serialized safely and disabled event types stay disabl test("a registered script is not installed after it has been removed", async () => { const webflow = { scripts: { list: async () => ({ registeredScripts: [{ id: "sa", displayName: "Simple Analytics", version: "1.0.0" }] }) }, sites: { scripts: { getCustomCode: async () => ({ scripts: [] }) } } } as unknown as WebflowClient; - assert.equal((await checkScriptStatus(webflow, "site-a")).installed, false); + assert.deepEqual(await checkScriptStatus(webflow, "site-a"), { installed: false, version: undefined }); }); diff --git a/designer-extension/src/App.css b/designer-extension/src/App.css index c249bac..4332fcc 100644 --- a/designer-extension/src/App.css +++ b/designer-extension/src/App.css @@ -440,3 +440,8 @@ body { .footer-disconnect:hover { color: #ccc; } + +.footer-disconnect:disabled { + color: #555; + cursor: not-allowed; +} diff --git a/designer-extension/src/App.tsx b/designer-extension/src/App.tsx index f231811..c4a706b 100644 --- a/designer-extension/src/App.tsx +++ b/designer-extension/src/App.tsx @@ -5,7 +5,7 @@ import { ConfigPanel } from "./components/ConfigPanel"; import "./App.css"; function App() { - const { sessionToken, isAuthLoading, exchangeAndVerifyIdToken, logout } = useAuth(); + const { sessionToken, isAuthLoading, exchangeAndVerifyIdToken, clearSession } = useAuth(); useEffect(() => { webflow.setExtensionSize("comfortable"); @@ -27,7 +27,7 @@ function App() { } if (sessionToken) { - return ; + return ; } return ; diff --git a/designer-extension/src/components/ConfigPanel.tsx b/designer-extension/src/components/ConfigPanel.tsx index 31b8466..c018952 100644 --- a/designer-extension/src/components/ConfigPanel.tsx +++ b/designer-extension/src/components/ConfigPanel.tsx @@ -2,7 +2,8 @@ import { useState, useCallback, useEffect } from "react"; import { useSites } from "../hooks/useSites"; import { useInstallScript } from "../hooks/useInstallScript"; import { StatusMessage } from "./StatusMessage"; -import { ScriptConfig } from "../types/types"; +import { ScriptConfig, StatusInfo } from "../types/types"; +import { disconnectWebflow } from "../services/api"; const DEFAULT_CONFIG: ScriptConfig = { trackPageviews: true, @@ -50,10 +51,10 @@ function saveConfig(siteId: string | null, config: ScriptConfig) { interface ConfigPanelProps { sessionToken: string; - onLogout: () => void; + onDisconnect: () => void; } -export function ConfigPanel({ sessionToken, onLogout }: ConfigPanelProps) { +export function ConfigPanel({ sessionToken, onDisconnect }: ConfigPanelProps) { const { siteId } = useSites(); const { install, @@ -67,20 +68,27 @@ export function ConfigPanel({ sessionToken, onLogout }: ConfigPanelProps) { const [activeTab, setActiveTab] = useState<"settings" | "info">("settings"); const [showAdvanced, setShowAdvanced] = useState(false); const [showEvents, setShowEvents] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [disconnectStatus, setDisconnectStatus] = useState({ + message: "", + type: "", + }); const [config, setConfig] = useState(() => loadConfig(siteId)); + const [loadedSiteId, setLoadedSiteId] = useState(null); // Reload config when siteId becomes available useEffect(() => { if (siteId) { setConfig(loadConfig(siteId)); + setLoadedSiteId(siteId); } }, [siteId]); - // Persist config on every change + // Do not persist defaults until the saved config for this site has loaded. useEffect(() => { - saveConfig(siteId, config); - }, [siteId, config]); + if (siteId && loadedSiteId === siteId) saveConfig(siteId, config); + }, [siteId, loadedSiteId, config]); function handleToggle(key: keyof ScriptConfig) { setConfig((prev) => ({ ...prev, [key]: !prev[key] })); @@ -111,9 +119,43 @@ export function ConfigPanel({ sessionToken, onLogout }: ConfigPanelProps) { }, []); function handleInstall() { + setDisconnectStatus({ message: "", type: "" }); install(config); } + async function handleDisconnect() { + if (!siteId) { + setDisconnectStatus({ + message: "Could not detect site. Open this app from the Webflow Designer.", + type: "error", + }); + return; + } + if (!window.confirm( + "Disconnect Simple Analytics from this site? This removes the app's script and stored Webflow authorization. Publish the site afterward to apply the script removal." + )) return; + + setIsDisconnecting(true); + setDisconnectStatus({ message: "Disconnecting Webflow...", type: "info" }); + try { + await disconnectWebflow(siteId, sessionToken); + const key = getStorageKey(siteId); + try { + if (key) localStorage.removeItem(key); + } catch { + // Authorization is already removed; local storage may be unavailable. + } + onDisconnect(); + } catch (error) { + setDisconnectStatus({ + message: `Error: ${error instanceof Error ? error.message : "Disconnection failed"}`, + type: "error", + }); + } finally { + setIsDisconnecting(false); + } + } + return (
{/* Tab bar */} @@ -413,30 +455,35 @@ export function ConfigPanel({ sessionToken, onLogout }: ConfigPanelProps) { ) : ( - + <> +
+ Already added Simple Analytics in Webflow Site settings? Remove + that manual script before installing to avoid duplicate pageviews. +
+ + )}
- )} @@ -460,6 +507,11 @@ export function ConfigPanel({ sessionToken, onLogout }: ConfigPanelProps) { script to your site. After installing, publish your site to start collecting data.

+

+ If Simple Analytics was previously added in Webflow Site settings, + remove that manual script first. Webflow does not let apps inspect + custom code added manually or by other apps. +

@@ -483,6 +535,8 @@ export function ConfigPanel({ sessionToken, onLogout }: ConfigPanelProps) {
)} + +
Powered by{" "} @@ -495,8 +549,12 @@ export function ConfigPanel({ sessionToken, onLogout }: ConfigPanelProps) { - · diff --git a/designer-extension/src/hooks/useAuth.ts b/designer-extension/src/hooks/useAuth.ts index 8b3d26b..d6f8070 100644 --- a/designer-extension/src/hooks/useAuth.ts +++ b/designer-extension/src/hooks/useAuth.ts @@ -50,5 +50,10 @@ export function useAuth() { return () => clearTimeout(timer); }, [sessionToken, exchangeAndVerifyIdToken]); - return { sessionToken, isAuthLoading, exchangeAndVerifyIdToken, logout: () => setSessionToken("") }; + return { + sessionToken, + isAuthLoading, + exchangeAndVerifyIdToken, + clearSession: () => setSessionToken(""), + }; } diff --git a/designer-extension/src/hooks/useInstallScript.ts b/designer-extension/src/hooks/useInstallScript.ts index d4b273f..321646b 100644 --- a/designer-extension/src/hooks/useInstallScript.ts +++ b/designer-extension/src/hooks/useInstallScript.ts @@ -18,7 +18,6 @@ export function useInstallScript( const [isRemoving, setIsRemoving] = useState(false); const [isChecking, setIsChecking] = useState(true); const [isInstalled, setIsInstalled] = useState(false); - const [duplicateDetected, setDuplicateDetected] = useState(false); const [scriptVersion, setScriptVersion] = useState(null); const [status, setStatus] = useState({ message: "", type: "" }); @@ -37,7 +36,6 @@ export function useInstallScript( if (!cancelled) { setIsInstalled(result.installed); setScriptVersion(result.version ?? null); - setDuplicateDetected(result.duplicateDetected); } } catch { // Silent fail — default to not installed @@ -133,7 +131,6 @@ export function useInstallScript( isRemoving, isChecking, isInstalled, - duplicateDetected, scriptVersion, status, }; diff --git a/designer-extension/src/services/api.ts b/designer-extension/src/services/api.ts index cec52ed..aa51ce9 100644 --- a/designer-extension/src/services/api.ts +++ b/designer-extension/src/services/api.ts @@ -94,3 +94,20 @@ export async function removeScript( throw new Error(data.error || "Failed to remove script"); } } + +/** POST /api/auth/disconnect - remove app data and authorization for this site */ +export async function disconnectWebflow( + siteId: string, + token: string +): Promise { + const response = await fetchWithTimeout(`${BASE_URL}/api/auth/disconnect`, { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ siteId }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to disconnect Webflow"); + } +} diff --git a/designer-extension/src/types/types.ts b/designer-extension/src/types/types.ts index 96d1582..2357484 100644 --- a/designer-extension/src/types/types.ts +++ b/designer-extension/src/types/types.ts @@ -50,7 +50,6 @@ export interface RegisteredScript { export interface ScriptStatus { installed: boolean; version?: string; - duplicateDetected: boolean; } // Status message display diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index b8a8ac4..cbbc556 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -16,6 +16,8 @@ if (process.argv.includes("--unhealthy")) { const response = await request(`/api/scripts/${action}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siteId: "test-site", scriptId: "test-script", version: "1.0.0" }) }); assert.equal(response.status, 401); } + const disconnect = await request("/api/auth/disconnect", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siteId: "test-site" }) }); + assert.equal(disconnect.status, 401); for (const path of ["/api/sites", "/api/scripts/status?siteId=test-site"]) { assert.equal((await request(path)).status, 401); }