From c3b11a8ef3cc21d8e9c2a9086664f2360e51ec1d Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:31:10 +1000 Subject: [PATCH 01/41] Share extension site jobs logic --- CHANGELOG.md | 8 + .../webflow-designer-extension.md | 16 +- docs/plans/README.md | 6 +- .../webflow-extension-reuse-follow-up.md | 96 ++++++ web/static/app/lib/site-jobs.js | 325 ++++++++++++++++++ web/static/app/pages/webflow-jobs.js | 186 +--------- webflow-designer-extension-cli/README.md | 10 +- .../public/lib/bridge.js | 2 + .../scripts/sync-shared.js | 1 + webflow-designer-extension-cli/src/index.ts | 320 ++++++----------- 10 files changed, 576 insertions(+), 394 deletions(-) create mode 100644 docs/plans/webflow-extension-reuse-follow-up.md create mode 100644 web/static/app/lib/site-jobs.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 29288fe37..2bfafc30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,14 @@ On merge, CI will: ## [Unreleased] +### Changed + +- shared Webflow extension job fetching, site scoping, and realtime fallback + logic into `web/static/app/lib/site-jobs.js`, reducing duplication between + the app layer and extension bridge runtime +- aligned extension reuse docs with the current ES modules migration state and + the remaining hybrid auth popup architecture + ## [0.31.3] – 2026-04-04 ### Fixed diff --git a/docs/architecture/webflow-designer-extension.md b/docs/architecture/webflow-designer-extension.md index 8e23105be..dfcd7d57b 100644 --- a/docs/architecture/webflow-designer-extension.md +++ b/docs/architecture/webflow-designer-extension.md @@ -16,13 +16,27 @@ Designer. It does not contain backend business logic. ## Auth model - Extension initiates auth via popup to GNH-hosted `/extension-auth.html`. -- Popup reuses existing shared auth system in `web/static/js/auth.js`. +- Popup currently uses a hybrid flow: + - server page shell in `web/templates/extension-auth.html` + - module entrypoint in `web/static/app/pages/webflow-login.js` + - legacy shared auth modal and redirect helpers in `web/static/js/auth.js` - First-time users are created via existing `POST /v1/auth/register` path. - Token handoff returns to extension using `postMessage` with origin/state validation. - Extension keeps auth token in session scope (`sessionStorage`) rather than persistent local storage. +## Shared frontend reuse + +- Shared primitives live under `web/static/app/`. +- The extension already reuses shared API helpers and Web Components via + `webflow-designer-extension-cli/scripts/sync-shared.js` and + `webflow-designer-extension-cli/public/lib/bridge.js`. +- Most extension page orchestration still lives in + `webflow-designer-extension-cli/src/index.ts`. +- The extension does not yet share the full jobs page orchestration or shell + styling with the main app. + ## Repository boundaries - Extension code: `/webflow-designer-extension-cli` diff --git a/docs/plans/README.md b/docs/plans/README.md index 08269a3cc..281725fda 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -12,12 +12,10 @@ Future features, implementation strategies, and technical planning documents. messaging strategy - **[platform-auth-architecture.md](./platform-auth-architecture.md)** - Platform authentication design -- **[unified-frontend-es-modules-plan.md](./unified-frontend-es-modules-plan.md)** - - Unified no-build frontend migration plan -- **[webflow-extension-binding-migration.md](./webflow-extension-binding-migration.md)** - - Migrate Webflow extension UI from imperative DOM to the shared binding system - **[ui-implementation.md](./ui-implementation.md)** - Frontend development strategy +- **[webflow-extension-reuse-follow-up.md](./webflow-extension-reuse-follow-up.md)** - + JS-first follow-up plan for extension reuse and shared frontend consolidation - **[webflow-integration.md](./webflow-integration.md)** - Webflow marketplace integration diff --git a/docs/plans/webflow-extension-reuse-follow-up.md b/docs/plans/webflow-extension-reuse-follow-up.md new file mode 100644 index 000000000..aaf422db0 --- /dev/null +++ b/docs/plans/webflow-extension-reuse-follow-up.md @@ -0,0 +1,96 @@ +# Webflow Extension Reuse Follow-up + +Date: 2026-04-05 Status: Proposed Scope: Webflow Designer extension +consolidation and shared frontend reuse + +## Current state + +The broader ES modules migration is complete and archived in the changelog. The +main app now has an established shared frontend layer in `web/static/app/`, +with `/dashboard`, settings, and job details already migrated to the module +architecture. + +Both branch checkpoints associated with that migration, +`feat/es-modules-extension-sync` and `feat/es-modules-phase-0`, are already +contained in `main`. They should be treated as historical delivery branches, +not pending work. + +The Webflow Designer extension has already adopted part of that shared layer: + +- shared API helpers via `app/lib/` +- shared Web Components via `app/components/` +- shared module sync into the extension build +- a bridge/import-map pattern so extension code can consume shared modules in a + cross-origin runtime + +That means the transition is partly complete. Shared primitives exist and are +in use, but the extension has not yet reached the same level of modular +consolidation as the main app. + +## What is already complete + +- `/dashboard`, settings, and job details are on the ES module architecture +- shared module structure exists in `web/static/app/`: + - `lib/` for reusable logic + - `components/` for shared UI primitives + - `pages/` for page orchestration +- the extension reuses shared primitives and API helpers through the + bridge/sync approach +- design tokens exist in the app layer and mirror the extension theme +- the completed migration is already documented in `CHANGELOG.md` + +## Remaining gaps + +- The extension still keeps most page orchestration in + `webflow-designer-extension-cli/src/index.ts` rather than in shared `/app` + modules. +- The current job-list sharing story is incomplete. The repository contains + `web/static/app/pages/webflow-jobs.js`, but the live extension still owns much + of its own job rendering and refresh flow. +- The extension auth popup still depends on legacy `/js/auth.js`. +- Extension shell styling remains separate from app styling. The app tokens + mirror the extension theme, but the extension shell has not been migrated to + the app style layer. +- Some documentation still overstates how far the extension has been + consolidated into the shared module system. + +## Recommended next phase + +This follow-up should be treated as a JavaScript-first reuse pass, not a full +UI unification project. + +- Extract surface-agnostic extension logic from + `webflow-designer-extension-cli/src/index.ts` into shared `/app` modules. +- Make the job-list sharing story truthful and consistent: + - either move extension job-list behaviour onto shared `pages/` modules + - or narrow the shared module claims so the code and docs say exactly what is + shared +- Keep the bridge/import-map approach for cross-origin extension use. +- Continue sharing reusable logic and UI primitives first, before attempting to + merge the full extension shell layout into the main app. +- Replace the remaining legacy auth dependency in `/extension-auth` so the + popup flow no longer relies on `/js/auth.js`. +- Update architecture and planning docs so they reflect the current state + accurately. + +## Non-goals + +- Recreating the deleted March 2026 ES modules plan +- Re-running the full `/dashboard` modernisation effort +- Forcing full shell or layout convergence between the extension and the main + app in this phase +- Replacing working backend Webflow APIs as part of this documentation update + +## Acceptance criteria + +- The new plan clearly states that the ES modules migration is complete and + archived in the changelog. +- The new plan clearly states that both ES modules branch checkpoints are + already contained in `main`. +- The new plan distinguishes between shared primitives that already exist and + extension page orchestration that is still local. +- The new plan sets a JS-first extension consolidation direction without + implying that the extension already shares all page-level logic with + `/dashboard`. +- The new plan supersedes the old branch-era planning context without + recreating archived migration history. diff --git a/web/static/app/lib/site-jobs.js b/web/static/app/lib/site-jobs.js new file mode 100644 index 000000000..720f37961 --- /dev/null +++ b/web/static/app/lib/site-jobs.js @@ -0,0 +1,325 @@ +/** + * lib/site-jobs.js — shared job fetching, site scoping, and realtime helpers + * + * Shared between app pages and the Webflow Designer extension. + * Rendering stays surface-specific; this module only owns data and state helpers. + */ + +import { get } from "/app/lib/api-client.js"; + +const REALTIME_DEBOUNCE_MS = 250; +const SUBSCRIBE_RETRY_INTERVAL_MS = 1000; +const MAX_SUBSCRIBE_RETRIES = 15; +const DEFAULT_FALLBACK_POLLING_INTERVAL_MS = 1000; + +/** + * Fetch the job list from the API. + * + * @param {{ limit?: number, range?: string, include?: string }} [options] + * @returns {Promise[]>} + */ +export async function fetchJobs(options = {}) { + const params = new URLSearchParams(); + if (options.limit) params.set("limit", String(options.limit)); + if (options.range) params.set("range", options.range); + if (options.include) params.set("include", options.include); + const qs = params.toString(); + const res = await get(`/v1/jobs${qs ? `?${qs}` : ""}`); + return res?.jobs ?? []; +} + +/** + * Normalise a domain or URL into a bare hostname. + * @param {string} input + * @returns {string} + */ +export function normaliseDomain(input) { + const trimmed = String(input || "") + .trim() + .toLowerCase() + .replace(/^https?:\/\//, "") + .replace(/^www\./, ""); + + if (!trimmed) { + return ""; + } + + return trimmed.split("/")[0] || trimmed; +} + +/** + * @param {{ siteDomain?: string|null, siteDomainCandidates?: string[] }} [options] + * @returns {string[]} + */ +export function getSiteDomainCandidates(options = {}) { + const normalised = new Set( + (options.siteDomainCandidates || []) + .map((candidate) => normaliseDomain(candidate)) + .filter(Boolean) + ); + + if (options.siteDomain) { + normalised.add(normaliseDomain(options.siteDomain)); + } + + return [...normalised]; +} + +/** + * Filter jobs to the current site scope. + * + * @param {Record[]} jobs + * @param {{ siteDomain?: string|null, siteDomainCandidates?: string[] }} [options] + * @returns {Record[]} + */ +export function filterJobsByDomains(jobs = [], options = {}) { + const candidates = getSiteDomainCandidates(options); + return jobs.filter((job) => { + const jobDomain = normaliseDomain(job?.domains?.name || job?.domain || ""); + return !candidates.length || candidates.includes(jobDomain); + }); +} + +/** + * @param {Record[]} jobs + * @param {{ siteDomain?: string|null, siteDomainCandidates?: string[] }} [options] + * @returns {Record|null} + */ +export function pickLatestJobByDomains(jobs = [], options = {}) { + return filterJobsByDomains(jobs, options)[0] || null; +} + +/** + * @param {Record[]} jobs + * @param {{ siteDomain?: string|null, siteDomainCandidates?: string[] }} [options] + * @param {(status: string) => boolean} [isActiveJobStatus] + * @returns {string} + */ +export function buildCompletedJobsSignature( + jobs = [], + options = {}, + isActiveJobStatus = defaultIsActiveJobStatus +) { + const completed = filterJobsByDomains(jobs, options) + .filter((job) => !isActiveJobStatus(String(job.status || ""))) + .slice(0, 6); + + return completed + .map( + (job) => + `${job.id || ""}:${job.status || ""}:${job.total_tasks || 0}:${job.completed_tasks || 0}:${job.failed_tasks || 0}:${job.skipped_tasks || 0}:${job.completed_at || ""}` + ) + .join("|"); +} + +/** + * @param {Record[]} jobs + * @param {{ siteDomain?: string|null, siteDomainCandidates?: string[] }} [options] + * @returns {string} + */ +export function buildChartJobsSignature(jobs = [], options = {}) { + const chartJobs = filterJobsByDomains(jobs, options) + .filter((job) => String(job.status || "").trim().toLowerCase() === "completed") + .slice(0, 12); + + return chartJobs + .map( + (job) => + `${job.id || ""}:${job.status || ""}:${job.failed_tasks || 0}:${job.skipped_tasks || 0}:${job.completed_at || ""}:${job.total_tasks || 0}` + ) + .join("|"); +} + +/** + * Subscribe to job updates via Supabase Realtime with a polling fallback. + * + * @param {{ + * orgId: string, + * onUpdate: () => void, + * supabaseClient?: { channel?: (name: string) => any, removeChannel?: (channel: any) => Promise }, + * channelName?: string, + * getFallbackInterval?: () => number, + * onSubscriptionIssue?: (status?: string, err?: Error) => void + * }} options + * @returns {() => void} + */ +export function subscribeToJobUpdates(options) { + const { + orgId, + onUpdate, + supabaseClient = window.supabase, + channelName = `hover-jobs:${orgId}`, + getFallbackInterval = () => DEFAULT_FALLBACK_POLLING_INTERVAL_MS, + onSubscriptionIssue, + } = options; + + let channel = null; + let retryCount = 0; + let retryTimer = null; + let fallbackTimer = null; + let lastUpdate = 0; + let debounceTimer = null; + let fallbackIntervalMs = null; + let unsubscribed = false; + + function resolveFallbackInterval() { + const next = Number(getFallbackInterval()); + return Number.isFinite(next) && next > 0 + ? next + : DEFAULT_FALLBACK_POLLING_INTERVAL_MS; + } + + function startFallback() { + const nextMs = resolveFallbackInterval(); + if (fallbackTimer && fallbackIntervalMs === nextMs) return; + if (fallbackTimer) { + clearInterval(fallbackTimer); + } + fallbackIntervalMs = nextMs; + fallbackTimer = setInterval(() => { + if (!unsubscribed) { + onUpdate(); + } + }, fallbackIntervalMs); + } + + function clearFallback() { + if (fallbackTimer) { + clearInterval(fallbackTimer); + fallbackTimer = null; + fallbackIntervalMs = null; + } + } + + function cleanup() { + unsubscribed = true; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + clearFallback(); + if (channel && supabaseClient?.removeChannel) { + supabaseClient.removeChannel(channel).catch(() => {}); + channel = null; + } + } + + function throttledUpdate() { + const now = Date.now(); + if (now - lastUpdate >= REALTIME_DEBOUNCE_MS) { + lastUpdate = now; + clearFallback(); + onUpdate(); + return; + } + + if (!debounceTimer) { + debounceTimer = setTimeout(() => { + debounceTimer = null; + if (unsubscribed) return; + lastUpdate = Date.now(); + clearFallback(); + onUpdate(); + }, REALTIME_DEBOUNCE_MS); + } + } + + function subscribe() { + if (unsubscribed) return; + + if (!orgId || !supabaseClient?.channel || !supabaseClient?.removeChannel) { + if (retryCount < MAX_SUBSCRIBE_RETRIES) { + retryCount++; + retryTimer = setTimeout(subscribe, SUBSCRIBE_RETRY_INTERVAL_MS); + } else { + onSubscriptionIssue?.("MAX_RETRIES"); + startFallback(); + } + return; + } + + retryCount = 0; + retryTimer = null; + + try { + channel = supabaseClient + .channel(channelName) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "jobs", + filter: `organisation_id=eq.${orgId}`, + }, + throttledUpdate + ) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "jobs", + filter: `organisation_id=eq.${orgId}`, + }, + throttledUpdate + ) + .on( + "postgres_changes", + { + event: "DELETE", + schema: "public", + table: "jobs", + filter: `organisation_id=eq.${orgId}`, + }, + throttledUpdate + ) + .subscribe((status, err) => { + if ( + (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || err) && + !unsubscribed + ) { + onSubscriptionIssue?.(status, err); + startFallback(); + } + }); + + // Start fallback immediately; clearFallback() stops it on the first real event. + startFallback(); + } catch (err) { + onSubscriptionIssue?.("SUBSCRIBE_FAILED", err); + startFallback(); + } + } + + subscribe(); + return cleanup; +} + +function defaultIsActiveJobStatus(status) { + const normalised = String(status || "").trim().toLowerCase(); + return [ + "pending", + "queued", + "initializing", + "running", + "in_progress", + "processing", + "cancelling", + ].includes(normalised); +} + +export default { + fetchJobs, + normaliseDomain, + getSiteDomainCandidates, + filterJobsByDomains, + pickLatestJobByDomains, + buildCompletedJobsSignature, + buildChartJobsSignature, + subscribeToJobUpdates, +}; diff --git a/web/static/app/pages/webflow-jobs.js b/web/static/app/pages/webflow-jobs.js index b58166015..300665f8c 100644 --- a/web/static/app/pages/webflow-jobs.js +++ b/web/static/app/pages/webflow-jobs.js @@ -1,42 +1,25 @@ /** - * pages/webflow-jobs.js — shared job-list logic for Webflow extension and dashboard + * pages/webflow-jobs.js — dashboard job helpers and table renderer * - * This module provides the data-fetching, state management, and rendering - * helpers for the job list surface. It is imported by both the Webflow - * Designer extension panel and the main app dashboard. - * - * It does NOT own a page lifecycle — pages that use it (extension index.ts, - * future dashboard.js) call init() and supply their own DOM containers. - * - * Prerequisites: - * - window.supabase initialised before calling subscribeToJobUpdates() - * - api-client.js / auth-session.js available via ES module imports - * - * Usage: - * import { fetchJobs, renderJobList, subscribeToJobUpdates } from "/app/pages/webflow-jobs.js"; - * - * const jobs = await fetchJobs({ limit: 10 }); - * renderJobList(container, jobs); - * const unsubscribe = subscribeToJobUpdates(orgId, () => refresh()); + * Shared fetching and realtime state lives in /app/lib/site-jobs.js. + * This page module keeps the dashboard-facing table renderer and a thin + * wrapper for the dashboard's adaptive polling cadence. */ -import { get } from "/app/lib/api-client.js"; +import { + fetchJobs as fetchSharedJobs, + subscribeToJobUpdates as subscribeToSharedJobUpdates, +} from "/app/lib/site-jobs.js"; import { formatRelativeTime, formatDuration, formatCount, - formatStatus, - statusCategory, } from "/app/lib/formatters.js"; import { createStatusPill } from "/app/components/hover-status-pill.js"; import { createDataTable } from "/app/components/hover-data-table.js"; // ── Constants ────────────────────────────────────────────────────────────────── -const REALTIME_DEBOUNCE_MS = 250; -const SUBSCRIBE_RETRY_INTERVAL_MS = 1000; -const MAX_SUBSCRIBE_RETRIES = 15; -// Match legacy gnh-auth-extension.js: 500 ms when jobs are active, 1 s when idle. const FALLBACK_POLLING_INTERVAL_ACTIVE_MS = 500; const FALLBACK_POLLING_INTERVAL_IDLE_MS = 1000; @@ -49,13 +32,7 @@ const FALLBACK_POLLING_INTERVAL_IDLE_MS = 1000; * @returns {Promise} */ export async function fetchJobs(options = {}) { - const params = new URLSearchParams(); - if (options.limit) params.set("limit", String(options.limit)); - if (options.range) params.set("range", options.range); - if (options.include) params.set("include", options.include); - const qs = params.toString(); - const res = await get(`/v1/jobs${qs ? `?${qs}` : ""}`); - return res?.jobs ?? []; + return fetchSharedJobs(options); } // ── Rendering ────────────────────────────────────────────────────────────────── @@ -165,141 +142,12 @@ export function renderErrorState(container, message = "Failed to load jobs.") { * @returns {() => void} unsubscribe / cleanup function */ export function subscribeToJobUpdates(orgId, onUpdate) { - let channel = null; - let retryCount = 0; - let retryTimer = null; - let fallbackTimer = null; - let lastUpdate = 0; - let debounceTimer = null; - let unsubscribed = false; - - function throttledUpdate() { - const now = Date.now(); - if (now - lastUpdate >= REALTIME_DEBOUNCE_MS) { - lastUpdate = now; - clearFallback(); - onUpdate(); - return; - } - if (!debounceTimer) { - debounceTimer = setTimeout(() => { - debounceTimer = null; - if (unsubscribed) return; - lastUpdate = Date.now(); - clearFallback(); - onUpdate(); - }, REALTIME_DEBOUNCE_MS); - } - } - - // Adaptive interval: 500 ms while jobs are active, 1 s when idle. - // Matches the legacy gnh-auth-extension.js dual-interval behaviour. - function getFallbackInterval() { - return window.dataBinder?.hasRealtimeActiveJobs - ? FALLBACK_POLLING_INTERVAL_ACTIVE_MS - : FALLBACK_POLLING_INTERVAL_IDLE_MS; - } - - let fallbackIntervalMs = null; - - function startFallback() { - const nextMs = getFallbackInterval(); - if (fallbackTimer && fallbackIntervalMs === nextMs) return; - if (fallbackTimer) { - clearInterval(fallbackTimer); - } - fallbackIntervalMs = nextMs; - fallbackTimer = setInterval(onUpdate, fallbackIntervalMs); - } - - function clearFallback() { - if (fallbackTimer) { - clearInterval(fallbackTimer); - fallbackTimer = null; - fallbackIntervalMs = null; - } - } - - function cleanup() { - unsubscribed = true; - if (retryTimer) { - clearTimeout(retryTimer); - retryTimer = null; - } - if (debounceTimer) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - clearFallback(); - if (channel && window.supabase) { - window.supabase.removeChannel(channel).catch(() => {}); - channel = null; - } - } - - function subscribe() { - if (unsubscribed) return; - if (!orgId || !window.supabase?.channel) { - if (retryCount < MAX_SUBSCRIBE_RETRIES) { - retryCount++; - retryTimer = setTimeout(subscribe, SUBSCRIBE_RETRY_INTERVAL_MS); - } else { - startFallback(); - } - return; - } - - retryCount = 0; - - try { - channel = window.supabase - .channel(`hover-jobs:${orgId}`) - .on( - "postgres_changes", - { - event: "INSERT", - schema: "public", - table: "jobs", - filter: `organisation_id=eq.${orgId}`, - }, - throttledUpdate - ) - .on( - "postgres_changes", - { - event: "UPDATE", - schema: "public", - table: "jobs", - filter: `organisation_id=eq.${orgId}`, - }, - throttledUpdate - ) - .on( - "postgres_changes", - { - event: "DELETE", - schema: "public", - table: "jobs", - filter: `organisation_id=eq.${orgId}`, - }, - throttledUpdate - ) - .subscribe((status, err) => { - if ( - (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || err) && - !unsubscribed - ) { - startFallback(); - } - }); - - // Start fallback immediately; clearFallback() stops it on first real event - startFallback(); - } catch { - startFallback(); - } - } - - subscribe(); - return cleanup; + return subscribeToSharedJobUpdates({ + orgId, + onUpdate, + getFallbackInterval: () => + window.dataBinder?.hasRealtimeActiveJobs + ? FALLBACK_POLLING_INTERVAL_ACTIVE_MS + : FALLBACK_POLLING_INTERVAL_IDLE_MS, + }); } diff --git a/webflow-designer-extension-cli/README.md b/webflow-designer-extension-cli/README.md index b249cc6c0..d4e9bf114 100644 --- a/webflow-designer-extension-cli/README.md +++ b/webflow-designer-extension-cli/README.md @@ -51,7 +51,9 @@ Expected behaviour: ## Popup auth bridge - Extension opens `GET /extension-auth.html` on GNH app domain. -- Popup reuses shared auth modal (`/js/auth.js`) for sign in/sign up. -- On success, popup posts `{ source: "gnh-extension-auth", accessToken }` back - to extension with origin and state checks. -- Popup only accepts trusted target origins (`*.webflow-ext.com` or localhost). +- Popup page is bootstrapped by `web/static/app/pages/webflow-login.js`, which + still reuses the shared auth modal and OAuth helpers from `/js/auth.js`. +- On success, popup posts + `{ source: "gnh-extension-auth", state, extensionState, accessToken }` back + to the extension. +- The extension validates popup origin and state before accepting the token. diff --git a/webflow-designer-extension-cli/public/lib/bridge.js b/webflow-designer-extension-cli/public/lib/bridge.js index e6be51208..7988a60ec 100644 --- a/webflow-designer-extension-cli/public/lib/bridge.js +++ b/webflow-designer-extension-cli/public/lib/bridge.js @@ -11,12 +11,14 @@ import * as apiClient from "/app/lib/api-client.js"; import * as formatters from "/app/lib/formatters.js"; import * as integrationHttp from "/app/lib/integration-http.js"; +import * as siteJobs from "/app/lib/site-jobs.js"; // Expose shared modules for index.js consumption window.HoverLib = { api: apiClient, fmt: formatters, http: integrationHttp, + jobs: siteJobs, }; // Signal that shared libs are ready diff --git a/webflow-designer-extension-cli/scripts/sync-shared.js b/webflow-designer-extension-cli/scripts/sync-shared.js index f248fdfa8..c1b1ba612 100644 --- a/webflow-designer-extension-cli/scripts/sync-shared.js +++ b/webflow-designer-extension-cli/scripts/sync-shared.js @@ -31,6 +31,7 @@ const LIB_MODULES = [ "lib/auth-session.js", "lib/formatters.js", "lib/integration-http.js", + "lib/site-jobs.js", "lib/domain-search.js", "lib/invite-flow.js", ]; diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 146c37aff..846a91dc8 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -38,12 +38,7 @@ const AUTH_POPUP_NAME = "bbbExtensionAuth"; const SCHEDULE_PLACEHOLDER = "off"; const SCHEDULE_OPTIONS = ["off", "6", "12", "24", "48"] as const; const JOB_POLLING_INTERVAL_MS = 6000; - -// Realtime subscription constants (mirrors dashboard pattern) -const REALTIME_DEBOUNCE_MS = 250; -const SUBSCRIBE_RETRY_INTERVAL_MS = 1000; const FALLBACK_POLLING_INTERVAL_MS = 1000; -const MAX_SUBSCRIBE_RETRIES = 15; const APP_ROUTES = { dashboard: "/dashboard", @@ -138,6 +133,39 @@ declare const HoverLib: { context?: string ) => Promise; }; + jobs: { + fetchJobs: (options?: { + limit?: number; + range?: string; + include?: string; + }) => Promise; + normaliseDomain: (input: string) => string; + filterJobsByDomains: ( + jobs: unknown[], + options?: { siteDomain?: string | null; siteDomainCandidates?: string[] } + ) => unknown[]; + pickLatestJobByDomains: ( + jobs: unknown[], + options?: { siteDomain?: string | null; siteDomainCandidates?: string[] } + ) => unknown | null; + buildCompletedJobsSignature: ( + jobs: unknown[], + options?: { siteDomain?: string | null; siteDomainCandidates?: string[] }, + isActiveJobStatus?: (status: string) => boolean + ) => string; + buildChartJobsSignature: ( + jobs: unknown[], + options?: { siteDomain?: string | null; siteDomainCandidates?: string[] } + ) => string; + subscribeToJobUpdates: (options: { + orgId: string; + onUpdate: () => void; + supabaseClient?: SupabaseClient | null; + channelName?: string; + getFallbackInterval?: () => number; + onSubscriptionIssue?: (status?: string, err?: Error) => void; + }) => () => void; + }; }; type ScheduleOption = (typeof SCHEDULE_OPTIONS)[number] | ""; @@ -217,10 +245,6 @@ type JobExportPayload = { tasks?: Record[]; }; -type JobListResponse = { - jobs: JobItem[]; -}; - type Scheduler = { id: string; domain: string; @@ -486,14 +510,8 @@ let lastChartJobsSignature = ""; // Supabase realtime state let supabaseClient: SupabaseClient | null = null; -let jobsChannel: RealtimeChannel | null = null; -let subscribeRetryCount = 0; -let subscribeRetryTimeoutId: number | null = null; -let fallbackPollingIntervalId: number | null = null; -let lastRealtimeRefresh = 0; -let throttleTimeoutId: number | null = null; let isRealtimeRefreshing = false; -let cleanupHandlerRegistered = false; +let jobsSubscriptionCleanup: (() => void) | null = null; function getStoredBaseUrl(): string { const storedBaseUrl = localStorage.getItem(API_BASE_STORAGE_KEY); @@ -639,15 +657,7 @@ function setText(node: Element | null, value: string): void { } function normalizeDomain(input: string): string { - const trimmed = input - .trim() - .toLowerCase() - .replace(/^https?:\/\//, "") - .replace(/^www\./, ""); - if (!trimmed) { - return ""; - } - return trimmed.split("/")[0] || trimmed; + return HoverLib.jobs.normaliseDomain(input); } function normalizeJobStatus(status: string): string { @@ -661,38 +671,28 @@ function isActiveJobStatus(status: string): boolean { function pickLatestJobForCurrentSite( jobs: JobItem[] | undefined ): JobItem | null { - const candidates = getSiteDomainCandidates(); - return ( - jobs?.find((job) => { - const jobDomain = normalizeDomain(job.domains?.name || ""); - return !candidates.length || candidates.includes(jobDomain); - }) || null - ); + return (HoverLib.jobs.pickLatestJobByDomains(jobs || [], { + siteDomain: state.siteDomain, + siteDomainCandidates: state.siteDomainCandidates, + }) || null) as JobItem | null; } function buildCompletedJobsSignature(jobs: JobItem[] | undefined): string { - const completed = filterSiteJobs(jobs || []) - .filter((job) => !isActiveJobStatus(job.status)) - .slice(0, 6); - - return completed - .map( - (job) => - `${job.id}:${job.status}:${job.total_tasks}:${job.completed_tasks}:${job.failed_tasks}:${job.skipped_tasks}:${job.completed_at || ""}` - ) - .join("|"); + return HoverLib.jobs.buildCompletedJobsSignature( + jobs || [], + { + siteDomain: state.siteDomain, + siteDomainCandidates: state.siteDomainCandidates, + }, + isActiveJobStatus + ); } function buildChartJobsSignature(jobs: JobItem[] | undefined): string { - const chartJobs = filterSiteJobs(jobs || []) - .filter((job) => normalizeJobStatus(job.status) === "completed") - .slice(0, 12); - return chartJobs - .map( - (job) => - `${job.id}:${job.status}:${job.failed_tasks}:${job.skipped_tasks}:${job.completed_at || ""}:${job.total_tasks}` - ) - .join("|"); + return HoverLib.jobs.buildChartJobsSignature(jobs || [], { + siteDomain: state.siteDomain, + siteDomainCandidates: state.siteDomainCandidates, + }); } function stopJobStatusPolling(): void { @@ -703,10 +703,9 @@ function stopJobStatusPolling(): void { } function startJobStatusPolling(): void { - // When realtime is active, the realtime subscription + fallback polling - // handle all refreshes. Only start the legacy 6s poller if we have no - // realtime channel (e.g. Supabase config unavailable). - if (jobsChannel || fallbackPollingIntervalId) { + // When realtime is active, the shared subscription also owns fallback polling. + // Only start the legacy 6 s poller if we have no shared subscription. + if (jobsSubscriptionCleanup) { return; } @@ -732,7 +731,6 @@ function startJobStatusPolling(): void { async function realtimeRefresh(): Promise { if (isRealtimeRefreshing) return; isRealtimeRefreshing = true; - lastRealtimeRefresh = Date.now(); try { // Refresh both job state and usage stats, matching the dashboard pattern. @@ -755,155 +753,44 @@ async function refreshUsage(): Promise { } } -function throttledRealtimeRefresh(): void { - // Receiving a real event proves realtime works — stop fallback polling. - clearFallbackPolling(); - - const now = Date.now(); - const timeSinceLastRefresh = now - lastRealtimeRefresh; - - if (timeSinceLastRefresh >= REALTIME_DEBOUNCE_MS && !isRealtimeRefreshing) { - void realtimeRefresh(); - return; - } - - // Schedule a refresh when the throttle window expires. - if (!throttleTimeoutId && !isRealtimeRefreshing) { - const delay = REALTIME_DEBOUNCE_MS - timeSinceLastRefresh; - throttleTimeoutId = window.setTimeout( - () => { - throttleTimeoutId = null; - if (!isRealtimeRefreshing) { - void realtimeRefresh(); - } - }, - Math.max(delay, 100) - ); - } -} - -function startFallbackPolling(): void { - if (fallbackPollingIntervalId) return; - - fallbackPollingIntervalId = window.setInterval(() => { - void realtimeRefresh(); - }, FALLBACK_POLLING_INTERVAL_MS); -} - -function clearFallbackPolling(): void { - if (fallbackPollingIntervalId) { - window.clearInterval(fallbackPollingIntervalId); - fallbackPollingIntervalId = null; - } -} - function cleanupRealtimeSubscription(): void { - if (subscribeRetryTimeoutId) { - window.clearTimeout(subscribeRetryTimeoutId); - subscribeRetryTimeoutId = null; - } - - if (throttleTimeoutId) { - window.clearTimeout(throttleTimeoutId); - throttleTimeoutId = null; - } - - clearFallbackPolling(); - - if (jobsChannel && supabaseClient) { - void supabaseClient.removeChannel(jobsChannel); - jobsChannel = null; + if (jobsSubscriptionCleanup) { + jobsSubscriptionCleanup(); + jobsSubscriptionCleanup = null; } - - subscribeRetryCount = 0; - cleanupHandlerRegistered = false; } async function subscribeToJobUpdates(): Promise { const orgId = state.activeOrganisationId; if (!orgId || !supabaseClient) { - if (subscribeRetryCount < MAX_SUBSCRIBE_RETRIES) { - subscribeRetryCount++; - subscribeRetryTimeoutId = window.setTimeout( - () => void subscribeToJobUpdates(), - SUBSCRIBE_RETRY_INTERVAL_MS - ); - } else { - console.warn("[Realtime] Max retries reached, enabling fallback polling"); - startFallbackPolling(); - } return; } - // Reset retry state on success. - subscribeRetryCount = 0; - subscribeRetryTimeoutId = null; - - // Clean up existing subscription if any. - if (jobsChannel && supabaseClient) { - try { - await supabaseClient.removeChannel(jobsChannel); - } catch (_e) { - // Ignore removal errors. - } - jobsChannel = null; - } - - // Register cleanup handler once. - if (!cleanupHandlerRegistered) { - window.addEventListener("beforeunload", cleanupRealtimeSubscription); - cleanupHandlerRegistered = true; - } - - try { - const channel = supabaseClient - .channel(`jobs-changes:${orgId}`) - .on( - "postgres_changes", - { - event: "UPDATE", - schema: "public", - table: "jobs", - filter: `organisation_id=eq.${orgId}`, - }, - () => throttledRealtimeRefresh() - ) - .on( - "postgres_changes", - { - event: "INSERT", - schema: "public", - table: "jobs", - filter: `organisation_id=eq.${orgId}`, - }, - () => throttledRealtimeRefresh() - ) - .on( - "postgres_changes", - { - event: "DELETE", - schema: "public", - table: "jobs", - filter: `organisation_id=eq.${orgId}`, - }, - () => throttledRealtimeRefresh() - ) - .subscribe((status, err) => { - if (status === "CHANNEL_ERROR" || status === "TIMED_OUT" || err) { - console.warn( - "[Realtime] Connection issue, fallback polling will continue" - ); - } - // Fallback polling stops only when we receive an actual realtime event. - }); - - // Start fallback polling immediately — cleared when a real event arrives. - startFallbackPolling(); - jobsChannel = channel; - } catch (err) { - console.error("[Realtime] Failed to subscribe to jobs:", err); - startFallbackPolling(); - } + cleanupRealtimeSubscription(); + jobsSubscriptionCleanup = HoverLib.jobs.subscribeToJobUpdates({ + orgId, + onUpdate: () => { + void realtimeRefresh(); + }, + supabaseClient, + channelName: `jobs-changes:${orgId}`, + getFallbackInterval: () => FALLBACK_POLLING_INTERVAL_MS, + onSubscriptionIssue: (status, err) => { + if ( + status === "CHANNEL_ERROR" || + status === "TIMED_OUT" || + status === "SUBSCRIBE_FAILED" || + status === "MAX_RETRIES" || + err + ) { + console.warn( + "[Realtime] Connection issue, fallback polling will continue", + status, + err + ); + } + }, + }); } // --------------------------------------------------------------------------- @@ -916,22 +803,23 @@ async function refreshCurrentJob(): Promise { try { jobPollInFlight = true; - const response = (await HoverLib.api.get( - "/v1/jobs?limit=50&include=stats" - )) as JobListResponse; - const latest = pickLatestJobForCurrentSite(response.jobs); + const jobs = (await HoverLib.jobs.fetchJobs({ + limit: 50, + include: "stats", + })) as JobItem[]; + const latest = pickLatestJobForCurrentSite(jobs); state.currentJob = latest; renderJobState(state.currentJob); - const completedSignature = buildCompletedJobsSignature(response.jobs); + const completedSignature = buildCompletedJobsSignature(jobs); if (completedSignature !== lastCompletedJobsSignature) { - renderRecentResults(response.jobs); + renderRecentResults(jobs); lastCompletedJobsSignature = completedSignature; } - const chartSignature = buildChartJobsSignature(response.jobs); + const chartSignature = buildChartJobsSignature(jobs); if (chartSignature !== lastChartJobsSignature) { - renderMiniChart(response.jobs); + renderMiniChart(jobs); lastChartJobsSignature = chartSignature; } @@ -1206,11 +1094,10 @@ function getIssueCounts(job: JobItem): { // --------------------------------------------------------------------------- function filterSiteJobs(jobs: JobItem[]): JobItem[] { - const candidates = getSiteDomainCandidates(); - return jobs.filter((job) => { - const jobDomain = normalizeDomain(job.domains?.name || ""); - return !candidates.length || candidates.includes(jobDomain); - }); + return HoverLib.jobs.filterJobsByDomains(jobs, { + siteDomain: state.siteDomain, + siteDomainCandidates: state.siteDomainCandidates, + }) as JobItem[]; } function renderRecentResults(jobs: JobItem[]): void { @@ -1637,18 +1524,19 @@ async function loadLatestJob(): Promise { } try { - const response = (await HoverLib.api.get( - "/v1/jobs?limit=50&include=stats" - )) as JobListResponse; + const jobs = (await HoverLib.jobs.fetchJobs({ + limit: 50, + include: "stats", + })) as JobItem[]; - const latest = pickLatestJobForCurrentSite(response.jobs); + const latest = pickLatestJobForCurrentSite(jobs); state.currentJob = latest; renderJobState(state.currentJob); - renderRecentResults(response.jobs); - renderMiniChart(response.jobs); - lastCompletedJobsSignature = buildCompletedJobsSignature(response.jobs); - lastChartJobsSignature = buildChartJobsSignature(response.jobs); + renderRecentResults(jobs); + renderMiniChart(jobs); + lastCompletedJobsSignature = buildCompletedJobsSignature(jobs); + lastChartJobsSignature = buildChartJobsSignature(jobs); startJobStatusPolling(); } catch (error) { state.currentJob = null; From 3c64a5e3e93f2735cb7742b27286093c35ef3010 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:33:11 +1000 Subject: [PATCH 02/41] Format extension README --- webflow-designer-extension-cli/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webflow-designer-extension-cli/README.md b/webflow-designer-extension-cli/README.md index d4e9bf114..6238cf40b 100644 --- a/webflow-designer-extension-cli/README.md +++ b/webflow-designer-extension-cli/README.md @@ -54,6 +54,6 @@ Expected behaviour: - Popup page is bootstrapped by `web/static/app/pages/webflow-login.js`, which still reuses the shared auth modal and OAuth helpers from `/js/auth.js`. - On success, popup posts - `{ source: "gnh-extension-auth", state, extensionState, accessToken }` back - to the extension. + `{ source: "gnh-extension-auth", state, extensionState, accessToken }` back to + the extension. - The extension validates popup origin and state before accepting the token. From 5e45247b8f06e0dd444f86d5cbeb07577a5e7543 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:46:23 +1000 Subject: [PATCH 03/41] Fix formatting and lint issues --- .github/workflows/fly-deploy.yml | 21 +++++++++----- CHANGELOG.md | 4 +-- .../webflow-extension-reuse-follow-up.md | 28 +++++++++---------- web/static/app/lib/site-jobs.js | 11 ++++++-- webflow-designer-extension-cli/src/index.ts | 2 +- 5 files changed, 40 insertions(+), 26 deletions(-) diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml index 9c077f9c7..0b6b13d8a 100644 --- a/.github/workflows/fly-deploy.yml +++ b/.github/workflows/fly-deploy.yml @@ -40,16 +40,23 @@ jobs: FLY_API_TOKEN: op://Good Native/hover-fly/FLY_API_TOKEN # Runtime secrets — hover-supabase DATABASE_URL: op://Good Native/hover-supabase/DATABASE_URL - DATABASE_DIRECT_URL: op://Good Native/hover-supabase/DATABASE_DIRECT_URL - SUPABASE_JWT_SECRET: op://Good Native/hover-supabase/SUPABASE_JWT_SECRET - SUPABASE_SERVICE_ROLE_KEY: op://Good Native/hover-supabase/SUPABASE_SERVICE_ROLE_KEY + DATABASE_DIRECT_URL: + op://Good Native/hover-supabase/DATABASE_DIRECT_URL + SUPABASE_JWT_SECRET: + op://Good Native/hover-supabase/SUPABASE_JWT_SECRET + SUPABASE_SERVICE_ROLE_KEY: + op://Good Native/hover-supabase/SUPABASE_SERVICE_ROLE_KEY # Runtime secrets — hover-runtime SENTRY_DSN: op://Good Native/hover-runtime/SENTRY_DSN LOOPS_API_KEY: op://Good Native/hover-runtime/LOOPS_API_KEY - SLACK_CLIENT_SECRET: op://Good Native/hover-runtime/SLACK_CLIENT_SECRET - WEBFLOW_CLIENT_SECRET: op://Good Native/hover-runtime/WEBFLOW_CLIENT_SECRET - GOOGLE_CLIENT_SECRET: op://Good Native/hover-runtime/GOOGLE_CLIENT_SECRET - OTEL_EXPORTER_OTLP_HEADERS: op://Good Native/hover-runtime/OTEL_EXPORTER_OTLP_HEADERS + SLACK_CLIENT_SECRET: + op://Good Native/hover-runtime/SLACK_CLIENT_SECRET + WEBFLOW_CLIENT_SECRET: + op://Good Native/hover-runtime/WEBFLOW_CLIENT_SECRET + GOOGLE_CLIENT_SECRET: + op://Good Native/hover-runtime/GOOGLE_CLIENT_SECRET + OTEL_EXPORTER_OTLP_HEADERS: + op://Good Native/hover-runtime/OTEL_EXPORTER_OTLP_HEADERS - uses: superfly/flyctl-actions/setup-flyctl@63da3ecc5e2793b98a3f2519b3d75d4f4c11cec2 # pinned diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfafc30e..9a0cce346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,8 @@ On merge, CI will: ### Changed - shared Webflow extension job fetching, site scoping, and realtime fallback - logic into `web/static/app/lib/site-jobs.js`, reducing duplication between - the app layer and extension bridge runtime + logic into `web/static/app/lib/site-jobs.js`, reducing duplication between the + app layer and extension bridge runtime - aligned extension reuse docs with the current ES modules migration state and the remaining hybrid auth popup architecture diff --git a/docs/plans/webflow-extension-reuse-follow-up.md b/docs/plans/webflow-extension-reuse-follow-up.md index aaf422db0..88738de5f 100644 --- a/docs/plans/webflow-extension-reuse-follow-up.md +++ b/docs/plans/webflow-extension-reuse-follow-up.md @@ -6,14 +6,14 @@ consolidation and shared frontend reuse ## Current state The broader ES modules migration is complete and archived in the changelog. The -main app now has an established shared frontend layer in `web/static/app/`, -with `/dashboard`, settings, and job details already migrated to the module +main app now has an established shared frontend layer in `web/static/app/`, with +`/dashboard`, settings, and job details already migrated to the module architecture. Both branch checkpoints associated with that migration, `feat/es-modules-extension-sync` and `feat/es-modules-phase-0`, are already -contained in `main`. They should be treated as historical delivery branches, -not pending work. +contained in `main`. They should be treated as historical delivery branches, not +pending work. The Webflow Designer extension has already adopted part of that shared layer: @@ -23,8 +23,8 @@ The Webflow Designer extension has already adopted part of that shared layer: - a bridge/import-map pattern so extension code can consume shared modules in a cross-origin runtime -That means the transition is partly complete. Shared primitives exist and are -in use, but the extension has not yet reached the same level of modular +That means the transition is partly complete. Shared primitives exist and are in +use, but the extension has not yet reached the same level of modular consolidation as the main app. ## What is already complete @@ -34,8 +34,8 @@ consolidation as the main app. - `lib/` for reusable logic - `components/` for shared UI primitives - `pages/` for page orchestration -- the extension reuses shared primitives and API helpers through the - bridge/sync approach +- the extension reuses shared primitives and API helpers through the bridge/sync + approach - design tokens exist in the app layer and mirror the extension theme - the completed migration is already documented in `CHANGELOG.md` @@ -56,8 +56,8 @@ consolidation as the main app. ## Recommended next phase -This follow-up should be treated as a JavaScript-first reuse pass, not a full -UI unification project. +This follow-up should be treated as a JavaScript-first reuse pass, not a full UI +unification project. - Extract surface-agnostic extension logic from `webflow-designer-extension-cli/src/index.ts` into shared `/app` modules. @@ -68,8 +68,8 @@ UI unification project. - Keep the bridge/import-map approach for cross-origin extension use. - Continue sharing reusable logic and UI primitives first, before attempting to merge the full extension shell layout into the main app. -- Replace the remaining legacy auth dependency in `/extension-auth` so the - popup flow no longer relies on `/js/auth.js`. +- Replace the remaining legacy auth dependency in `/extension-auth` so the popup + flow no longer relies on `/js/auth.js`. - Update architecture and planning docs so they reflect the current state accurately. @@ -92,5 +92,5 @@ UI unification project. - The new plan sets a JS-first extension consolidation direction without implying that the extension already shares all page-level logic with `/dashboard`. -- The new plan supersedes the old branch-era planning context without - recreating archived migration history. +- The new plan supersedes the old branch-era planning context without recreating + archived migration history. diff --git a/web/static/app/lib/site-jobs.js b/web/static/app/lib/site-jobs.js index 720f37961..71938c0fa 100644 --- a/web/static/app/lib/site-jobs.js +++ b/web/static/app/lib/site-jobs.js @@ -119,7 +119,12 @@ export function buildCompletedJobsSignature( */ export function buildChartJobsSignature(jobs = [], options = {}) { const chartJobs = filterJobsByDomains(jobs, options) - .filter((job) => String(job.status || "").trim().toLowerCase() === "completed") + .filter( + (job) => + String(job.status || "") + .trim() + .toLowerCase() === "completed" + ) .slice(0, 12); return chartJobs @@ -301,7 +306,9 @@ export function subscribeToJobUpdates(options) { } function defaultIsActiveJobStatus(status) { - const normalised = String(status || "").trim().toLowerCase(); + const normalised = String(status || "") + .trim() + .toLowerCase(); return [ "pending", "queued", diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 846a91dc8..9f4fcb8ce 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -760,7 +760,7 @@ function cleanupRealtimeSubscription(): void { } } -async function subscribeToJobUpdates(): Promise { +function subscribeToJobUpdates(): void { const orgId = state.activeOrganisationId; if (!orgId || !supabaseClient) { return; From 067a9e467d32eda1d81b0f23bce4f2af0df26037 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Sun, 5 Apr 2026 20:51:45 +1000 Subject: [PATCH 04/41] Fix shared jobs review issues --- web/static/app/lib/site-jobs.js | 2 +- .../scripts/sync-shared.js | 26 ++++++++++++++----- webflow-designer-extension-cli/src/index.ts | 3 +++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/web/static/app/lib/site-jobs.js b/web/static/app/lib/site-jobs.js index 71938c0fa..73ba00601 100644 --- a/web/static/app/lib/site-jobs.js +++ b/web/static/app/lib/site-jobs.js @@ -130,7 +130,7 @@ export function buildChartJobsSignature(jobs = [], options = {}) { return chartJobs .map( (job) => - `${job.id || ""}:${job.status || ""}:${job.failed_tasks || 0}:${job.skipped_tasks || 0}:${job.completed_at || ""}:${job.total_tasks || 0}` + `${job.id || ""}:${job.status || ""}:${job.failed_tasks || 0}:${job.skipped_tasks || 0}:${job.completed_at || ""}:${job.total_tasks || 0}:${job.stats?.total_broken_links || 0}:${job.stats?.slow_page_buckets?.over_10s || 0}:${job.stats?.slow_page_buckets?.["5_to_10s"] || 0}:${job.stats?.slow_page_buckets?.["3_to_5s"] || 0}` ) .join("|"); } diff --git a/webflow-designer-extension-cli/scripts/sync-shared.js b/webflow-designer-extension-cli/scripts/sync-shared.js index c1b1ba612..3ad53fc16 100644 --- a/webflow-designer-extension-cli/scripts/sync-shared.js +++ b/webflow-designer-extension-cli/scripts/sync-shared.js @@ -25,13 +25,15 @@ const COMPONENTS = [ "components/hover-tabs.js", ]; -// Lib modules — shared logic loaded via bridge.js -const LIB_MODULES = [ +// Lib modules required by bridge.js or other shared extension runtime paths. +const REQUIRED_LIB_MODULES = ["lib/site-jobs.js"]; + +// Lib modules — shared logic loaded via bridge.js or available for future reuse. +const OPTIONAL_LIB_MODULES = [ "lib/api-client.js", "lib/auth-session.js", "lib/formatters.js", "lib/integration-http.js", - "lib/site-jobs.js", "lib/domain-search.js", "lib/invite-flow.js", ]; @@ -63,14 +65,26 @@ for (const file of COMPONENTS) { } } -// Sync lib modules to public/lib/ -for (const file of LIB_MODULES) { +// Sync required lib modules to public/lib/ +for (const file of REQUIRED_LIB_MODULES) { const src = path.join(APP_ROOT, file); const dest = path.join(PUBLIC, file); if (fs.existsSync(src)) { syncFile(src, dest); } else { - console.warn(` WARN: ${file} not found, skipping`); + console.error(` ERROR: required shared module missing: ${file}`); + process.exit(1); + } +} + +// Sync optional lib modules to public/lib/ +for (const file of OPTIONAL_LIB_MODULES) { + const src = path.join(APP_ROOT, file); + const dest = path.join(PUBLIC, file); + if (fs.existsSync(src)) { + syncFile(src, dest); + } else { + console.warn(` WARN: optional shared module missing: ${file}, skipping`); } } diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 9f4fcb8ce..dee1a62d9 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -766,6 +766,9 @@ function subscribeToJobUpdates(): void { return; } + // The shared subscription owns realtime fallback polling, so the older + // active-job interval must stop before we hand control across. + stopJobStatusPolling(); cleanupRealtimeSubscription(); jobsSubscriptionCleanup = HoverLib.jobs.subscribeToJobUpdates({ orgId, From 8bf94fa79794e92d5cd85e3497dbb3c7ecf8f0fb Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:12:02 +1000 Subject: [PATCH 05/41] Share Webflow site logic --- .../app/lib/settings/integrations/webflow.js | 154 +++------------- web/static/app/lib/webflow-sites.js | 173 ++++++++++++++++++ .../public/lib/bridge.js | 2 + .../scripts/sync-shared.js | 2 +- webflow-designer-extension-cli/src/index.ts | 98 ++++------ 5 files changed, 236 insertions(+), 193 deletions(-) create mode 100644 web/static/app/lib/webflow-sites.js diff --git a/web/static/app/lib/settings/integrations/webflow.js b/web/static/app/lib/settings/integrations/webflow.js index bdc8be9ea..956c3af25 100644 --- a/web/static/app/lib/settings/integrations/webflow.js +++ b/web/static/app/lib/settings/integrations/webflow.js @@ -5,12 +5,14 @@ * Flow: Connect -> OAuth -> Return to settings -> Configure sites */ -import { post } from "/app/lib/api-client.js"; -import { getAccessToken } from "/app/lib/auth-session.js"; import { - fetchWithTimeout, - normaliseIntegrationError, -} from "/app/lib/integration-http.js"; + disconnectWebflowConnection, + listWebflowConnections, + listWebflowSites, + setWebflowSiteAutoPublish, + setWebflowSiteSchedule, + startWebflowConnection, +} from "/app/lib/webflow-sites.js"; import { showToast as _showToast } from "/app/components/hover-toast.js"; import { formatRelativeDate } from "/app/lib/settings/integrations/shared.js"; @@ -90,23 +92,7 @@ function handleWebflowAction(action, element) { export async function loadWebflowConnections() { try { - const token = await getAccessToken(); - if (!token) return; - - const response = await fetchWithTimeout( - "/v1/integrations/webflow", - { headers: { Authorization: `Bearer ${token}` } }, - { module: "webflow", action: "list" } - ); - if (!response.ok) { - const text = await response.text(); - throw normaliseIntegrationError(response, text, { - module: "webflow", - action: "list", - }); - } - const json = await response.json(); - const connections = json?.data || json || []; + const connections = await listWebflowConnections(); const connectionsList = document.getElementById("webflowConnectionsList"); const emptyState = document.getElementById("webflowEmptyState"); @@ -171,7 +157,7 @@ export async function loadWebflowConnections() { async function connectWebflow() { try { - const response = await post("/v1/integrations/webflow"); + const response = await startWebflowConnection(); if (response?.auth_url) { window.location.href = response.auth_url; } else { @@ -192,27 +178,7 @@ async function disconnectWebflow(connectionId) { return; try { - const token = await getAccessToken(); - if (!token) { - toast("error", "Not authenticated. Please sign in."); - return; - } - - const response = await fetchWithTimeout( - `/v1/integrations/webflow/${encodeURIComponent(connectionId)}`, - { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, - { module: "webflow", action: "disconnect", connectionId } - ); - - if (!response.ok) { - const text = await response.text(); - throw normaliseIntegrationError(response, text, { - module: "webflow", - action: "disconnect", - connectionId, - }); - } - + await disconnectWebflowConnection(connectionId); toast("success", "Webflow disconnected"); loadWebflowConnections(); } catch (error) { @@ -233,31 +199,7 @@ export async function loadWebflowSites(connectionId, page = 1) { if (emptyEl) emptyEl.style.display = "none"; try { - const token = await getAccessToken(); - if (!token) { - toast("error", "Not authenticated. Please sign in."); - sitesState.loading = false; - if (loadingEl) loadingEl.style.display = "none"; - return; - } - - const response = await fetchWithTimeout( - `/v1/integrations/webflow/${encodeURIComponent(connectionId)}/sites`, - { headers: { Authorization: `Bearer ${token}` } }, - { module: "webflow", action: "list-sites", connectionId } - ); - - if (!response.ok) { - const text = await response.text(); - throw normaliseIntegrationError(response, text, { - module: "webflow", - action: "list-sites", - connectionId, - }); - } - - const json = await response.json(); - const data = json?.data ?? { sites: [] }; + const data = await listWebflowSites(connectionId, { page }); const sites = Array.isArray(data.sites) ? data.sites : []; sitesState.connectionId = connectionId; @@ -361,38 +303,10 @@ async function handleScheduleChange(event) { select.disabled = true; try { - const token = await getAccessToken(); - if (!token) { - toast("error", "Not authenticated. Please sign in."); - select.disabled = false; - return; - } - - const response = await fetchWithTimeout( - `/v1/integrations/webflow/sites/${encodeURIComponent(siteId)}/schedule`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ - connection_id: connectionId, - schedule_interval_hours: interval, - }), - }, - { module: "webflow", action: "update-schedule", siteId, connectionId } - ); - - if (!response.ok) { - const text = await response.text(); - throw normaliseIntegrationError(response, text, { - module: "webflow", - action: "update-schedule", - siteId, - connectionId, - }); - } + await setWebflowSiteSchedule(siteId, { + connectionId, + scheduleIntervalHours: interval, + }); const site = sitesState.sites.find((s) => s.webflow_site_id === siteId); if (site) site.schedule_interval_hours = interval; @@ -400,7 +314,10 @@ async function handleScheduleChange(event) { if (interval) { let autoPublishEnabled = false; try { - await setAutoPublish(siteId, connectionId, true); + await setWebflowSiteAutoPublish(siteId, { + connectionId, + enabled: true, + }); autoPublishEnabled = true; } catch (err) { console.error("Failed to auto-enable run-on-publish:", err); @@ -429,34 +346,6 @@ async function handleScheduleChange(event) { } } -async function setAutoPublish(siteId, connectionId, enabled) { - const token = await getAccessToken(); - if (!token) throw new Error("Not authenticated. Please sign in."); - - const response = await fetchWithTimeout( - `/v1/integrations/webflow/sites/${encodeURIComponent(siteId)}/auto-publish`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ connection_id: connectionId, enabled }), - }, - { module: "webflow", action: "set-auto-publish", siteId, connectionId } - ); - - if (!response.ok) { - const text = await response.text(); - throw normaliseIntegrationError(response, text, { - module: "webflow", - action: "set-auto-publish", - siteId, - connectionId, - }); - } -} - async function handleAutoPublishToggle(event) { const toggle = event.target; const siteId = toggle.dataset.siteId; @@ -475,7 +364,10 @@ async function handleAutoPublishToggle(event) { } try { - await setAutoPublish(siteId, connectionId, enabled); + await setWebflowSiteAutoPublish(siteId, { + connectionId, + enabled, + }); const site = sitesState.sites.find((s) => s.webflow_site_id === siteId); if (site) site.auto_publish_enabled = enabled; diff --git a/web/static/app/lib/webflow-sites.js b/web/static/app/lib/webflow-sites.js new file mode 100644 index 000000000..5eaf783a0 --- /dev/null +++ b/web/static/app/lib/webflow-sites.js @@ -0,0 +1,173 @@ +/** + * lib/webflow-sites.js — shared Webflow connection and site helpers + * + * Shared between app pages and the Webflow Designer extension. + * Rendering and popup handling stay surface-specific; this module only owns + * Webflow API reads/writes and current-site matching. + */ + +import * as apiClient from "/app/lib/api-client.js"; +import { + getSiteDomainCandidates, + normaliseDomain, +} from "/app/lib/site-jobs.js"; + +function resolveApi(api) { + return api || apiClient; +} + +/** + * Start the Webflow OAuth flow. + * @param {{ api?: typeof apiClient }} [options] + * @returns {Promise<{ auth_url?: string }>} + */ +export async function startWebflowConnection(options = {}) { + const api = resolveApi(options.api); + const response = await api.post("/v1/integrations/webflow"); + return response && typeof response === "object" ? response : {}; +} + +/** + * @param {{ api?: typeof apiClient }} [options] + * @returns {Promise[]>} + */ +export async function listWebflowConnections(options = {}) { + const api = resolveApi(options.api); + const response = await api.get("/v1/integrations/webflow"); + return Array.isArray(response) ? response : []; +} + +/** + * @param {string} connectionId + * @param {{ api?: typeof apiClient, page?: number, limit?: number }} [options] + * @returns {Promise<{ sites: Record[], pagination: { has_next?: boolean } | null }>} + */ +export async function listWebflowSites(connectionId, options = {}) { + const api = resolveApi(options.api); + const page = Number.isFinite(options.page) ? options.page : 1; + const limit = Number.isFinite(options.limit) ? options.limit : 50; + const response = await api.get( + `/v1/integrations/webflow/${encodeURIComponent(connectionId)}/sites?page=${page}&limit=${limit}` + ); + + return { + sites: Array.isArray(response?.sites) ? response.sites : [], + pagination: + response?.pagination && typeof response.pagination === "object" + ? response.pagination + : null, + }; +} + +/** + * @param {string} connectionId + * @param {{ api?: typeof apiClient }} [options] + * @returns {Promise} + */ +export async function disconnectWebflowConnection(connectionId, options = {}) { + const api = resolveApi(options.api); + await api.del(`/v1/integrations/webflow/${encodeURIComponent(connectionId)}`); +} + +/** + * @param {string} siteId + * @param {{ + * connectionId: string, + * enabled: boolean, + * api?: typeof apiClient + * }} options + * @returns {Promise} + */ +export async function setWebflowSiteAutoPublish(siteId, options) { + const api = resolveApi(options?.api); + await api.put( + `/v1/integrations/webflow/sites/${encodeURIComponent(siteId)}/auto-publish`, + { + connection_id: options.connectionId, + enabled: options.enabled, + } + ); +} + +/** + * @param {string} siteId + * @param {{ + * connectionId: string, + * scheduleIntervalHours: number | null, + * api?: typeof apiClient + * }} options + * @returns {Promise} + */ +export async function setWebflowSiteSchedule(siteId, options) { + const api = resolveApi(options?.api); + await api.put( + `/v1/integrations/webflow/sites/${encodeURIComponent(siteId)}/schedule`, + { + connection_id: options.connectionId, + schedule_interval_hours: options.scheduleIntervalHours, + } + ); +} + +/** + * Find the Webflow site config that matches the current site domain(s). + * + * @param {{ + * siteDomain?: string | null, + * siteDomainCandidates?: string[], + * api?: typeof apiClient, + * connections?: Record[], + * limit?: number + * }} [options] + * @returns {Promise | null>} + */ +export async function findMatchingWebflowSite(options = {}) { + const candidates = getSiteDomainCandidates(options); + if (!candidates.length) { + return null; + } + + const connections = + options.connections || (await listWebflowConnections({ api: options.api })); + if (!Array.isArray(connections) || connections.length === 0) { + return null; + } + + const limit = Number.isFinite(options.limit) ? options.limit : 50; + + for (const connection of connections) { + if (!connection?.id) { + continue; + } + + let page = 1; + + while (true) { + const payload = await listWebflowSites(connection.id, { + api: options.api, + page, + limit, + }); + + const matchedSite = payload.sites.find((site) => { + const domain = normaliseDomain(site?.primary_domain || ""); + return candidates.includes(domain); + }); + + if (matchedSite) { + return { + ...matchedSite, + connection_id: connection.id, + }; + } + + if (!payload.pagination?.has_next) { + break; + } + + page += 1; + } + } + + return null; +} diff --git a/webflow-designer-extension-cli/public/lib/bridge.js b/webflow-designer-extension-cli/public/lib/bridge.js index 7988a60ec..8fedb910b 100644 --- a/webflow-designer-extension-cli/public/lib/bridge.js +++ b/webflow-designer-extension-cli/public/lib/bridge.js @@ -12,6 +12,7 @@ import * as apiClient from "/app/lib/api-client.js"; import * as formatters from "/app/lib/formatters.js"; import * as integrationHttp from "/app/lib/integration-http.js"; import * as siteJobs from "/app/lib/site-jobs.js"; +import * as webflowSites from "/app/lib/webflow-sites.js"; // Expose shared modules for index.js consumption window.HoverLib = { @@ -19,6 +20,7 @@ window.HoverLib = { fmt: formatters, http: integrationHttp, jobs: siteJobs, + webflow: webflowSites, }; // Signal that shared libs are ready diff --git a/webflow-designer-extension-cli/scripts/sync-shared.js b/webflow-designer-extension-cli/scripts/sync-shared.js index 3ad53fc16..95670b355 100644 --- a/webflow-designer-extension-cli/scripts/sync-shared.js +++ b/webflow-designer-extension-cli/scripts/sync-shared.js @@ -26,7 +26,7 @@ const COMPONENTS = [ ]; // Lib modules required by bridge.js or other shared extension runtime paths. -const REQUIRED_LIB_MODULES = ["lib/site-jobs.js"]; +const REQUIRED_LIB_MODULES = ["lib/site-jobs.js", "lib/webflow-sites.js"]; // Lib modules — shared logic loaded via bridge.js or available for future reuse. const OPTIONAL_LIB_MODULES = [ diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index dee1a62d9..7dda2e3ac 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -166,6 +166,25 @@ declare const HoverLib: { onSubscriptionIssue?: (status?: string, err?: Error) => void; }) => () => void; }; + webflow: { + startWebflowConnection: () => Promise<{ auth_url?: string }>; + listWebflowConnections: () => Promise; + findMatchingWebflowSite: (options: { + api?: typeof HoverLib.api; + connections?: unknown[]; + siteDomain?: string | null; + siteDomainCandidates?: string[]; + limit?: number; + }) => Promise; + setWebflowSiteAutoPublish: ( + siteId: string, + options: { + api?: typeof HoverLib.api; + connectionId: string; + enabled: boolean; + } + ) => Promise; + }; }; type ScheduleOption = (typeof SCHEDULE_OPTIONS)[number] | ""; @@ -274,13 +293,6 @@ type WebflowSiteSetting = { scheduler_id?: string; }; -type WebflowSitesResponse = { - sites: WebflowSiteSetting[]; - pagination?: { - has_next: boolean; - }; -}; - type AuthMessage = { source?: string; type?: string; @@ -626,18 +638,6 @@ function asSelect(element: Element | null): HTMLSelectElement | null { return element instanceof HTMLSelectElement ? element : null; } -function getSiteDomainCandidates(): string[] { - const normalised = new Set( - state.siteDomainCandidates - .map((candidate) => normalizeDomain(candidate)) - .filter(Boolean) - ); - if (state.siteDomain) { - normalised.add(state.siteDomain); - } - return [...normalised]; -} - function hide(el: HTMLElement | null): void { if (el) { el.classList.add("hidden"); @@ -1590,13 +1590,14 @@ async function loadCurrentSchedule(): Promise { async function findConnectedWebflowSite(): Promise { if (!state.token || !state.siteDomain) { + state.webflowConnected = false; + state.webflowAutoPublishEnabled = false; renderWebflowStatus(false); return null; } - const connections = (await HoverLib.api.get( - "/v1/integrations/webflow" - )) as WebflowConnection[]; + const connections = + (await HoverLib.webflow.listWebflowConnections()) as WebflowConnection[]; if (!connections || connections.length === 0) { state.webflowConnected = false; @@ -1607,41 +1608,12 @@ async function findConnectedWebflowSite(): Promise { state.webflowConnected = true; - const candidates = getSiteDomainCandidates(); - let matched: WebflowSiteSetting | null = null; - - for (const connection of connections) { - let page = 1; - - while (true) { - const sites = (await HoverLib.api.get( - `/v1/integrations/webflow/${connection.id}/sites?page=${page}&limit=50` - )) as WebflowSitesResponse; - - const candidate = sites.sites?.find((site) => { - const domain = normalizeDomain(site.primary_domain); - return candidates.includes(domain); - }); - - if (candidate) { - matched = { - ...candidate, - connection_id: connection.id, - }; - break; - } - - if (!sites.pagination?.has_next) { - break; - } - - page += 1; - } - - if (matched) { - break; - } - } + const matched = (await HoverLib.webflow.findMatchingWebflowSite({ + api: HoverLib.api, + connections, + siteDomain: state.siteDomain, + siteDomainCandidates: state.siteDomainCandidates, + })) as WebflowSiteSetting | null; if (matched) { state.webflowAutoPublishEnabled = Boolean(matched.auto_publish_enabled); @@ -1677,9 +1649,13 @@ async function setWebflowAutoPublish(enabled: boolean): Promise { }; try { - await HoverLib.api.put( - `/v1/integrations/webflow/sites/${siteSetting.webflow_site_id}/auto-publish`, - payload + await HoverLib.webflow.setWebflowSiteAutoPublish( + siteSetting.webflow_site_id, + { + api: HoverLib.api, + connectionId: payload.connection_id, + enabled: payload.enabled, + } ); } catch (error) { // Revert on failure. @@ -1893,7 +1869,7 @@ async function connectWebflow(): Promise { } } - const response = (await HoverLib.api.post("/v1/integrations/webflow")) as { + const response = (await HoverLib.webflow.startWebflowConnection()) as { auth_url: string; }; From 49861859fd8e2123cb8645b7309de8c24f836c57 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:27:03 +1000 Subject: [PATCH 06/41] Align extension auth popup --- web/static/app/pages/webflow-login.js | 142 +++++++++++++------------- web/static/js/auth.js | 3 + 2 files changed, 75 insertions(+), 70 deletions(-) diff --git a/web/static/app/pages/webflow-login.js b/web/static/app/pages/webflow-login.js index 1d7576cec..a88dd199f 100644 --- a/web/static/app/pages/webflow-login.js +++ b/web/static/app/pages/webflow-login.js @@ -10,7 +10,10 @@ * 2. - + From d85f7c8bae748ab51b181f69dba0632afca2b25c Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:16:14 +1000 Subject: [PATCH 10/41] Add PR dev app override --- webflow-designer-extension-cli/package.json | 2 +- .../public/dev-config.js | 1 + .../public/index.html | 1 + webflow-designer-extension-cli/scripts/dev.js | 152 ++++++++++++++++++ webflow-designer-extension-cli/src/index.ts | 21 ++- 5 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 webflow-designer-extension-cli/public/dev-config.js create mode 100644 webflow-designer-extension-cli/scripts/dev.js diff --git a/webflow-designer-extension-cli/package.json b/webflow-designer-extension-cli/package.json index 57adf2361..1160e9fea 100644 --- a/webflow-designer-extension-cli/package.json +++ b/webflow-designer-extension-cli/package.json @@ -9,7 +9,7 @@ "sync-shared": "node scripts/sync-shared.js", "sync-components": "npm run sync-shared", "build": "npm run sync-shared && npm run compile && npm exec -- webflow extension bundle --skip-update-check", - "dev": "npm run sync-shared && concurrently -r \"npm exec -- webflow extension serve --skip-update-check\" \"npm run compile -- --watch --preserveWatchOutput\"", + "dev": "node scripts/dev.js", "lint": "eslint --no-config-lookup -c eslint.config.mjs \"src/**/*.ts\"", "format:check": "prettier --check \"./**/*.{ts,json,md,html,css,yml,yaml}\"", "format:write": "prettier --write \"./**/*.{ts,json,md,html,css,yml,yaml}\"" diff --git a/webflow-designer-extension-cli/public/dev-config.js b/webflow-designer-extension-cli/public/dev-config.js new file mode 100644 index 000000000..3bf192f80 --- /dev/null +++ b/webflow-designer-extension-cli/public/dev-config.js @@ -0,0 +1 @@ +window.HOVER_EXTENSION_CONFIG = window.HOVER_EXTENSION_CONFIG || {}; diff --git a/webflow-designer-extension-cli/public/index.html b/webflow-designer-extension-cli/public/index.html index 83aa1f3b5..b2e8b78a4 100644 --- a/webflow-designer-extension-cli/public/index.html +++ b/webflow-designer-extension-cli/public/index.html @@ -286,6 +286,7 @@ + diff --git a/webflow-designer-extension-cli/scripts/dev.js b/webflow-designer-extension-cli/scripts/dev.js new file mode 100644 index 000000000..6ac06f498 --- /dev/null +++ b/webflow-designer-extension-cli/scripts/dev.js @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { spawn, spawnSync } = require("child_process"); + +const ROOT = path.resolve(__dirname, ".."); +const PUBLIC_DIR = path.join(ROOT, "public"); +const DEV_CONFIG_PATH = path.join(PUBLIC_DIR, "dev-config.js"); +const DEFAULT_DEV_CONFIG = + "window.HOVER_EXTENSION_CONFIG = window.HOVER_EXTENSION_CONFIG || {};\n"; +const NPM_CMD = process.platform === "win32" ? "npm.cmd" : "npm"; + +function parsePreviewNumber(argv, env) { + const cliArgs = argv.slice(2); + const envPr = String(env.npm_config_pr || "").trim(); + + if (envPr && envPr !== "true" && envPr !== "false") { + return envPr; + } + + const explicitArg = cliArgs.find((arg) => /^--pr=/.test(arg)); + if (explicitArg) { + return explicitArg.split("=")[1] || ""; + } + + const prFlagIndex = cliArgs.indexOf("--pr"); + if (prFlagIndex >= 0 && cliArgs[prFlagIndex + 1]) { + return cliArgs[prFlagIndex + 1]; + } + + if (envPr === "true") { + const positional = cliArgs.find((arg) => /^\d+$/.test(arg)); + if (positional) { + return positional; + } + } + + return ""; +} + +function buildAppOrigin(argv, env) { + const explicitOrigin = String(env.HOVER_APP_ORIGIN || "").trim(); + if (explicitOrigin) { + return explicitOrigin.replace(/\/+$/, ""); + } + + const pr = parsePreviewNumber(argv, env); + if (pr) { + return `https://hover-pr-${pr}.fly.dev`; + } + + return ""; +} + +function writeDevConfig(appOrigin) { + const content = appOrigin + ? `window.HOVER_EXTENSION_CONFIG = { appOrigin: ${JSON.stringify(appOrigin)} };\n` + : DEFAULT_DEV_CONFIG; + fs.writeFileSync(DEV_CONFIG_PATH, content, "utf8"); +} + +function runOrExit(command, args) { + const result = spawnSync(command, args, { + cwd: ROOT, + stdio: "inherit", + }); + + if (result.status !== 0) { + process.exit(result.status || 1); + } +} + +let restored = false; +function restoreDevConfig() { + if (restored) { + return; + } + restored = true; + try { + writeDevConfig(""); + } catch (_error) { + // Best-effort cleanup only. + } +} + +function main() { + const appOrigin = buildAppOrigin(process.argv, process.env); + writeDevConfig(appOrigin); + + if (appOrigin) { + console.log(`[dev] Using override app origin: ${appOrigin}`); + } else { + console.log("[dev] Using default app origin"); + } + + runOrExit(NPM_CMD, ["run", "sync-shared"]); + + const serve = spawn( + NPM_CMD, + ["exec", "--", "webflow", "extension", "serve", "--skip-update-check"], + { + cwd: ROOT, + stdio: "inherit", + } + ); + + const compile = spawn( + NPM_CMD, + ["run", "compile", "--", "--watch", "--preserveWatchOutput"], + { + cwd: ROOT, + stdio: "inherit", + } + ); + + let shuttingDown = false; + + function shutdown(exitCode = 0) { + if (shuttingDown) { + return; + } + shuttingDown = true; + + restoreDevConfig(); + + if (!serve.killed) { + serve.kill("SIGTERM"); + } + if (!compile.killed) { + compile.kill("SIGTERM"); + } + + setTimeout(() => { + process.exit(exitCode); + }, 50); + } + + serve.on("exit", (code) => { + shutdown(code || 0); + }); + + compile.on("exit", (code) => { + shutdown(code || 0); + }); + + process.on("SIGINT", () => shutdown(0)); + process.on("SIGTERM", () => shutdown(0)); + process.on("exit", restoreDevConfig); +} + +main(); diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index dc1e8a06e..720de91bd 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -33,7 +33,6 @@ const API_TOKEN_STORAGE_KEY = "gnh_extension_api_token_session"; const AUTH_POPUP_WIDTH = 520; const AUTH_POPUP_HEIGHT = 760; const DEFAULT_GNH_APP_ORIGIN = "https://hover.app.goodnative.co"; -const LEGACY_EXTENSION_APP_ORIGINS = new Set(["https://hover-pr-255.fly.dev"]); const AUTH_POPUP_NAME = "bbbExtensionAuth"; const SCHEDULE_PLACEHOLDER = "off"; const SCHEDULE_OPTIONS = ["off", "6", "12", "24", "48"] as const; @@ -82,6 +81,12 @@ declare const webflow: { setExtensionSize: (size: ExtensionPanelSize) => Promise; }; +type ExtensionWindow = Window & { + HOVER_EXTENSION_CONFIG?: { + appOrigin?: string; + }; +}; + // Shared modules exposed by lib/bridge.js via window.HoverLib declare const HoverLib: { api: { @@ -560,12 +565,20 @@ let isRealtimeRefreshing = false; let jobsSubscriptionCleanup: (() => void) | null = null; function getStoredBaseUrl(): string { + const extensionWindow = window as ExtensionWindow; + const runtimeBaseUrl = String( + extensionWindow.HOVER_EXTENSION_CONFIG?.appOrigin || "" + ).trim(); + if (runtimeBaseUrl) { + return runtimeBaseUrl.replace(/\/+$/, ""); + } + const storedBaseUrl = localStorage.getItem(API_BASE_STORAGE_KEY); if (!storedBaseUrl) { return DEFAULT_GNH_APP_ORIGIN; } - if (LEGACY_EXTENSION_APP_ORIGINS.has(storedBaseUrl)) { + if (/^https:\/\/hover-pr-\d+\.fly\.dev\/?$/i.test(storedBaseUrl)) { return DEFAULT_GNH_APP_ORIGIN; } @@ -2097,7 +2110,9 @@ async function initialise(): Promise { cleanupRealtimeSubscription(); }); try { - localStorage.setItem(API_BASE_STORAGE_KEY, state.apiBaseUrl); + if (!(window as ExtensionWindow).HOVER_EXTENSION_CONFIG?.appOrigin) { + localStorage.setItem(API_BASE_STORAGE_KEY, state.apiBaseUrl); + } } catch (_error) { // ignore } From fce9849ab2441e2ddb2f7b9b18e994c8c2833787 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:23:23 +1000 Subject: [PATCH 11/41] Fix auth Supabase collision --- web/static/js/auth.js | 50 +++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/web/static/js/auth.js b/web/static/js/auth.js index ce64031a0..d9cb157e3 100644 --- a/web/static/js/auth.js +++ b/web/static/js/auth.js @@ -39,7 +39,7 @@ if (!hasSupabaseRuntimeConfig()) { } // Global state -let supabase; +let supabaseClient = null; let captchaToken = null; const MAX_TURNSTILE_RETRIES = 2; let pendingSignupSubmission = null; @@ -278,6 +278,7 @@ function getOAuthCallbackURL(params = {}) { function initialiseSupabase() { // If already initialised (client has auth property), return success if (window.supabase && window.supabase.auth) { + supabaseClient = window.supabase; return true; } @@ -290,8 +291,11 @@ function initialiseSupabase() { // Otherwise, create the client from the SDK if (window.supabase && window.supabase.createClient) { - supabase = window.supabase.createClient(SUPABASE_URL, SUPABASE_ANON_KEY); - window.supabase = supabase; // Ensure it's globally available + supabaseClient = window.supabase.createClient( + SUPABASE_URL, + SUPABASE_ANON_KEY + ); + window.supabase = supabaseClient; // Ensure it's globally available return true; } return false; @@ -431,7 +435,7 @@ async function handleAuthCallback() { const { data: { session }, error, - } = await supabase.auth.setSession({ + } = await supabaseClient.auth.setSession({ access_token: accessToken, refresh_token: refreshToken, }); @@ -476,7 +480,7 @@ async function handleAuthCallback() { // Check if already authenticated const { data: { session }, - } = await supabase.auth.getSession(); + } = await supabaseClient.auth.getSession(); if (session) { const pendingInviteToken = getPendingInviteToken(); if ( @@ -534,7 +538,7 @@ async function registerUserWithBackend(user) { } try { - const session = await supabase.auth.getSession(); + const session = await supabaseClient.auth.getSession(); if (!session.data.session) { console.error("No session available for registration"); return false; @@ -682,7 +686,7 @@ function updateAuthState(isAuthenticated) { ) { logoutBtn.addEventListener("click", async () => { try { - const { error } = await supabase.auth.signOut(); + const { error } = await supabaseClient.auth.signOut(); if (error) { console.error("Logout error:", error); alert("Logout failed. Please try again."); @@ -716,7 +720,7 @@ async function updateUserInfo() { // Get current user from Supabase directly const { data: { session }, - } = await supabase.auth.getSession(); + } = await supabaseClient.auth.getSession(); if (session && session.user && session.user.email) { const email = session.user.email; @@ -1072,7 +1076,7 @@ async function handleEmailLogin(event) { clearAuthError(); try { - const { data, error } = await supabase.auth.signInWithPassword({ + const { data, error } = await supabaseClient.auth.signInWithPassword({ email, password, }); @@ -1165,7 +1169,7 @@ async function executeEmailSignup() { name: fullName || "", }; - const { data, error } = await supabase.auth.signUp({ + const { data, error } = await supabaseClient.auth.signUp({ email, password, options: signupOptions, @@ -1274,13 +1278,13 @@ async function trySendCliCallback(callbackUrlOverride) { } try { - if (!supabase) { + if (!supabaseClient) { return false; } const { data: { session }, error, - } = await supabase.auth.getSession(); + } = await supabaseClient.auth.getSession(); if (error || !session) { return false; } @@ -1374,7 +1378,7 @@ async function handlePasswordReset(event) { clearAuthError(); try { - const { error } = await supabase.auth.resetPasswordForEmail(email, { + const { error } = await supabaseClient.auth.resetPasswordForEmail(email, { redirectTo: `${window.location.origin}/dashboard`, }); @@ -1419,7 +1423,7 @@ async function handleSocialLogin(provider, options = {}) { window.GNH_APP?.oauthRedirectOverride || (window.GNH_APP?.extensionAuth ? window.location.href : ""); - const { data, error } = await supabase.auth.signInWithOAuth({ + const { data, error } = await supabaseClient.auth.signInWithOAuth({ provider, options: { redirectTo: redirectOverride || getOAuthRedirectTarget(), @@ -1486,7 +1490,7 @@ async function initAuthCallbackPage() { const { data: { session }, - } = await supabase.auth.getSession(); + } = await supabaseClient.auth.getSession(); if (session) { authCallbackRedirectIssued = true; window.location.replace("/dashboard"); @@ -1815,12 +1819,12 @@ function setupAuthHandlers() { // Ensure Supabase client is initialized before calling getSession. await waitForAuthScript(); - if (!supabase || !supabase.auth) { + if (!supabaseClient || !supabaseClient.auth) { console.error("CLI auth: Supabase client not initialized"); return; } - const { data } = await supabase.auth.getSession(); + const { data } = await supabaseClient.auth.getSession(); if (!data?.session) { await loadAuthModal(); showAuthModal(); @@ -1835,12 +1839,12 @@ function initialiseAuthStateSync() { if (authStateSyncInitialised) { return true; } - if (!supabase?.auth && !initialiseSupabase()) { + if (!supabaseClient?.auth && !initialiseSupabase()) { return false; } authStateSyncInitialised = true; - supabase.auth + supabaseClient.auth .getSession() .then(({ data }) => { const session = data?.session; @@ -1856,7 +1860,7 @@ function initialiseAuthStateSync() { updateAuthState(false); }); - supabase.auth.onAuthStateChange((_event, session) => { + supabaseClient.auth.onAuthStateChange((_event, session) => { const isAuthenticated = Boolean(session); updateAuthState(isAuthenticated); if (isAuthenticated) { @@ -1889,7 +1893,7 @@ function scheduleAuthStateSyncRetry() { */ async function handleLogout() { try { - const { error } = await supabase.auth.signOut(); + const { error } = await supabaseClient.auth.signOut(); if (error) { console.error("Logout error:", error); alert("Logout failed. Please try again."); @@ -2059,7 +2063,7 @@ function initExtensionAuthPage() { const { data: { session }, error, - } = await supabase.auth.getSession(); + } = await supabaseClient.auth.getSession(); if (error || !session?.access_token || !session.user) { throw new Error(error?.message || "No active session found", { @@ -2122,7 +2126,7 @@ function initExtensionAuthPage() { const { data: { session }, - } = await supabase.auth.getSession(); + } = await supabaseClient.auth.getSession(); if (session?.access_token) { setStatus("Existing session found. Connecting extension…"); await sendSessionToExtension(); From bec755e4bd5c255d0e66d487869fe6a1b4345942 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:54:40 +1000 Subject: [PATCH 12/41] Rewrite extension auth popup --- web/static/app/lib/supabase-client.js | 51 ++ web/static/app/pages/webflow-login.js | 779 ++++++++++++++++++++++---- web/templates/extension-auth.html | 20 +- 3 files changed, 732 insertions(+), 118 deletions(-) create mode 100644 web/static/app/lib/supabase-client.js diff --git a/web/static/app/lib/supabase-client.js b/web/static/app/lib/supabase-client.js new file mode 100644 index 000000000..5cd8e7710 --- /dev/null +++ b/web/static/app/lib/supabase-client.js @@ -0,0 +1,51 @@ +/** + * lib/supabase-client.js — module-side Supabase bootstrap + * + * New ES module code can call ensureSupabaseClient() to convert the loaded + * UMD SDK namespace into the shared window.supabase auth client expected by + * the existing app helpers. + */ + +import { isConfigured, supabaseAnonKey, supabaseUrl } from "/app/lib/config.js"; + +/** + * Returns the active browser auth client when already initialised. + * @returns {import("@supabase/supabase-js").SupabaseClient|null} + */ +export function getSupabaseClient() { + return window.supabase?.auth ? window.supabase : null; +} + +/** + * Initialises the browser auth client from the loaded UMD SDK when needed. + * The returned client is stored on window.supabase for compatibility with the + * existing module helpers that read window.supabase.auth. + * + * @returns {import("@supabase/supabase-js").SupabaseClient} + */ +export function ensureSupabaseClient() { + const existingClient = getSupabaseClient(); + if (existingClient) { + return existingClient; + } + + if (!isConfigured()) { + throw new Error( + "Supabase configuration unavailable. Please reload and try again." + ); + } + + const supabaseNamespace = window.supabase; + if (typeof supabaseNamespace?.createClient !== "function") { + throw new Error("Supabase SDK is not available yet. Please refresh."); + } + + const client = supabaseNamespace.createClient(supabaseUrl, supabaseAnonKey); + window.supabase = client; + return client; +} + +export default { + getSupabaseClient, + ensureSupabaseClient, +}; diff --git a/web/static/app/pages/webflow-login.js b/web/static/app/pages/webflow-login.js index 425e950e7..d1ca14d48 100644 --- a/web/static/app/pages/webflow-login.js +++ b/web/static/app/pages/webflow-login.js @@ -1,32 +1,13 @@ /** * pages/webflow-login.js — Webflow Designer extension auth screen * - * Entrypoint for the extension-auth page served by the Go backend at - * /extension-auth. Replaces the legacy core.js + gnh-auth-extension.js - * global-script model for this surface. - * - * Loading contract (extension-auth.html): - * 1. - @@ -112,6 +108,10 @@ border-color: var(--border-colour--active); color: var(--text-colour--primary); } + + .extension-auth-reopen[hidden] { + display: none; + } From efacb6477fb26b7e2bf34e348db1c3b25b7f0234 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:09:03 +1000 Subject: [PATCH 13/41] Format changelog spacing --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5b8f5370..721ce266b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ On merge, CI will: app layer and extension bridge runtime - aligned extension reuse docs with the current ES modules migration state and the remaining hybrid auth popup architecture + ## [0.31.4] – 2026-04-05 ### Fixed From 086bf06c386ef698321e6b0b9eff35fe3432a6ae Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:24:33 +1000 Subject: [PATCH 14/41] Fix popup OAuth return --- web/static/app/pages/webflow-login.js | 76 +++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 10 deletions(-) diff --git a/web/static/app/pages/webflow-login.js b/web/static/app/pages/webflow-login.js index d1ca14d48..64bc962cc 100644 --- a/web/static/app/pages/webflow-login.js +++ b/web/static/app/pages/webflow-login.js @@ -18,9 +18,6 @@ import { showToast } from "/app/components/hover-toast.js"; * Passed as both ?state= and ?extension_state= in the URL. */ const SEARCH_PARAMS = new URLSearchParams(window.location.search); -const TARGET_ORIGIN = SEARCH_PARAMS.get("origin") || ""; -const EXTENSION_STATE = - SEARCH_PARAMS.get("extension_state") || SEARCH_PARAMS.get("state") || ""; const AUTH_MODAL_PATH = "/auth-modal.html"; const OAUTH_CALLBACK_QUERY_KEYS = [ "error", @@ -34,6 +31,8 @@ const OAUTH_CALLBACK_QUERY_KEYS = [ const TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; const MAX_TURNSTILE_RETRIES = 2; +const POPUP_AUTH_TARGET_ORIGIN_STORAGE_KEY = "gnh_extension_auth_target_origin"; +const POPUP_AUTH_STATE_STORAGE_KEY = "gnh_extension_auth_state"; /** @type {import("@supabase/supabase-js").SupabaseClient|null} */ let supabaseClient = null; @@ -45,6 +44,14 @@ let turnstileLoadPromise = null; let turnstileRetryCount = 0; let awaitingCaptchaRefresh = false; let pendingSignupSubmission = null; +let targetOrigin = getPopupContextValue( + POPUP_AUTH_TARGET_ORIGIN_STORAGE_KEY, + SEARCH_PARAMS.get("origin") || "" +); +let extensionState = getPopupContextValue( + POPUP_AUTH_STATE_STORAGE_KEY, + SEARCH_PARAMS.get("extension_state") || SEARCH_PARAMS.get("state") || "" +); // ── Element references ───────────────────────────────────────────────────────── @@ -67,6 +74,7 @@ async function init() { return; } + persistPopupContext(); setStatus("Preparing sign-in…"); try { @@ -125,8 +133,8 @@ async function handleAuthenticated(session) { window.opener.postMessage( { source: "gnh-extension-auth", - state: EXTENSION_STATE, - extensionState: EXTENSION_STATE, + state: extensionState, + extensionState, type: "success", accessToken: session.access_token, user: { @@ -135,10 +143,11 @@ async function handleAuthenticated(session) { avatarUrl: session.user?.user_metadata?.avatar_url ?? "", }, }, - TARGET_ORIGIN + targetOrigin ); setStatus("Signed in — you can close this window.", "success"); + clearPopupContext(); showToast("Signed in successfully.", { variant: "success", duration: 0 }); } catch (err) { console.error("webflow-login: postMessage failed", err); @@ -500,6 +509,11 @@ function showLogin() { } function validatePopupContract() { + if (!targetOrigin || !extensionState) { + setStatus("Missing extension context. Please reopen sign-in.", "error"); + return false; + } + if (!window.opener || window.opener.closed) { setStatus( "This login window must be opened from the Webflow extension.", @@ -508,7 +522,7 @@ function validatePopupContract() { return false; } - if (TARGET_ORIGIN && !isValidExtensionTargetOrigin(TARGET_ORIGIN)) { + if (targetOrigin && !isValidExtensionTargetOrigin(targetOrigin)) { setStatus("Invalid extension origin. Please reopen sign-in.", "error"); return false; } @@ -517,7 +531,7 @@ function validatePopupContract() { const referrerOrigin = document.referrer ? new URL(document.referrer).origin : ""; - if (referrerOrigin && TARGET_ORIGIN && referrerOrigin !== TARGET_ORIGIN) { + if (referrerOrigin && targetOrigin && referrerOrigin !== targetOrigin) { setStatus( "Origin mismatch. Please relaunch from the extension.", "error" @@ -556,6 +570,43 @@ function isValidExtensionTargetOrigin(rawOrigin) { } } +function getPopupContextValue(storageKey, fallbackValue) { + if (fallbackValue) { + return fallbackValue; + } + + try { + return window.sessionStorage.getItem(storageKey) || ""; + } catch (_error) { + return ""; + } +} + +function persistPopupContext() { + if (!targetOrigin || !extensionState) { + return; + } + + try { + window.sessionStorage.setItem( + POPUP_AUTH_TARGET_ORIGIN_STORAGE_KEY, + targetOrigin + ); + window.sessionStorage.setItem(POPUP_AUTH_STATE_STORAGE_KEY, extensionState); + } catch (_error) { + // Ignore storage failures. + } +} + +function clearPopupContext() { + try { + window.sessionStorage.removeItem(POPUP_AUTH_TARGET_ORIGIN_STORAGE_KEY); + window.sessionStorage.removeItem(POPUP_AUTH_STATE_STORAGE_KEY); + } catch (_error) { + // Ignore storage failures. + } +} + function hasOAuthCallbackQuery(urlParams) { return ( urlParams.has("code") || @@ -790,14 +841,19 @@ async function handleSocialLogin(provider) { clearAuthError(); try { - const { error } = await supabaseClient.auth.signInWithOAuth({ + persistPopupContext(); + + const { data, error } = await supabaseClient.auth.signInWithOAuth({ provider, options: { - redirectTo: window.location.href, + redirectTo: `${window.location.origin}/extension-auth.html`, }, }); if (error) throw error; + if (data?.url) { + window.location.assign(data.url); + } } catch (error) { console.error("webflow-login: social login failed", error); showAuthError(error?.message || `${provider} login failed.`); From 13730b5d22fcb41c11114e186da76d43aa566efc Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:38:32 +1000 Subject: [PATCH 15/41] Sync org switch across surfaces --- webflow-designer-extension-cli/src/index.ts | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 720de91bd..740eb72d4 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -30,6 +30,7 @@ type RealtimeChannel = { const API_BASE_STORAGE_KEY = "gnh_extension_api_base"; const API_TOKEN_STORAGE_KEY = "gnh_extension_api_token_session"; +const ACTIVE_ORG_STORAGE_KEY = "gnh_active_org_id"; const AUTH_POPUP_WIDTH = 520; const AUTH_POPUP_HEIGHT = 760; const DEFAULT_GNH_APP_ORIGIN = "https://hover.app.goodnative.co"; @@ -558,6 +559,7 @@ let jobStatusPoller: number | null = null; let jobPollInFlight = false; let lastCompletedJobsSignature = ""; let lastChartJobsSignature = ""; +let crossSurfaceOrgRefreshInFlight = false; // Supabase realtime state let supabaseClient: SupabaseClient | null = null; @@ -1894,6 +1896,29 @@ async function refreshDashboard(): Promise { } } +async function syncActiveOrganisationFromStorage( + nextOrganisationId: string | null +): Promise { + if (!state.token || !nextOrganisationId) { + return; + } + + if ( + crossSurfaceOrgRefreshInFlight || + nextOrganisationId === state.activeOrganisationId + ) { + return; + } + + crossSurfaceOrgRefreshInFlight = true; + try { + state.activeOrganisationId = nextOrganisationId; + await refreshDashboard(); + } finally { + crossSurfaceOrgRefreshInFlight = false; + } +} + async function switchOrganisation(): Promise { const select = asSelect(ui.orgSelect); if (!select || !select.value) { @@ -2109,6 +2134,12 @@ async function initialise(): Promise { stopJobStatusPolling(); cleanupRealtimeSubscription(); }); + window.addEventListener("storage", (event) => { + if (event.key !== ACTIVE_ORG_STORAGE_KEY) { + return; + } + void syncActiveOrganisationFromStorage(event.newValue); + }); try { if (!(window as ExtensionWindow).HOVER_EXTENSION_CONFIG?.appOrigin) { localStorage.setItem(API_BASE_STORAGE_KEY, state.apiBaseUrl); From 2cb6577e212dc085c0fd8fcecbd097f3299318d7 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:43:56 +1000 Subject: [PATCH 16/41] Refresh org context in extension --- webflow-designer-extension-cli/src/index.ts | 26 ++++++++++++--------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 740eb72d4..e1cb11296 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -254,10 +254,6 @@ type UsageStats = { plan_display_name: string; }; -type UsageResponse = { - usage: UsageStats; -}; - type JobItem = { id: string; status: string; @@ -782,23 +778,31 @@ async function realtimeRefresh(): Promise { isRealtimeRefreshing = true; try { - // Refresh both job state and usage stats, matching the dashboard pattern. - await Promise.all([refreshCurrentJob(), refreshUsage()]); + // Refresh both job state and organisation context so cross-surface org + // switches update quota, selected org, and the realtime subscription. + await Promise.all([refreshCurrentJob(), refreshOrganisationContext()]); } finally { isRealtimeRefreshing = false; } } -async function refreshUsage(): Promise { +async function refreshOrganisationContext(): Promise { if (!state.token) return; try { - const usageData = (await HoverLib.api.get("/v1/usage")) as UsageResponse; - state.usage = usageData.usage || null; + const previousOrganisationId = state.activeOrganisationId; + await loadUsageAndOrgs(); renderUsage(state.usage); + + if (state.activeOrganisationId !== previousOrganisationId) { + renderOrganisations(); + if (supabaseClient) { + subscribeToJobUpdates(); + } + } } catch (error) { - // Non-critical — keep existing usage displayed. - console.warn("Failed to refresh usage stats:", error); + // Non-critical — keep existing org/quota state displayed. + console.warn("Failed to refresh organisation context:", error); } } From 258a34053eebafe4bdedca08372fb283a3fe5edc Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:06:37 +1000 Subject: [PATCH 17/41] Unify dashboard shell --- dashboard.html | 608 +++----- web/static/app/lib/site-jobs.js | 3 - web/static/app/pages/dashboard.js | 1298 +++++++++++------ web/static/app/pages/webflow-login.js | 103 +- web/static/app/styles/dashboard-extension.css | 821 +++++++---- 5 files changed, 1679 insertions(+), 1154 deletions(-) diff --git a/dashboard.html b/dashboard.html index ee684bc8b..5b100fd1a 100644 --- a/dashboard.html +++ b/dashboard.html @@ -9,6 +9,7 @@ href="/assets/Good-Native_Hover_App_Logo_Webflow.png" /> Dashboard - Hover + @@ -27,25 +28,15 @@ window.location.hostname === "hover.app.goodnative.co" ? "production" : "development", - - // Performance Monitoring - tracesSampleRate: 0.1, // 10% of transactions - - // Session Replay - records user sessions when errors occur - replaysSessionSampleRate: 0, // Don't record all sessions - replaysOnErrorSampleRate: 1.0, // Record 100% of sessions with errors - - // User context + tracesSampleRate: 0.1, + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 1.0, beforeSend(event) { return event; }, - - // Ignore certain errors ignoreErrors: [ - // Browser extensions "ResizeObserver loop limit exceeded", "Non-Error promise rejection captured", - // Network errors that are expected /Failed to fetch/, /NetworkError/, /Load failed/, @@ -53,8 +44,13 @@ }); - - + + diff --git a/web/static/app/lib/site-jobs.js b/web/static/app/lib/site-jobs.js index 73ba00601..2bde0a22b 100644 --- a/web/static/app/lib/site-jobs.js +++ b/web/static/app/lib/site-jobs.js @@ -292,9 +292,6 @@ export function subscribeToJobUpdates(options) { startFallback(); } }); - - // Start fallback immediately; clearFallback() stops it on the first real event. - startFallback(); } catch (err) { onSubscriptionIssue?.("SUBSCRIBE_FAILED", err); startFallback(); diff --git a/web/static/app/pages/dashboard.js b/web/static/app/pages/dashboard.js index 3af237cef..4f4748bf5 100644 --- a/web/static/app/pages/dashboard.js +++ b/web/static/app/pages/dashboard.js @@ -1,266 +1,783 @@ /** - * pages/dashboard.js — dashboard page orchestrator + * pages/dashboard.js — module-native dashboard shell * - * Owns all dashboard rendering and interaction: stats cards, job list - * (hover-job-card), job creation form, org creation modal, admin - * actions, and realtime subscriptions. - * - * No remaining legacy script dependencies. + * Uses the same site-focused shell model as the Webflow extension: + * org switcher, quota badge, per-site scheduler, run-now action, and + * latest/past report surfaces. */ -import { get, post, put } from "/app/lib/api-client.js"; -import { fetchJobs, subscribeToJobUpdates } from "/app/pages/webflow-jobs.js"; +import { get, post } from "/app/lib/api-client.js"; +import { onAuthStateChange, getSession } from "/app/lib/auth-session.js"; import { createJobCard } from "/app/components/hover-job-card.js"; import { showToast } from "/app/components/hover-toast.js"; -import { formatCount } from "/app/lib/formatters.js"; -import { initCreateOrgModal } from "/app/lib/settings/organisations.js"; -import { initAdminResetButton } from "/app/lib/admin.js"; +import { formatDateTime, getInitials } from "/app/lib/formatters.js"; +import { + loadOrganisationContext, + switchOrganisation as switchOrganisationApi, +} from "/app/lib/organisation-api.js"; import { - deleteScheduler, + findSchedulerByDomain, saveSchedulerForDomain, + disableScheduler, } from "/app/lib/scheduler-api.js"; +import { ensureSupabaseClient } from "/app/lib/supabase-client.js"; +import { + buildChartJobsSignature, + buildCompletedJobsSignature, + fetchJobs, + filterJobsByDomains, + normaliseDomain, + pickLatestJobByDomains, + subscribeToJobUpdates, +} from "/app/lib/site-jobs.js"; import { ensureDomainByName, + getDomains, + loadOrganisationDomains, setupDomainSearchInput, } from "/app/lib/domain-search.js"; -// ── State ────────────────────────────────────────────────────────────────────── +const ACTIVE_ORG_STORAGE_KEY = "gnh_active_org_id"; +const SELECTED_DOMAIN_STORAGE_KEY = "gnh_dashboard_selected_domain"; +const ACTIVE_JOB_STATUSES = new Set([ + "pending", + "queued", + "initializing", + "running", + "in_progress", + "processing", +]); +const SCHEDULE_PLACEHOLDER = "off"; +const SCHEDULE_OPTIONS = new Set(["off", "6", "12", "24", "48"]); +const APP_ROUTES = { + auth: "/extension-auth.html", + settings: "/settings/plans", + viewJob: "/jobs", + help: "/dashboard", + feedback: "/dashboard", +}; + +let authSubscriptionCleanup = null; +let jobsSubscriptionCleanup = null; +let statusToastTimer = null; +let initialised = false; + +const state = { + session: null, + activeOrganisationId: "", + organisations: [], + usage: null, + selectedDomain: normaliseDomain( + window.localStorage.getItem(SELECTED_DOMAIN_STORAGE_KEY) || "" + ), + siteDomainCandidates: [], + currentScheduler: null, + currentJob: null, + userAvatarUrl: "", + userEmail: "", + lastCompletedJobsSignature: "", + lastChartJobsSignature: "", + refreshing: false, +}; + +const ui = { + guestState: document.getElementById("guestState"), + authState: document.getElementById("authState"), + loginButton: document.getElementById("dashboardLoginButton"), + signupButton: document.getElementById("dashboardSignupButton"), + settingsButton: document.getElementById("settingsButton"), + profileAvatar: document.getElementById("profileAvatar"), + orgSelect: document.getElementById("orgSelect"), + planNameText: document.getElementById("planNameText"), + planRemainingValue: document.getElementById("planRemainingValue"), + domainInput: document.getElementById("domainInput"), + scheduleSelect: document.getElementById("scheduleSelect"), + runNowButton: document.getElementById("runNowButton"), + runFirstCheckButton: document.getElementById("runFirstCheckButton"), + statusBlock: document.getElementById("statusBlock"), + statusText: document.getElementById("statusText"), + detailText: document.getElementById("detailText"), + noJobState: document.getElementById("noJobState"), + noJobText: document.getElementById("noJobText"), + jobSection: document.getElementById("jobSection"), + latestResultsList: document.getElementById("latestResultsList"), + recentResultsList: document.getElementById("recentResultsList"), + miniChart: document.getElementById("miniChart"), + chartScaleLabels: Array.from( + document.querySelectorAll(".chart-y-scale span") + ), + feedbackButton: document.getElementById("feedbackButton"), + helpButton: document.getElementById("helpButton"), +}; + +function show(node) { + node?.classList.remove("hidden"); +} -let currentRange = "today"; +function hide(node) { + node?.classList.add("hidden"); +} -// ── Bootstrap ────────────────────────────────────────────────────────────────── +function setText(node, value) { + if (node) { + node.textContent = value; + } +} -/** - * Initialise the dashboard module layer. - * Called once auth and org state are confirmed ready. - */ -let _initialised = false; -async function init() { - if (_initialised) return; - _initialised = true; - // Wire date range selector - const dateRange = document.getElementById("dateRange"); - if (dateRange) { - dateRange.addEventListener("change", (e) => { - currentRange = e.target.value; - refresh(); - }); +function normaliseJobStatus(status) { + return String(status || "") + .trim() + .toLowerCase(); +} + +function isActiveJobStatus(status) { + return ACTIVE_JOB_STATUSES.has(normaliseJobStatus(status)); +} + +function asCount(value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return 0; } + return Math.max(0, Math.floor(value)); +} - // Wire action buttons — refresh, create-job modal, close-create-job-modal - document.addEventListener("click", (e) => { - const el = e.target.closest("[gnh-action]"); - if (!el) return; - const action = el.getAttribute("gnh-action"); - if (action === "refresh-dashboard") { - e.preventDefault(); - refresh(); - } else if (action === "create-job") { - e.preventDefault(); - openCreateJobModal(); - } else if (action === "close-create-job-modal") { - e.preventDefault(); - closeCreateJobModal(); - } - }); +function getIssueCounts(job) { + const buckets = job.stats?.slow_page_buckets; + const statsBrokenLinks = asCount(job.stats?.total_broken_links); + const fallbackBrokenLinks = asCount(job.failed_tasks); + + if (job.stats && buckets) { + const verySlow = asCount(buckets.over_10s) + asCount(buckets["5_to_10s"]); + const slow = asCount(buckets["3_to_5s"]); + return { + brokenLinks: Math.max(statsBrokenLinks, fallbackBrokenLinks), + verySlow, + slow, + }; + } - // Job creation forms (inline "Start Crawl" + modal "Create Job") - for (const formId of ["dashboardJobForm", "createJobForm"]) { - const form = document.getElementById(formId); - if (form) form.addEventListener("submit", handleJobCreation); - } - - // Domain search autocomplete - const domainInput = document.getElementById("jobDomain"); - if (domainInput) { - const container = domainInput.closest(".gnh-domain-search"); - setupDomainSearchInput({ - input: domainInput, - container: container || domainInput.parentElement, - clearOnSelect: false, - onSelectDomain: (domain) => { - domainInput.value = domain.name; - }, - onCreateDomain: (domain) => { - domainInput.value = domain.name; - }, - onError: (message) => { - showToast(message || "Failed to create domain.", { variant: "error" }); - }, - }); + return { + brokenLinks: fallbackBrokenLinks, + verySlow: 0, + slow: 0, + }; +} + +function buildAuthUrl(mode = "login") { + const authUrl = new URL(APP_ROUTES.auth, window.location.origin); + authUrl.searchParams.set("return_to", window.location.href); + authUrl.searchParams.set("mode", mode); + return authUrl.toString(); +} + +function openAuth(mode = "login") { + window.location.assign(buildAuthUrl(mode)); +} + +function setStatus(message, detail = "") { + if (statusToastTimer !== null) { + clearTimeout(statusToastTimer); + statusToastTimer = null; } - // Org creation modal - initCreateOrgModal({ onCreated: () => refresh() }); + ui.statusBlock?.classList.remove("status-block--fading"); + setText(ui.statusText, message); + setText(ui.detailText, detail); - // Network monitoring - setupNetworkMonitoring(); + if (!message && !detail) { + return; + } - // Initial render (waitForSession inside refresh handles Supabase timing) - await refresh(); + statusToastTimer = window.setTimeout(() => { + ui.statusBlock?.classList.add("status-block--fading"); + statusToastTimer = window.setTimeout(() => { + ui.statusBlock?.classList.remove("status-block--fading"); + setText(ui.statusText, ""); + setText(ui.detailText, ""); + statusToastTimer = null; + }, 500); + }, 3000); +} - // Admin section (must run after refresh so Supabase session is available) - await initAdminResetButton("resetDbBtn", { - containerSelector: "#adminGroup", - }); +function renderAuthState(isAuthed) { + if (isAuthed) { + hide(ui.guestState); + show(ui.authState); + return; + } + + show(ui.guestState); + hide(ui.authState); +} - // Subscribe to realtime job updates (falls back to 10 s polling when - // Supabase realtime is unavailable, e.g. on preview branches). - let unsubscribe = null; - function startSubscription() { - if (unsubscribe) unsubscribe(); - const orgId = window.GNH_ACTIVE_ORG?.id; - unsubscribe = subscribeToJobUpdates(orgId, () => refresh()); +function updateAvatarFromState() { + if (!ui.profileAvatar) { + return; + } + + ui.profileAvatar.innerHTML = ""; + if (state.userAvatarUrl) { + const img = document.createElement("img"); + img.src = state.userAvatarUrl; + img.alt = state.userEmail || "Account"; + ui.profileAvatar.appendChild(img); + return; + } + + ui.profileAvatar.textContent = getInitials(state.userEmail || "Hover"); +} + +function renderUsage() { + if (!state.usage) { + if (ui.planNameText) { + ui.planNameText.innerHTML = "Plan: \u2014"; + } + setText(ui.planRemainingValue, "\u2014"); + return; + } + + const plan = state.usage.plan_display_name || state.usage.plan_name || "Plan"; + const limit = Number(state.usage.daily_limit || 0).toLocaleString(); + const remaining = Number(state.usage.daily_remaining || 0).toLocaleString(); + + if (ui.planNameText) { + ui.planNameText.innerHTML = `Plan: ${plan} (${limit} / day)`; + } + setText(ui.planRemainingValue, `${remaining} remaining`); +} + +function renderOrganisations() { + const select = ui.orgSelect; + if (!(select instanceof HTMLSelectElement)) { + return; + } + + select.innerHTML = ""; + + if (!state.organisations.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No organisations"; + select.appendChild(option); + select.disabled = true; + return; } - startSubscription(); - // Re-subscribe and refresh when the active org changes. - document.addEventListener("gnh:org-switched", () => { - refresh(); - startSubscription(); + select.disabled = false; + state.organisations.forEach((organisation) => { + const option = document.createElement("option"); + option.value = organisation.id; + option.textContent = organisation.name; + option.selected = organisation.id === state.activeOrganisationId; + select.appendChild(option); }); } -// ── Refresh ──────────────────────────────────────────────────────────────────── +function renderScheduleState() { + const select = ui.scheduleSelect; + if (!(select instanceof HTMLSelectElement)) { + return; + } + + if (!state.currentScheduler || !state.currentScheduler.is_enabled) { + select.value = SCHEDULE_PLACEHOLDER; + return; + } -async function refresh() { - await Promise.all([refreshStats(), refreshJobs()]); + const hours = String(state.currentScheduler.schedule_interval_hours); + select.value = SCHEDULE_OPTIONS.has(hours) ? hours : SCHEDULE_PLACEHOLDER; } -// ── Stats ────────────────────────────────────────────────────────────────────── +function clearNode(node) { + if (!node) return; + while (node.firstChild) { + node.removeChild(node.firstChild); + } +} -async function refreshStats() { - // Gate behind session — avoids a 401 when the module runs before core.js - // has signed in. - const token = await waitForSession(); - if (!token) return; - try { - const tzOffset = new Date().getTimezoneOffset(); - // api-client auto-unwraps the { status, data } envelope - const data = await get( - `/v1/dashboard/stats?range=${currentRange}&tzOffset=${tzOffset}` - ); - const stats = data?.stats; - if (!stats) return; +function renderNoJobState(message, canRun = false) { + setText(ui.noJobText, message); + if (ui.runFirstCheckButton) { + ui.runFirstCheckButton.hidden = !canRun; + } + show(ui.noJobState); +} - setStatCard("stats.total_jobs", formatCount(stats.total_jobs)); - setStatCard("stats.running_jobs", formatCount(stats.running_jobs)); - setStatCard("stats.completed_jobs", formatCount(stats.completed_jobs)); - setStatCard("stats.failed_jobs", formatCount(stats.failed_jobs)); - } catch { - // Non-fatal — stats cards stay at previous values +function hideNoJobState() { + if (ui.runFirstCheckButton) { + ui.runFirstCheckButton.hidden = false; } + hide(ui.noJobState); } -/** - * Update a stat card value by its gnh-text attribute selector. - * Falls back gracefully when the element doesn't exist. - */ -function setStatCard(key, value) { - const el = document.querySelector(`[gnh-text="${key}"]`); - if (el) el.textContent = value; +function renderJobState(job) { + const section = ui.jobSection; + if (!section) { + return; + } + + clearNode(section); + + if (!job || !isActiveJobStatus(job.status)) { + hide(section); + return; + } + + const card = createJobCard(job, { context: "extension" }); + card.addEventListener("hover-job-card:view", (event) => { + window.location.href = event.detail.path; + }); + card.addEventListener("hover-job-card:export", (event) => { + void exportJob(event.detail.jobId); + }); + section.appendChild(card); + show(section); } -// ── Jobs list ────────────────────────────────────────────────────────────────── +function renderRecentResults(jobs) { + const latestContainer = ui.latestResultsList; + const recentContainer = ui.recentResultsList; -/** @type {Map} jobId → card element, for in-place updates */ -const _jobCards = new Map(); + if (!latestContainer || !recentContainer) { + return; + } -async function refreshJobs() { - const container = document.querySelector(".gnh-jobs-list"); - if (!container) return; + clearNode(latestContainer); + clearNode(recentContainer); - const token = await waitForSession(); - if (!token) return; + const siteJobs = filterJobsByDomains(jobs, { + siteDomain: state.selectedDomain, + siteDomainCandidates: state.siteDomainCandidates, + }); - try { - const jobs = await fetchJobs({ - limit: 10, - range: currentRange, - include: "stats", + if (!state.selectedDomain) { + renderNoJobState("Select a site to review its latest report."); + return; + } + + if (siteJobs.length === 0) { + renderNoJobState(`No runs yet for ${state.selectedDomain}.`, true); + return; + } + + hideNoJobState(); + + const completedJobs = siteJobs.filter( + (job) => !isActiveJobStatus(job.status) + ); + + if (completedJobs.length === 0) { + const empty = document.createElement("p"); + empty.className = "detail"; + empty.textContent = "No completed runs yet."; + latestContainer.appendChild(empty); + return; + } + + const groupedJobs = completedJobs.slice(0, 6); + const latestJob = groupedJobs[0] || null; + const recentJobs = groupedJobs.slice(1, 6); + + function makeCard(cardJob, compact) { + const card = createJobCard(cardJob, { + context: "extension", + compact, + }); + card.addEventListener("hover-job-card:view", (event) => { + window.location.href = event.detail.path; }); - renderJobCards(container, jobs); - } catch { - // Non-fatal — existing cards stay visible + card.addEventListener("hover-job-card:export", (event) => { + void exportJob(event.detail.jobId); + }); + return card; } + + latestContainer.appendChild(makeCard(latestJob, false)); + recentJobs.forEach((job) => { + recentContainer.appendChild(makeCard(job, true)); + }); } -function renderJobCards(container, jobs) { - // Empty state - if (jobs.length === 0) { - _jobCards.forEach((card) => card.remove()); - _jobCards.clear(); +function renderMiniChart(jobs) { + const container = ui.miniChart; + if (!container) { + return; + } - let empty = container.querySelector(".jobs-empty-state"); - if (!empty) { - empty = document.createElement("p"); - empty.className = "jobs-empty-state detail"; - empty.textContent = "No jobs yet."; - container.appendChild(empty); - } + clearNode(container); + + const completedJobs = filterJobsByDomains(jobs, { + siteDomain: state.selectedDomain, + siteDomainCandidates: state.siteDomainCandidates, + }) + .filter((job) => normaliseJobStatus(job.status) === "completed") + .slice(0, 12); + + if (completedJobs.length === 0) { + ui.chartScaleLabels.forEach((label) => { + label.textContent = "0"; + }); return; } - // Remove empty state if present - container.querySelector(".jobs-empty-state")?.remove(); + const chartRows = completedJobs + .filter((job) => Boolean(job.stats)) + .map((job) => { + const { brokenLinks, verySlow, slow } = getIssueCounts(job); + const errorCount = brokenLinks; + const okCount = verySlow + slow; + const totalPages = Math.max(0, Number(job.total_tasks || 0)); + return { + job, + errorCount, + okCount, + issueTotal: errorCount + okCount, + totalPages, + }; + }) + .filter((row) => row.issueTotal > 0 && row.totalPages > 0) + .reverse(); + + if (!chartRows.length) { + ui.chartScaleLabels.forEach((label) => { + label.textContent = "0"; + }); + return; + } - // Track which job IDs are in the new response - const incoming = new Set(jobs.map((j) => j.id)); + const maxIssues = Math.max(...chartRows.map((row) => row.issueTotal), 1); + const ticks = [ + maxIssues, + Math.round(maxIssues * 0.5), + Math.round(maxIssues * 0.25), + 0, + ]; - // Remove cards no longer in the list - _jobCards.forEach((card, id) => { - if (!incoming.has(id)) { - card.remove(); - _jobCards.delete(id); + ui.chartScaleLabels.forEach((label, index) => { + label.textContent = String(ticks[index] ?? 0); + }); + + for (const row of chartRows) { + const bar = document.createElement("div"); + bar.className = "chart-bar"; + bar.role = "button"; + bar.tabIndex = 0; + bar.title = `${formatDateTime(row.job.completed_at || row.job.created_at)}\nStatus: Completed\nOK: ${row.okCount}\nError: ${row.errorCount}\nTotal pages: ${Number(row.job.total_tasks || 0).toLocaleString()}`; + + const detailPath = `${APP_ROUTES.viewJob}/${encodeURIComponent(row.job.id)}`; + const openDetail = () => { + window.location.href = detailPath; + }; + + bar.addEventListener("click", openDetail); + bar.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDetail(); + } + }); + + if (row.okCount > 0) { + const okSegment = document.createElement("div"); + okSegment.className = "chart-bar--warning"; + okSegment.style.height = `${Math.max(2, Math.min((row.okCount / maxIssues) * 100, 100))}%`; + bar.appendChild(okSegment); + } + + if (row.errorCount > 0) { + const errorSegment = document.createElement("div"); + errorSegment.className = "chart-bar--danger"; + errorSegment.style.height = `${Math.max(2, Math.min((row.errorCount / maxIssues) * 100, 100))}%`; + bar.appendChild(errorSegment); + } + + if (bar.children.length > 0) { + container.appendChild(bar); + } + } +} + +function setDisabledAll(disabled) { + [ + ui.settingsButton, + ui.orgSelect, + ui.domainInput, + ui.scheduleSelect, + ui.runNowButton, + ui.runFirstCheckButton, + ].forEach((control) => { + if ( + control instanceof HTMLButtonElement || + control instanceof HTMLInputElement || + control instanceof HTMLSelectElement + ) { + control.disabled = disabled; } }); +} - // Update existing cards in-place, append new ones in order - jobs.forEach((job, index) => { - const existing = _jobCards.get(job.id); - if (existing) { - // In-place update — no DOM removal, no flicker - existing.job = job; - // Ensure correct order - const cards = Array.from(container.querySelectorAll("hover-job-card")); - if (cards[index] !== existing) - container.insertBefore(existing, cards[index] ?? null); - } else { - const card = createJobCard(job, { context: "dashboard" }); - card.dataset.jobId = job.id; - - // Navigation — "All" button and "View all X" in issue tables - card.addEventListener("hover-job-card:view", (e) => { - window.location.href = e.detail.path; - }); +function updateDomainInput() { + if (ui.domainInput instanceof HTMLInputElement) { + ui.domainInput.value = state.selectedDomain || ""; + } +} - // Export - card.addEventListener("hover-job-card:export", (e) => { - exportJob(e.detail.jobId).catch((err) => - showToast(`Export failed: ${err.message}`, { variant: "error" }) - ); - }); +function persistSelectedDomain() { + if (state.selectedDomain) { + window.localStorage.setItem( + SELECTED_DOMAIN_STORAGE_KEY, + state.selectedDomain + ); + return; + } + window.localStorage.removeItem(SELECTED_DOMAIN_STORAGE_KEY); +} - // Restart - card.addEventListener("hover-job-card:restart", (e) => { - restartJob(e.detail.job).catch((err) => - showToast(`Restart failed: ${err.message}`, { variant: "error" }) - ); - }); +function applySelectedDomain(domain) { + const nextDomain = normaliseDomain(domain); + if (nextDomain !== state.selectedDomain) { + state.lastCompletedJobsSignature = ""; + state.lastChartJobsSignature = ""; + } + + state.selectedDomain = nextDomain; + state.siteDomainCandidates = state.selectedDomain + ? [state.selectedDomain] + : []; + persistSelectedDomain(); + updateDomainInput(); +} + +async function waitForSupabaseClient(timeoutMs = 5000) { + const start = Date.now(); - // Cancel - card.addEventListener("hover-job-card:cancel", (e) => { - cancelJob(e.detail.jobId).catch((err) => - showToast(`Cancel failed: ${err.message}`, { variant: "error" }) - ); + while (Date.now() - start < timeoutMs) { + try { + return ensureSupabaseClient(); + } catch (_error) { + await new Promise((resolve) => { + window.setTimeout(resolve, 50); }); + } + } - // Insert at correct position - const cards = Array.from(container.querySelectorAll("hover-job-card")); - container.insertBefore(card, cards[index] ?? null); - _jobCards.set(job.id, card); + throw new Error("Supabase client did not initialise in time."); +} + +async function ensureSelectedDomain() { + const availableDomains = getDomains(); + if (state.selectedDomain) { + return; + } + + const stored = normaliseDomain( + window.localStorage.getItem(SELECTED_DOMAIN_STORAGE_KEY) || "" + ); + if (stored) { + applySelectedDomain(stored); + return; + } + + if (availableDomains.length > 0) { + applySelectedDomain(availableDomains[0].name); + } +} + +async function loadOrganisationState() { + const context = await loadOrganisationContext(); + state.organisations = Array.isArray(context.organisations) + ? context.organisations + : []; + state.activeOrganisationId = context.activeOrganisationId || ""; + state.usage = context.usage || null; + if (state.activeOrganisationId) { + window.localStorage.setItem( + ACTIVE_ORG_STORAGE_KEY, + state.activeOrganisationId + ); + } +} + +async function loadCurrentSchedule() { + if (!state.selectedDomain) { + state.currentScheduler = null; + renderScheduleState(); + return; + } + + state.currentScheduler = await findSchedulerByDomain(state.selectedDomain); + renderScheduleState(); +} + +async function refreshSiteResults() { + const jobs = await fetchJobs({ limit: 50, include: "stats" }); + + if (!state.selectedDomain) { + const firstJobDomain = normaliseDomain( + jobs[0]?.domains?.name || jobs[0]?.domain || "" + ); + if (firstJobDomain) { + applySelectedDomain(firstJobDomain); } + } + + state.currentJob = pickLatestJobByDomains(jobs, { + siteDomain: state.selectedDomain, + siteDomainCandidates: state.siteDomainCandidates, }); + + renderJobState(state.currentJob); + + const completedSignature = buildCompletedJobsSignature( + jobs, + { + siteDomain: state.selectedDomain, + siteDomainCandidates: state.siteDomainCandidates, + }, + isActiveJobStatus + ); + + if (completedSignature !== state.lastCompletedJobsSignature) { + renderRecentResults(jobs); + state.lastCompletedJobsSignature = completedSignature; + } + + const chartSignature = buildChartJobsSignature(jobs, { + siteDomain: state.selectedDomain, + siteDomainCandidates: state.siteDomainCandidates, + }); + + if (chartSignature !== state.lastChartJobsSignature) { + renderMiniChart(jobs); + state.lastChartJobsSignature = chartSignature; + } +} + +function cleanupJobSubscription() { + if (jobsSubscriptionCleanup) { + jobsSubscriptionCleanup(); + jobsSubscriptionCleanup = null; + } +} + +function startJobSubscription() { + cleanupJobSubscription(); + if (!state.activeOrganisationId) { + return; + } + + jobsSubscriptionCleanup = subscribeToJobUpdates({ + orgId: state.activeOrganisationId, + onUpdate: () => { + void refreshDashboard({ silent: true }); + }, + }); +} + +async function refreshDashboard(options = {}) { + if (state.refreshing) { + return; + } + + state.refreshing = true; + if (!options.silent) { + setDisabledAll(true); + } + + try { + await loadOrganisationState(); + await loadOrganisationDomains(); + await ensureSelectedDomain(); + await Promise.all([loadCurrentSchedule(), refreshSiteResults()]); + renderUsage(); + renderOrganisations(); + updateAvatarFromState(); + renderAuthState(true); + startJobSubscription(); + } catch (error) { + console.error("dashboard: failed to refresh", error); + showToast(error.message || "Failed to refresh the dashboard.", { + variant: "error", + }); + } finally { + if (!options.silent) { + setDisabledAll(false); + } + state.refreshing = false; + } +} + +async function resolveSelectedDomain({ allowCreate = false } = {}) { + const rawValue = + ui.domainInput instanceof HTMLInputElement ? ui.domainInput.value : ""; + const nextValue = normaliseDomain(rawValue || state.selectedDomain || ""); + if (!nextValue) { + applySelectedDomain(""); + await loadCurrentSchedule(); + renderJobState(null); + renderRecentResults([]); + renderMiniChart([]); + return ""; + } + + if (allowCreate) { + const ensured = await ensureDomainByName(nextValue, { allowCreate: true }); + applySelectedDomain(ensured?.name || nextValue); + } else { + applySelectedDomain(nextValue); + } + + return state.selectedDomain; +} + +async function handleDomainCommit({ allowCreate = false } = {}) { + await resolveSelectedDomain({ allowCreate }); + await Promise.all([loadCurrentSchedule(), refreshSiteResults()]); +} + +async function switchOrganisation() { + if (!(ui.orgSelect instanceof HTMLSelectElement) || !ui.orgSelect.value) { + return; + } + + setDisabledAll(true); + try { + await switchOrganisationApi(ui.orgSelect.value); + state.activeOrganisationId = ui.orgSelect.value; + window.localStorage.setItem(ACTIVE_ORG_STORAGE_KEY, ui.orgSelect.value); + applySelectedDomain(""); + await refreshDashboard(); + } finally { + setDisabledAll(false); + } +} + +async function runNow() { + const domain = await resolveSelectedDomain({ allowCreate: true }); + if (!domain) { + showToast("Enter a site domain first.", { variant: "error" }); + return; + } + + setDisabledAll(true); + try { + await post("/v1/jobs", { + domain, + max_pages: 0, + use_sitemap: true, + find_links: true, + }); + setStatus("Run started.", `Checking ${domain}.`); + showToast(`Run started for ${domain}`, { variant: "success" }); + await refreshDashboard({ silent: true }); + } catch (error) { + console.error("dashboard: failed to run job", error); + showToast(error.message || "Failed to start the run.", { + variant: "error", + }); + } finally { + setDisabledAll(false); + } } async function exportJob(jobId) { @@ -277,279 +794,210 @@ async function exportJob(jobId) { const keys = Object.keys(tasks[0]); const csv = [ keys.join(","), - ...tasks.map((t) => keys.map((k) => csvEscape(t[k])).join(",")), + ...tasks.map((task) => keys.map((key) => csvEscape(task[key])).join(",")), ].join("\n"); + const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `job-${jobId}.csv`; - a.click(); + const link = document.createElement("a"); + link.href = url; + link.download = `job-${jobId}.csv`; + link.click(); URL.revokeObjectURL(url); showToast("Export downloaded.", { variant: "success" }); - } catch (err) { - throw err; + } catch (error) { + showToast(`Export failed: ${error.message}`, { variant: "error" }); } } -function csvEscape(val) { - if (val == null) return ""; - const str = String(val); - return str.includes(",") || str.includes('"') || str.includes("\n") - ? `"${str.replace(/"/g, '""')}"` - : str; -} - -// ── Create job modal ─────────────────────────────────────────────────────────── - -function openCreateJobModal() { - const modal = document.getElementById("createJobModal"); - if (modal) modal.style.display = "flex"; -} - -function closeCreateJobModal() { - const modal = document.getElementById("createJobModal"); - if (modal) modal.style.display = "none"; - const form = document.getElementById("createJobForm"); - if (form) { - form.reset(); - const maxPages = document.getElementById("maxPages"); - if (maxPages) maxPages.value = "0"; - } +function csvEscape(value) { + if (value == null) return ""; + const text = String(value); + return text.includes(",") || text.includes('"') || text.includes("\n") + ? `"${text.replace(/"/g, '""')}"` + : text; } -// ── Job creation ──────────────────────────────────────────────────────────────── - -async function handleJobCreation(event) { - event.preventDefault(); - const formData = new FormData(event.target); - - let domain = formData.get("domain"); - const maxPages = parseInt(formData.get("max_pages")); - const concurrencyValue = formData.get("concurrency"); - const scheduleInterval = formData.get("schedule_interval_hours"); - - if (!domain) { - showToast("Domain is required", { variant: "error" }); +async function setJobSchedule() { + if (!(ui.scheduleSelect instanceof HTMLSelectElement)) { return; } - // Ensure domain exists (creates if needed) - try { - const ensuredDomain = await ensureDomainByName(domain, { - allowCreate: true, - }); - if (ensuredDomain?.name) domain = ensuredDomain.name; - } catch (error) { - showToast(error.message || "Failed to create domain.", { - variant: "error", - }); + const requested = ui.scheduleSelect.value; + if (!SCHEDULE_OPTIONS.has(requested)) { + ui.scheduleSelect.value = SCHEDULE_PLACEHOLDER; return; } - const domainField = document.getElementById("jobDomain"); - if (domainField) domainField.value = domain; + const domain = await resolveSelectedDomain({ + allowCreate: requested !== SCHEDULE_PLACEHOLDER, + }); - if (maxPages < 0 || maxPages > 10000) { - showToast("Maximum pages must be between 0 and 10,000", { + if (!domain) { + showToast("Enter a site domain before changing the schedule.", { variant: "error", }); + renderScheduleState(); return; } - const requestBody = { - domain, - max_pages: maxPages, - use_sitemap: true, - find_links: true, - }; - if ( - concurrencyValue && - concurrencyValue !== "" && - concurrencyValue !== "default" - ) { - requestBody.concurrency = parseInt(concurrencyValue); - } - + setDisabledAll(true); try { - if (scheduleInterval && scheduleInterval !== "") { - const hours = parseInt(scheduleInterval); - if (isNaN(hours) || ![6, 12, 24, 48].includes(hours)) { - showToast( - "Invalid schedule interval. Must be 6, 12, 24, or 48 hours.", - { - variant: "error", - } - ); - return; - } - - const scheduler = await saveSchedulerForDomain(domain, hours, { - extra: { - max_pages: maxPages, - find_links: true, - concurrency: requestBody.concurrency || 20, - }, - }); - - try { - await post("/v1/jobs", { ...requestBody, scheduler_id: scheduler.id }); - } catch (jobError) { - console.error( - "Failed to create initial job, cleaning up scheduler:", - jobError - ); - try { - await deleteScheduler(scheduler.id); - } catch (cleanupError) { - console.error("Failed to clean up scheduler:", cleanupError); - } - throw jobError; + if (requested === SCHEDULE_PLACEHOLDER) { + if (state.currentScheduler?.id) { + await disableScheduler(state.currentScheduler.id, { + expectedIsEnabled: state.currentScheduler.is_enabled, + }); } - - closeCreateJobModal(); - showToast(`Scheduled job created for ${domain} (every ${hours} hours)`, { - variant: "success", - }); - } else { - await post("/v1/jobs", requestBody); - - const df = document.getElementById("jobDomain"); - const mp = document.getElementById("maxPages"); - const si = document.getElementById("scheduleInterval"); - if (df) df.value = ""; - if (mp) mp.value = "0"; - if (si) si.value = ""; - - closeCreateJobModal(); - showToast(`Job created for ${domain}`, { variant: "success" }); + state.currentScheduler = null; + renderScheduleState(); + setStatus("Scheduler disabled.", `No recurring run for ${domain}.`); + return; } - await refresh(); + const scheduler = await saveSchedulerForDomain(domain, Number(requested), { + currentScheduler: state.currentScheduler, + extra: { + max_pages: 0, + find_links: true, + concurrency: 20, + }, + }); + state.currentScheduler = scheduler; + renderScheduleState(); + setStatus("Scheduler updated.", `Running every ${requested} hours.`); } catch (error) { - console.error("Failed to create job:", error); - showToast(error.message || "Failed to create job. Please try again.", { + console.error("dashboard: failed to save schedule", error); + renderScheduleState(); + showToast(error.message || "Failed to save the schedule.", { variant: "error", }); + } finally { + setDisabledAll(false); } } -// ── Job actions ──────────────────────────────────────────────────────────────── - -async function restartJob(job) { - try { - await post("/v1/jobs", { - domain: job.domains?.name || job.domain, - max_pages: job.max_pages ?? 0, - use_sitemap: true, - find_links: job.find_links ?? true, - concurrency: job.concurrency, - }); - showToast("Job restarted.", { variant: "success" }); - await refresh(); - } catch (err) { - showToast(`Failed to restart job: ${err.message}`, { variant: "error" }); +function bindDomainSearch() { + if (!(ui.domainInput instanceof HTMLInputElement)) { + return; } -} -async function cancelJob(jobId) { - try { - await put(`/v1/jobs/${encodeURIComponent(jobId)}`, { action: "cancel" }); - showToast("Job cancelled.", { variant: "warning" }); - await refresh(); - } catch (err) { - showToast(`Failed to cancel job: ${err.message}`, { variant: "error" }); - } -} - -// ── Network monitoring ────────────────────────────────────────────────────────── - -function updateNetworkStatus() { - const indicator = document.querySelector(".status-indicator"); - if (!indicator) return; - if (navigator.onLine) { - indicator.textContent = ""; - const dot = document.createElement("span"); - dot.className = "status-dot"; - const label = document.createElement("span"); - label.textContent = "Live"; - indicator.appendChild(dot); - indicator.appendChild(label); - } else { - indicator.textContent = ""; - const dot = document.createElement("span"); - dot.className = "status-dot"; - dot.style.background = "#ef4444"; - const label = document.createElement("span"); - label.textContent = "Offline"; - indicator.appendChild(dot); - indicator.appendChild(label); - } -} + setupDomainSearchInput({ + input: ui.domainInput, + container: ui.domainInput.parentElement, + clearOnSelect: false, + onSelectDomain: async (domain) => { + applySelectedDomain(domain.name); + await Promise.all([loadCurrentSchedule(), refreshSiteResults()]); + }, + onCreateDomain: async (domain) => { + applySelectedDomain(domain.name); + await Promise.all([loadCurrentSchedule(), refreshSiteResults()]); + }, + onError: (message) => { + showToast(message || "Failed to create domain.", { variant: "error" }); + }, + }); -function setupNetworkMonitoring() { - updateNetworkStatus(); + ui.domainInput.addEventListener("change", () => { + void handleDomainCommit(); + }); + ui.domainInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleDomainCommit(); + } + }); +} - window.addEventListener("online", () => { - updateNetworkStatus(); - showToast("Connection restored. Refreshing data...", { - variant: "success", - }); - setTimeout(() => refresh(), 500); +function bindEvents() { + ui.loginButton?.addEventListener("click", () => openAuth("login")); + ui.signupButton?.addEventListener("click", () => openAuth("signup")); + ui.settingsButton?.addEventListener("click", () => { + window.location.assign(APP_ROUTES.settings); + }); + ui.orgSelect?.addEventListener("change", () => { + void switchOrganisation(); + }); + ui.scheduleSelect?.addEventListener("change", () => { + void setJobSchedule(); + }); + ui.runNowButton?.addEventListener("click", () => { + void runNow(); + }); + ui.runFirstCheckButton?.addEventListener("click", () => { + void runNow(); + }); + ui.feedbackButton?.addEventListener("click", () => { + window.location.assign(APP_ROUTES.feedback); + }); + ui.helpButton?.addEventListener("click", () => { + window.location.assign(APP_ROUTES.help); }); - window.addEventListener("offline", () => { - updateNetworkStatus(); - showToast("Connection lost. Some features may not work.", { - variant: "error", - }); + window.addEventListener("storage", (event) => { + if ( + event.key === ACTIVE_ORG_STORAGE_KEY && + event.newValue && + event.newValue !== state.activeOrganisationId + ) { + state.activeOrganisationId = event.newValue; + applySelectedDomain(""); + void refreshDashboard({ silent: true }); + } }); } -// ── Helpers ──────────────────────────────────────────────────────────────────── +async function syncAuthState(session) { + state.session = session; + state.userEmail = session?.user?.email || ""; + state.userAvatarUrl = session?.user?.user_metadata?.avatar_url || ""; + updateAvatarFromState(); -/** - * Wait for window.supabase to be initialised and have an active session. - * Returns the access token, or null if no session within the timeout. - * @param {number} [timeoutMs=8000] - * @returns {Promise} - */ -function waitForSession(timeoutMs = 8000) { - return new Promise((resolve) => { - const start = Date.now(); - const check = async () => { - try { - const { data } = await window.supabase?.auth?.getSession(); - const token = data?.session?.access_token; - if (token) { - resolve(token); - return; - } - } catch { - /* not ready yet */ - } - if (Date.now() - start > timeoutMs) { - resolve(null); - return; - } - setTimeout(check, 200); - }; - check(); - }); + if (!session) { + cleanupJobSubscription(); + renderAuthState(false); + return; + } + + renderAuthState(true); + await refreshDashboard(); } -// ── Entry point ──────────────────────────────────────────────────────────────── +async function init() { + if (initialised) { + return; + } + initialised = true; + + bindEvents(); + bindDomainSearch(); + await waitForSupabaseClient(); + + const initialSession = await getSession().catch(() => null); + await syncAuthState(initialSession); + + authSubscriptionCleanup = onAuthStateChange((event, nextSession) => { + if (event === "SIGNED_OUT") { + state.selectedDomain = ""; + persistSelectedDomain(); + } + void syncAuthState(nextSession); + }); +} -// Initialise after DOM is ready. waitForSession() inside refresh() handles -// the Supabase timing — no dependency on gnh-bootstrap.js or GNH_APP.whenReady. if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => - init().catch(console.error) - ); + document.addEventListener("DOMContentLoaded", () => { + void init(); + }); } else { - init().catch(console.error); + void init(); } -// ── Legacy bridges ───────────────────────────────────────────────────────────── -// Expose refresh for external callers (e.g. global-nav org-switch). -window.HoverDashboard = { refresh }; +window.HoverDashboard = { + refresh: () => refreshDashboard(), + destroy: () => { + authSubscriptionCleanup?.(); + cleanupJobSubscription(); + }, +}; diff --git a/web/static/app/pages/webflow-login.js b/web/static/app/pages/webflow-login.js index 64bc962cc..6e3a82c1e 100644 --- a/web/static/app/pages/webflow-login.js +++ b/web/static/app/pages/webflow-login.js @@ -33,6 +33,8 @@ const TURNSTILE_SCRIPT_SRC = const MAX_TURNSTILE_RETRIES = 2; const POPUP_AUTH_TARGET_ORIGIN_STORAGE_KEY = "gnh_extension_auth_target_origin"; const POPUP_AUTH_STATE_STORAGE_KEY = "gnh_extension_auth_state"; +const AUTH_RETURN_TO_STORAGE_KEY = "gnh_auth_return_to"; +const REQUESTED_MODE_STORAGE_KEY = "gnh_auth_requested_mode"; /** @type {import("@supabase/supabase-js").SupabaseClient|null} */ let supabaseClient = null; @@ -52,6 +54,14 @@ let extensionState = getPopupContextValue( POPUP_AUTH_STATE_STORAGE_KEY, SEARCH_PARAMS.get("extension_state") || SEARCH_PARAMS.get("state") || "" ); +let returnTo = getPopupContextValue( + AUTH_RETURN_TO_STORAGE_KEY, + SEARCH_PARAMS.get("return_to") || "" +); +let requestedMode = getPopupContextValue( + REQUESTED_MODE_STORAGE_KEY, + SEARCH_PARAMS.get("mode") || "login" +); // ── Element references ───────────────────────────────────────────────────────── @@ -74,7 +84,7 @@ async function init() { return; } - persistPopupContext(); + persistAuthContext(); setStatus("Preparing sign-in…"); try { @@ -94,7 +104,7 @@ async function init() { return; } - showLogin(); + showInitialAuthForm(); const reopenBtn = el("reopenModalBtn"); if (reopenBtn) { @@ -125,6 +135,13 @@ async function handleAuthenticated(session) { } if (!window.opener) { + clearAuthContext(); + const destination = resolveReturnDestination(); + if (destination) { + window.location.replace(destination); + return; + } + setStatus("Signed in — you can close this window.", "success"); showToast("Signed in successfully.", { variant: "success", duration: 0 }); return; @@ -147,7 +164,7 @@ async function handleAuthenticated(session) { ); setStatus("Signed in — you can close this window.", "success"); - clearPopupContext(); + clearAuthContext(); showToast("Signed in successfully.", { variant: "success", duration: 0 }); } catch (err) { console.error("webflow-login: postMessage failed", err); @@ -582,31 +599,87 @@ function getPopupContextValue(storageKey, fallbackValue) { } } -function persistPopupContext() { - if (!targetOrigin || !extensionState) { - return; - } - +function persistAuthContext() { try { - window.sessionStorage.setItem( - POPUP_AUTH_TARGET_ORIGIN_STORAGE_KEY, - targetOrigin - ); - window.sessionStorage.setItem(POPUP_AUTH_STATE_STORAGE_KEY, extensionState); + if (targetOrigin) { + window.sessionStorage.setItem( + POPUP_AUTH_TARGET_ORIGIN_STORAGE_KEY, + targetOrigin + ); + } + if (extensionState) { + window.sessionStorage.setItem( + POPUP_AUTH_STATE_STORAGE_KEY, + extensionState + ); + } + if (returnTo) { + window.sessionStorage.setItem(AUTH_RETURN_TO_STORAGE_KEY, returnTo); + } + if (requestedMode) { + window.sessionStorage.setItem(REQUESTED_MODE_STORAGE_KEY, requestedMode); + } } catch (_error) { // Ignore storage failures. } } -function clearPopupContext() { +function clearAuthContext() { try { window.sessionStorage.removeItem(POPUP_AUTH_TARGET_ORIGIN_STORAGE_KEY); window.sessionStorage.removeItem(POPUP_AUTH_STATE_STORAGE_KEY); + window.sessionStorage.removeItem(AUTH_RETURN_TO_STORAGE_KEY); + window.sessionStorage.removeItem(REQUESTED_MODE_STORAGE_KEY); } catch (_error) { // Ignore storage failures. } } +function resolveRequestedMode() { + const mode = String(requestedMode || "login") + .trim() + .toLowerCase(); + if (mode === "signup" || mode === "reset") { + return mode; + } + return "login"; +} + +function showInitialAuthForm() { + const mode = resolveRequestedMode(); + if (mode === "signup") { + setStatus("Create your account to continue."); + showForm("signup"); + return; + } + + if (mode === "reset") { + setStatus("Reset your password to continue."); + showForm("reset"); + return; + } + + showLogin(); +} + +function resolveReturnDestination() { + if (!returnTo) { + return ""; + } + + try { + const destination = new URL(returnTo, window.location.origin); + if (destination.origin !== window.location.origin) { + console.warn("webflow-login: ignoring cross-origin return_to", returnTo); + return ""; + } + return destination.toString(); + } catch (error) { + console.warn("webflow-login: invalid return_to", error); + return ""; + } +} + function hasOAuthCallbackQuery(urlParams) { return ( urlParams.has("code") || @@ -841,7 +914,7 @@ async function handleSocialLogin(provider) { clearAuthError(); try { - persistPopupContext(); + persistAuthContext(); const { data, error } = await supabaseClient.auth.signInWithOAuth({ provider, diff --git a/web/static/app/styles/dashboard-extension.css b/web/static/app/styles/dashboard-extension.css index 712565034..ddcfb4a6a 100644 --- a/web/static/app/styles/dashboard-extension.css +++ b/web/static/app/styles/dashboard-extension.css @@ -1,11 +1,9 @@ -/* ─── Dashboard Surface — extension-aligned shared styles ────────────────── - * - * Depends on tokens.css, base.css, and components.css being loaded first. - * Applies the Webflow extension visual language to /dashboard while keeping - * dashboard-specific layout hooks explicit. - * ────────────────────────────────────────────────────────────────────────── */ +/* Dashboard shell aligned to the Webflow extension surface. */ body.page-dashboard { + margin: 0; + min-height: 100vh; + font-family: var(--font-family--primary); color: var(--text-colour--primary); background: radial-gradient( @@ -21,440 +19,685 @@ body.page-dashboard { ); } -body.page-dashboard .global-nav .header { - background: rgba(3, 4, 35, 0.92); - border-bottom: var(--border-size--thin) solid var(--border-colour--active); - backdrop-filter: blur(18px); +body.page-dashboard .dashboard-shell { + min-height: 100vh; } -body.page-dashboard .global-nav .container { - max-width: 1240px; - padding: 0 var(--spacing--6xl); +body.page-dashboard .app-shell { + padding: 0; + display: flex; + flex-direction: column; } -body.page-dashboard .global-nav .logo, -body.page-dashboard .global-nav .logo:hover, -body.page-dashboard .global-nav .nav-title, -body.page-dashboard .global-nav .user-info, -body.page-dashboard .global-nav .gnh-org-single { - color: var(--text-colour--primary); +body.page-dashboard .panel { + width: min(1120px, calc(100vw - 48px)); + margin: 24px auto; + display: flex; + flex-direction: column; + min-height: calc(100vh - 48px); + overflow: hidden; + border: 1px solid var(--border-colour--default); + box-shadow: var(--shadow--overlay); + background: rgba(3, 4, 35, 0.96); } -body.page-dashboard .global-nav .nav-separator { - color: var(--text-colour--disabled); +body.page-dashboard .section { + padding: 12px 18px; + border-bottom: 1px solid rgba(142, 218, 239, 0.32); } -body.page-dashboard .global-nav .gnh-org-switcher-btn, -body.page-dashboard .global-nav .gnh-org-create-btn, -body.page-dashboard .global-nav .gnh-dropdown-item, -body.page-dashboard .global-nav .gnh-notifications-footer-btn, -body.page-dashboard .global-nav .gnh-button, -body.page-dashboard .global-nav .gnh-button-outline, -body.page-dashboard .global-nav .gnh-button-primary { - color: var(--colour--brand-primary); - background: var(--background-colour--canvas); - border-color: var(--border-colour--active); +body.page-dashboard .hidden { + display: none !important; } -body.page-dashboard .global-nav .gnh-org-switcher-btn:hover, -body.page-dashboard .global-nav .gnh-org-create-btn:hover, -body.page-dashboard .global-nav .gnh-dropdown-item:hover, -body.page-dashboard .global-nav .gnh-notifications-footer-btn:hover, -body.page-dashboard .global-nav .gnh-button-outline:hover { - background: var(--background-colour--hover-subtle); - border-color: var(--border-colour--strong); +body.page-dashboard .header { + padding: 16px 18px 14px; + border-bottom: none; + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing--xl); + flex-shrink: 0; } -body.page-dashboard .global-nav .gnh-button-primary { - color: var(--text-colour--on-brand); - background: var(--background-colour--brand); - border-color: var(--background-colour--brand); +body.page-dashboard .top-brand { + display: flex; + align-items: center; + justify-content: center; + width: 100%; } -body.page-dashboard .global-nav .gnh-button-primary:hover { - background: var(--background-colour--brand-hover); - border-color: var(--background-colour--brand-hover); +body.page-dashboard .brand-logo { + display: block; + width: 112px; + max-width: 100%; + height: auto; } -body.page-dashboard .global-nav .gnh-org-dropdown, -body.page-dashboard .global-nav .gnh-dropdown-menu, -body.page-dashboard .global-nav .gnh-notifications-dropdown { - background: var(--background-colour--panel); - border: var(--border-size--default) solid var(--border-colour--default); - box-shadow: var(--shadow--overlay); +body.page-dashboard .section-title { + margin: 0; + font-size: 14px; + line-height: 1.35; + font-weight: 700; + color: var(--colour--brand-primary); } -body.page-dashboard .global-nav .gnh-org-dropdown-header, -body.page-dashboard .global-nav .gnh-org-dropdown-footer, -body.page-dashboard .global-nav .gnh-dropdown-divider, -body.page-dashboard .global-nav .gnh-notifications-header, -body.page-dashboard .global-nav .gnh-notifications-footer { - border-color: var(--border-colour--default); +body.page-dashboard .auth-wrapper { + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-between; + padding: 18px 14px 16px; } -body.page-dashboard .global-nav .gnh-org-item, -body.page-dashboard .global-nav .gnh-notification-item { - color: var(--text-colour--primary); +body.page-dashboard .dashboard-auth-wrapper { + min-height: 560px; } -body.page-dashboard .global-nav .gnh-org-item:hover, -body.page-dashboard .global-nav .gnh-notification-item:hover { - background: var(--background-colour--hover-subtle); +body.page-dashboard .auth-header { + padding: 0; + flex-direction: column; + align-items: stretch; + gap: 18px; } -body.page-dashboard .global-nav .gnh-org-item.active { - background: rgba(97, 208, 239, 0.18); - color: var(--colour--brand-primary); +body.page-dashboard .icon-list { + margin: 18px 0; + padding-left: 0; + list-style: none; } -body.page-dashboard .main-content { - max-width: 1240px; - margin: 0 auto; - padding: var(--spacing--7xl) var(--spacing--6xl) var(--spacing--8xl); +body.page-dashboard .icon-list li { display: flex; - flex-direction: column; - gap: var(--spacing--6xl); + align-items: flex-start; + gap: 8px; + margin: 0 0 12px; + font-size: 13.82px; + line-height: 16px; + color: var(--text-colour--secondary); } -.dashboard-surface-card { - background: linear-gradient( - 180deg, - rgba(16, 26, 69, 0.96) 0%, - rgba(3, 4, 35, 0.98) 100% - ); - border: var(--border-size--default) solid var(--border-colour--default); - border-radius: calc(var(--radius--control) * 2); - box-shadow: var(--shadow--overlay); +body.page-dashboard .icon { + display: inline-block; + width: 1em; + height: 1em; + flex-shrink: 0; + background-color: currentcolor; + -webkit-mask-position: center; + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: contain; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; +} + +body.page-dashboard .icon--medium { + width: 1em; + height: 1em; +} + +body.page-dashboard .icon--large { + width: 1.15em; + height: 1.15em; +} + +body.page-dashboard .icon--small { + width: 16px; + height: 11px; +} + +body.page-dashboard .icon--broken { + -webkit-mask-image: url("/app/icons/Icon_BrokenLink.svg"); + mask-image: url("/app/icons/Icon_BrokenLink.svg"); +} + +body.page-dashboard .icon--download { + -webkit-mask-image: url("/app/icons/Icon_Download.svg"); + mask-image: url("/app/icons/Icon_Download.svg"); +} + +body.page-dashboard .icon--person { + -webkit-mask-image: url("/app/icons/Icon_Person.svg"); + mask-image: url("/app/icons/Icon_Person.svg"); } -.dashboard-quick-run { - padding: var(--spacing--7xl); +body.page-dashboard .icon--bee { + -webkit-mask-image: url("/app/icons/Icon_Bee.svg"); + mask-image: url("/app/icons/Icon_Bee.svg"); } -.dashboard-quick-run__header { +body.page-dashboard .icon--arrow { + -webkit-mask-image: url("/app/icons/Icon_Arrow.svg"); + mask-image: url("/app/icons/Icon_Arrow.svg"); +} + +body.page-dashboard .icon--arrow--right { + transform: rotate(0deg); +} + +body.page-dashboard .actions { display: flex; + flex-wrap: wrap; + gap: var(--spacing--lg); + margin-top: var(--spacing--xl); +} + +body.page-dashboard .auth-actions { flex-direction: column; - gap: var(--spacing--sm); - margin-bottom: var(--spacing--6xl); + gap: 14px; + margin-top: 0; } -.dashboard-quick-run__title { - margin: 0; - font-size: calc(var(--font-size--display) * 1.25); - font-weight: 700; - color: var(--colour--brand-primary); +body.page-dashboard .auth-login-prompt { + display: flex; + flex-direction: column; + gap: 18px; + padding: 6px 0 0; } -.dashboard-quick-run__copy { +body.page-dashboard .auth-login-divider { + width: 100%; + border-top: 0.5px solid var(--background-colour--brand); +} + +body.page-dashboard .auth-login-copy { margin: 0; + font-size: 13px; + line-height: 1.4; + text-align: center; color: var(--text-colour--secondary); } -.dashboard-quick-run__form { - display: grid; - grid-template-columns: minmax(0, 1.8fr) repeat(3, minmax(0, 0.8fr)) auto; - gap: var(--spacing--6xl); - align-items: end; +body.page-dashboard .btn { + appearance: none; + -webkit-appearance: none; + border: 2px solid transparent; + border-radius: var(--radius--control); + font-family: var(--font-family--primary); + letter-spacing: var(--letter-spacing--default); + cursor: pointer; + white-space: nowrap; + transition: + background var(--motion-duration--fast) var(--motion-easing--standard), + border-color var(--motion-duration--fast) var(--motion-easing--standard), + color var(--motion-duration--fast) var(--motion-easing--standard); } -.dashboard-field { - display: flex; - flex-direction: column; +body.page-dashboard .btn:not(select):not(input) { + display: inline-flex; + align-items: center; + justify-content: center; gap: var(--spacing--sm); - min-width: 0; } -.dashboard-field__label { - font-size: var(--font-size--caption); - font-weight: 700; - color: var(--text-colour--secondary); - letter-spacing: 0.03em; - text-transform: uppercase; +body.page-dashboard .btn:disabled { + opacity: var(--opacity--disabled); + cursor: not-allowed; } -.dashboard-field__control { - width: 100%; - min-height: 44px; - padding: 0 var(--spacing--4xl); - border: var(--border-size--default) solid var(--border-colour--active); - border-radius: var(--radius--control); - background: rgba(3, 4, 35, 0.78); - color: var(--text-colour--primary); - font: inherit; +body.page-dashboard .btn--primary { + background: var(--background-colour--brand); + color: var(--text-colour--on-brand); + border-color: var(--background-colour--brand); } -.dashboard-field__control:focus { - outline: none; - border-color: var(--colour--brand-primary-hover); - box-shadow: 0 0 0 3px rgba(142, 218, 239, 0.14); +body.page-dashboard .btn--primary:not(:disabled):hover { + background: var(--background-colour--brand-hover); + border-color: var(--background-colour--brand-hover); } -.dashboard-field__control::placeholder { - color: var(--text-colour--disabled); +body.page-dashboard .btn--secondary { + background: rgba(97, 208, 239, 0.35); + color: var(--colour--brand-primary); + border-color: var(--background-colour--brand); +} + +body.page-dashboard .btn--secondary:not(:disabled):hover { + background: var(--background-colour--hover-subtle); + border-color: var(--border-colour--strong); } -.dashboard-field__control option { +body.page-dashboard .btn--tertiary { + background-color: var(--background-colour--canvas); + color: var(--colour--brand-primary); + border-color: var(--background-colour--brand); +} + +body.page-dashboard .btn--tertiary:not(:disabled):hover { + background-color: var(--background-colour--canvas); + color: var(--colour--brand-primary); +} + +body.page-dashboard .btn--ghost { + background: transparent; color: var(--text-colour--primary); - background: var(--background-colour--panel); + border-color: transparent; +} + +body.page-dashboard .btn--ghost:not(:disabled):hover { + color: var(--text-colour--secondary); +} + +body.page-dashboard .btn--lg { + min-height: 30px; + padding: 4px 10px; + font-size: 13.82px; + line-height: 1; + font-weight: 700; + letter-spacing: 0.02em; + min-width: 100%; + justify-content: center; + gap: 4px; +} + +body.page-dashboard .btn--sm { + min-width: 0; + height: 36px; + padding: 0 12px; + font-size: var(--font-size--button-primary); + font-weight: 700; + line-height: 1; + gap: 3px; + box-shadow: var(--shadow--surface); } -.dashboard-quick-run__submit { - min-height: 44px; - padding: 0 var(--spacing--6xl); +body.page-dashboard .btn--icon-square { + width: 36px; + height: 36px; + padding: 0; + flex-shrink: 0; +} + +body.page-dashboard .btn--border-thin { + border-width: var(--border-size--default); +} + +body.page-dashboard .btn--square { + border-radius: 0; +} + +body.page-dashboard .btn--footer-link { + padding: 0; + font-size: var(--font-size--title); + font-weight: 400; + gap: var(--spacing--3xl); +} + +body.page-dashboard .btn--select { + cursor: pointer; + appearance: none; + -webkit-appearance: none; + outline: none; + padding-right: var(--spacing--7xl) !important; + padding-left: var(--spacing--lg) !important; + text-overflow: ellipsis; white-space: nowrap; + overflow: hidden; + background-image: var(--control-icon--chevron); + background-repeat: no-repeat; + background-position: right var(--spacing--lg) center; + background-size: 8px 5px; } -body.page-dashboard .dashboard-actions, -body.page-dashboard .gnh-jobs-section, -body.page-dashboard .gnh-stat-card, -body.page-dashboard .dashboard-login-card, -body.page-dashboard .gnh-modal-content { - background: linear-gradient( - 180deg, - rgba(16, 26, 69, 0.96) 0%, - rgba(3, 4, 35, 0.98) 100% - ); - border: var(--border-size--default) solid var(--border-colour--default); - box-shadow: var(--shadow--overlay); +body.page-dashboard .corners--right { + border-top-left-radius: 0; + border-top-right-radius: var(--radius--notch); + border-bottom-right-radius: var(--radius--notch); + border-bottom-left-radius: 0; } -body.page-dashboard .dashboard-actions { - margin-bottom: 0; - padding: var(--spacing--6xl); - border-radius: calc(var(--radius--control) * 2); +body.page-dashboard .corners--top-left { + border-radius: var(--radius--notch) 0 0 0; } -body.page-dashboard .dashboard-filters { - gap: var(--spacing--6xl); +body.page-dashboard .topbar { + display: flex; + align-items: center; + gap: var(--spacing--2xl); + flex-shrink: 0; + background: var(--background-colour--panel); } -body.page-dashboard .filter-group { - gap: var(--spacing--sm); +body.page-dashboard .topbar-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing--2xl); + flex: 1; + min-width: 0; } -body.page-dashboard .filter-label { - font-size: var(--font-size--caption); +body.page-dashboard .topbar-plan { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--spacing--md); + flex-shrink: 0; +} + +body.page-dashboard .topbar-profile { + justify-content: center; + background: transparent; + border-color: var(--border-colour--default); + box-shadow: none; + padding: 0; + overflow: hidden; +} + +body.page-dashboard .topbar-profile-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: var(--colour--brand-primary); + font-size: 0.7rem; font-weight: 700; - color: var(--text-colour--secondary); letter-spacing: 0.03em; + color: #fff; + overflow: hidden; } -body.page-dashboard .filter-select, -body.page-dashboard .gnh-form-input { - border: var(--border-size--default) solid var(--border-colour--active); - border-radius: var(--radius--control); - background: rgba(3, 4, 35, 0.78); +body.page-dashboard .topbar-profile-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +body.page-dashboard .topbar-org-select { + width: 240px; + max-width: 100%; +} + +body.page-dashboard .plan-badge { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +body.page-dashboard .plan-badge-title, +body.page-dashboard .plan-badge-remaining { + margin: 0; + font-size: var(--font-size--caption); color: var(--text-colour--primary); } -body.page-dashboard .filter-select:focus, -body.page-dashboard .gnh-form-input:focus { - border-color: var(--colour--brand-primary-hover); - box-shadow: 0 0 0 3px rgba(142, 218, 239, 0.14); +body.page-dashboard .plan-badge-remaining { + display: flex; + align-items: center; + gap: var(--spacing--md); } -body.page-dashboard .gnh-btn, -body.page-dashboard .gnh-button { - min-height: 38px; - border-radius: var(--radius--control); - border: var(--border-size--default) solid transparent; - font: inherit; - font-weight: 700; - transition: - background var(--motion-duration--fast) var(--motion-easing--standard), - border-color var(--motion-duration--fast) var(--motion-easing--standard), - color var(--motion-duration--fast) var(--motion-easing--standard); +body.page-dashboard .action-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing--3xl); + flex-shrink: 0; + background: var(--background-colour--panel); } -body.page-dashboard .gnh-btn-primary, -body.page-dashboard .gnh-button-primary { - color: var(--text-colour--on-brand); - background: var(--background-colour--brand); - border-color: var(--background-colour--brand); +body.page-dashboard .action-bar-controls { + display: flex; + align-items: center; + gap: var(--spacing--3xl); + min-width: 0; + flex: 1; } -body.page-dashboard .gnh-btn-primary:hover, -body.page-dashboard .gnh-button-primary:hover { - background: var(--background-colour--brand-hover); - border-color: var(--background-colour--brand-hover); +body.page-dashboard .control-group { + display: flex; + align-items: center; + gap: var(--spacing--lg); + min-width: 0; +} + +body.page-dashboard .control-group--domain { + flex: 1; } -body.page-dashboard .gnh-btn-secondary, -body.page-dashboard .gnh-button-outline { +body.page-dashboard .control-label { + font-size: var(--font-size--label); color: var(--colour--brand-primary); - background: rgba(97, 208, 239, 0.12); - border-color: var(--border-colour--active); + white-space: nowrap; +} + +body.page-dashboard .dashboard-domain-search { + position: relative; + min-width: 0; + flex: 1; +} + +body.page-dashboard .dashboard-domain-input { + width: 100%; + box-sizing: border-box; + text-align: left; +} + +body.page-dashboard .action-bar-select { + width: 90px; + min-width: 90px; } -body.page-dashboard .gnh-btn-secondary:hover, -body.page-dashboard .gnh-button-outline:hover { +body.page-dashboard .status-block { + flex-shrink: 0; + opacity: 1; + transition: opacity 0.5s var(--motion-easing--standard); background: var(--background-colour--hover-subtle); - border-color: var(--border-colour--strong); } -body.page-dashboard .page-actions { - gap: var(--spacing--xl); +body.page-dashboard .status-block:not(:has(p:not(:empty))) { + display: none; } -body.page-dashboard .gnh-stats-grid { - gap: var(--spacing--6xl); - margin-bottom: 0; +body.page-dashboard .status-block--fading { + opacity: 0; } -body.page-dashboard .gnh-stat-card { - padding: var(--spacing--7xl); - border-radius: calc(var(--radius--control) * 2); - text-align: left; - position: relative; - overflow: hidden; +body.page-dashboard .status-block-content { + display: flex; + flex-direction: column; + gap: var(--spacing--2xs); + padding: var(--spacing--md) var(--spacing--xl); } -body.page-dashboard .gnh-stat-card::before { - content: ""; - position: absolute; - inset: 0 auto 0 0; - width: 4px; - background: linear-gradient( - 180deg, - var(--colour--brand-primary) 0%, - rgba(97, 208, 239, 0.15) 100% - ); -} - -body.page-dashboard .gnh-stat-value { - font-size: clamp(2rem, 5vw, 2.6rem); - margin-bottom: var(--spacing--sm); +body.page-dashboard .status { + margin: 0; + font-size: var(--font-size--label); + font-weight: 700; color: var(--colour--brand-primary); } -body.page-dashboard .gnh-stat-label { +body.page-dashboard .detail { + margin: 0; + font-size: var(--font-size--body); color: var(--text-colour--secondary); - letter-spacing: 0.06em; } -body.page-dashboard .gnh-jobs-section { - border-radius: calc(var(--radius--control) * 2); - margin-bottom: 0; +body.page-dashboard .content-scroll { + flex: 1; + background-color: var(--colour--neutral-800); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--spacing--3xl); + padding-right: 0; +} + +body.page-dashboard .dot { + display: inline-block; + width: var(--size--dot-default); + height: var(--size--dot-default); + border-radius: var(--radius--round); + flex-shrink: 0; } -body.page-dashboard .gnh-section-header { - padding: var(--spacing--7xl) var(--spacing--7xl) 0; - margin-bottom: var(--spacing--6xl); +body.page-dashboard .dot--success { + background: var(--status-colour--success); } -body.page-dashboard .gnh-section-header h3 { - color: var(--text-colour--primary); +body.page-dashboard .job-band { + display: flex; + flex-direction: column; + gap: var(--spacing--lg); + width: calc(100% + 36px); + margin: -12px -18px 0; + padding: 12px 18px; + background: var(--background-colour--hover-subtle); } -body.page-dashboard .gnh-jobs-list { - padding: 0 var(--spacing--7xl) var(--spacing--7xl); +body.page-dashboard .job-band:not(:has(#jobSection:not(.hidden))) { + display: none; } -.dashboard-login-card { - max-width: 430px; - margin: 80px auto; - padding: var(--spacing--8xl); - border-radius: calc(var(--radius--control) * 2); +body.page-dashboard .no-job-state { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing--xl); + padding: var(--spacing--7xl) 0; } -.dashboard-login-card__header { - text-align: center; - margin-bottom: var(--spacing--7xl); +body.page-dashboard .results-section { + display: flex; + flex-direction: column; + gap: var(--spacing--3xl); } -.dashboard-login-card__icon { - font-size: 44px; - margin-bottom: var(--spacing--xl); +body.page-dashboard .section-heading { + margin: 0; + font-size: var(--font-size--title); + font-weight: 400; + color: var(--text-colour--heading); } -.dashboard-login-card__title { - margin: 0 0 var(--spacing--sm); - font-size: 2rem; - font-weight: 700; - color: var(--colour--brand-primary); +body.page-dashboard .results-list { + display: flex; + flex-direction: column; + gap: var(--spacing--3xl); } -.dashboard-login-card__copy, -.dashboard-helper-text { - margin: 0; - font-size: var(--font-size--caption); - line-height: 1.5; +body.page-dashboard .chart-section { + padding: var(--spacing--xl) 0; +} + +body.page-dashboard .chart-layout { + display: grid; + grid-template-columns: 32px 1fr; + align-items: end; + gap: var(--spacing--sm); +} + +body.page-dashboard .chart-y-scale { + height: var(--size--chart-height); + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + font-size: var(--font-size--label); color: var(--text-colour--secondary); + line-height: 1; } -.dashboard-login-card__actions { +body.page-dashboard .mini-chart { + position: relative; + height: var(--size--chart-height); display: flex; - gap: var(--spacing--xl); - margin-bottom: var(--spacing--6xl); + align-items: flex-end; + justify-content: center; + gap: var(--spacing--8xl); + padding-left: var(--spacing--sm); } -.dashboard-login-card__actions .gnh-button { - flex: 1; +body.page-dashboard .mini-chart::before { + content: ""; + position: absolute; + left: 0; + bottom: 0; + top: 0; + width: var(--border-size--default); + background: var(--text-colour--secondary); } -.dashboard-modal-actions { +body.page-dashboard .mini-chart::after { + content: ""; + position: absolute; + left: 0; + bottom: 0; + right: 0; + height: var(--border-size--default); + background: var(--text-colour--secondary); +} + +body.page-dashboard .chart-bar { display: flex; - gap: var(--spacing--xl); + flex-direction: column; justify-content: flex-end; - margin-top: var(--spacing--8xl); + align-items: stretch; + width: var(--size--chart-bar-width); + height: 100%; + cursor: pointer; } -body.page-dashboard .gnh-modal-content { - border-radius: calc(var(--radius--control) * 2); +body.page-dashboard .chart-bar:focus-visible { + outline: var(--border-size--default) solid var(--border-colour--strong); + outline-offset: var(--spacing--2xs); } -body.page-dashboard .gnh-modal-title, -body.page-dashboard .gnh-form-group label { - color: var(--text-colour--primary); +body.page-dashboard .chart-bar--danger { + background: transparent; + border: var(--border-size--default) solid var(--status-colour--danger); } -body.page-dashboard .gnh-modal-close { - color: var(--text-colour--secondary); +body.page-dashboard .chart-bar--warning { + background: transparent; + border: var(--border-size--default) solid var(--status-colour--warning); } -body.page-dashboard .gnh-modal-close:hover { - color: var(--text-colour--primary); +body.page-dashboard .panel-footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; } -@media (max-width: 980px) { - .dashboard-quick-run__form { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .dashboard-quick-run__submit { - width: 100%; - grid-column: 1 / -1; - } +body.page-dashboard hover-job-card { + display: block; } -@media (max-width: 768px) { - body.page-dashboard .global-nav .container, - body.page-dashboard .main-content { - padding-left: var(--spacing--4xl); - padding-right: var(--spacing--4xl); +@media (max-width: 860px) { + body.page-dashboard .panel { + width: calc(100vw - 24px); + margin: 12px auto; + min-height: calc(100vh - 24px); } - body.page-dashboard .dashboard-actions { + body.page-dashboard .topbar, + body.page-dashboard .action-bar, + body.page-dashboard .action-bar-controls { flex-direction: column; align-items: stretch; } - body.page-dashboard .dashboard-filters, - .dashboard-login-card__actions, - .dashboard-modal-actions { + body.page-dashboard .topbar-content { flex-direction: column; + align-items: stretch; + } + + body.page-dashboard .topbar-org-select, + body.page-dashboard .dashboard-domain-search, + body.page-dashboard .dashboard-domain-input, + body.page-dashboard .action-bar-select { + width: 100%; + max-width: 100%; } - .dashboard-quick-run, - .dashboard-login-card, - body.page-dashboard .gnh-stat-card, - body.page-dashboard .gnh-section-header, - body.page-dashboard .gnh-jobs-list { - padding-left: var(--spacing--6xl); - padding-right: var(--spacing--6xl); + body.page-dashboard .plan-badge { + align-items: flex-start; } - .dashboard-quick-run__form { - grid-template-columns: 1fr; + body.page-dashboard .panel-footer { + gap: var(--spacing--lg); + flex-wrap: wrap; } } From b1fd95fab36f558ce526cd5be2530511f94207b7 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:39:40 +1000 Subject: [PATCH 18/41] Share site view helpers --- web/static/app/lib/site-view.js | 477 ++++++++++++++++ web/static/app/pages/dashboard.js | 362 +++--------- .../public/lib/bridge.js | 2 + .../scripts/sync-shared.js | 1 + webflow-designer-extension-cli/src/index.ts | 514 ++++-------------- 5 files changed, 666 insertions(+), 690 deletions(-) create mode 100644 web/static/app/lib/site-view.js diff --git a/web/static/app/lib/site-view.js b/web/static/app/lib/site-view.js new file mode 100644 index 000000000..9613a955a --- /dev/null +++ b/web/static/app/lib/site-view.js @@ -0,0 +1,477 @@ +/** + * lib/site-view.js — shared site-surface rendering helpers + * + * Shared between the dashboard and Webflow Designer extension. + * Owns common topbar/render logic for usage, orgs, avatar, job cards, + * recent results, and the mini chart. Surface-specific bootstrapping and + * action handlers remain local to each entrypoint. + */ + +import { createJobCard } from "/app/components/hover-job-card.js"; +import { formatDateTime, getInitials } from "/app/lib/formatters.js"; +import { filterJobsByDomains } from "/app/lib/site-jobs.js"; + +function clearNode(node) { + if (!node) return; + while (node.firstChild) { + node.removeChild(node.firstChild); + } +} + +function asCount(value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return 0; + } + return Math.max(0, Math.floor(value)); +} + +function getNoJobTextTarget(noJobState, noJobText) { + if (noJobText) { + return noJobText; + } + return noJobState?.querySelector?.(".detail") || null; +} + +function show(node) { + node?.classList?.remove("hidden"); +} + +function hide(node) { + node?.classList?.add("hidden"); +} + +function setText(node, value) { + if (node) { + node.textContent = value; + } +} + +function getIssueCounts(job) { + const buckets = job.stats?.slow_page_buckets; + const statsBrokenLinks = asCount(job.stats?.total_broken_links); + const fallbackBrokenLinks = asCount(job.failed_tasks); + + if (job.stats && buckets) { + const verySlow = asCount(buckets.over_10s) + asCount(buckets["5_to_10s"]); + const slow = asCount(buckets["3_to_5s"]); + return { + brokenLinks: Math.max(statsBrokenLinks, fallbackBrokenLinks), + verySlow, + slow, + }; + } + + return { + brokenLinks: fallbackBrokenLinks, + verySlow: 0, + slow: 0, + }; +} + +async function getGravatarUrl(email, size = 80) { + const normalised = (email || "").trim().toLowerCase(); + if (!normalised || !globalThis.crypto?.subtle) return ""; + + try { + const data = new TextEncoder().encode(normalised); + const digest = await globalThis.crypto.subtle.digest("SHA-256", data); + const hash = [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + const params = new URLSearchParams({ s: String(size), d: "404" }); + return `https://www.gravatar.com/avatar/${hash}?${params.toString()}`; + } catch { + return ""; + } +} + +function makeResultCard(job, options = {}) { + const card = createJobCard(job, { + context: options.context || "extension", + compact: Boolean(options.compact), + }); + + if (typeof options.onViewJob === "function") { + card.addEventListener("hover-job-card:view", (event) => { + options.onViewJob(event.detail.path, job); + }); + } + + if (typeof options.onExportJob === "function") { + card.addEventListener("hover-job-card:export", (event) => { + options.onExportJob(event.detail.jobId, job); + }); + } + + return card; +} + +export async function renderUserAvatar(options = {}) { + const { element, displayName = "", email = "", avatarUrl = "" } = options; + if (!element) { + return; + } + + const initials = getInitials(displayName || email || "?"); + const existingImg = element.querySelector("img"); + if (existingImg) { + existingImg.remove(); + } + + element.textContent = initials; + + const resolvedAvatarUrl = avatarUrl || (await getGravatarUrl(email, 80)); + if (!resolvedAvatarUrl) { + return; + } + + const img = document.createElement("img"); + img.src = resolvedAvatarUrl; + img.alt = displayName || email || "User avatar"; + img.loading = "lazy"; + img.decoding = "async"; + img.addEventListener( + "load", + () => { + element.textContent = ""; + element.appendChild(img); + }, + { once: true } + ); + img.addEventListener( + "error", + () => { + if (img.parentNode) img.parentNode.removeChild(img); + element.textContent = initials; + }, + { once: true } + ); +} + +export function renderUsage(options = {}) { + const { usage, planNameText, planRemainingValue } = options; + if (!usage) { + if (planNameText) { + planNameText.innerHTML = "Plan: \u2014"; + } + setText(planRemainingValue, "\u2014"); + return; + } + + const plan = usage.plan_display_name || usage.plan_name || "Plan"; + const limit = Number(usage.daily_limit || 0).toLocaleString(); + const remaining = Number(usage.daily_remaining || 0).toLocaleString(); + + if (planNameText) { + planNameText.innerHTML = `Plan: ${plan} (${limit} / day)`; + } + setText(planRemainingValue, `${remaining} remaining`); +} + +export function renderOrganisations(options = {}) { + const { + select, + organisations = [], + activeOrganisationId = "", + emptyLabel = "No organisations", + } = options; + if (!(select instanceof HTMLSelectElement)) { + return; + } + + clearNode(select); + + if (!organisations.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = emptyLabel; + select.appendChild(option); + select.disabled = true; + return; + } + + select.disabled = false; + organisations.forEach((organisation) => { + const option = document.createElement("option"); + option.value = organisation.id; + option.textContent = organisation.name; + option.selected = organisation.id === activeOrganisationId; + select.appendChild(option); + }); +} + +export function renderScheduleState(options = {}) { + const { + select, + currentScheduler, + placeholder = "off", + allowedValues = ["off", "6", "12", "24", "48"], + } = options; + if (!(select instanceof HTMLSelectElement)) { + return; + } + + if (!currentScheduler || !currentScheduler.is_enabled) { + select.value = placeholder; + return; + } + + const allowed = new Set(allowedValues); + const hours = String(currentScheduler.schedule_interval_hours); + select.value = allowed.has(hours) ? hours : placeholder; +} + +export function renderJobState(options = {}) { + const { + jobSection, + job, + isActiveJobStatus, + context = "extension", + onViewJob, + onExportJob, + } = options; + if (!jobSection) { + return; + } + + clearNode(jobSection); + + if (!job || !isActiveJobStatus?.(job.status)) { + hide(jobSection); + return; + } + + const card = makeResultCard(job, { + context, + onViewJob, + onExportJob, + }); + jobSection.appendChild(card); + show(jobSection); +} + +export function renderRecentResults(options = {}) { + const { + latestResultsList, + recentResultsList, + noJobState, + noJobText, + noJobActionButton, + jobs = [], + siteDomain = "", + siteDomainCandidates = [], + isActiveJobStatus, + context = "extension", + onViewJob, + onExportJob, + emptySelectionMessage = "Select a site to review its latest report.", + emptySiteMessage = "No runs yet for this site.", + emptyCompletedMessage = "No completed runs yet.", + showEmptyAction = false, + } = options; + + if (!latestResultsList || !recentResultsList) { + return; + } + + clearNode(latestResultsList); + clearNode(recentResultsList); + + const siteJobs = filterJobsByDomains(jobs, { + siteDomain, + siteDomainCandidates, + }); + const noJobTextTarget = getNoJobTextTarget(noJobState, noJobText); + + if ( + !siteDomain && + (!siteDomainCandidates || siteDomainCandidates.length === 0) + ) { + setText(noJobTextTarget, emptySelectionMessage); + if (noJobActionButton) { + noJobActionButton.hidden = true; + } + show(noJobState); + return; + } + + if (siteJobs.length === 0) { + setText(noJobTextTarget, emptySiteMessage); + if (noJobActionButton) { + noJobActionButton.hidden = !showEmptyAction; + } + show(noJobState); + return; + } + + if (noJobActionButton) { + noJobActionButton.hidden = false; + } + hide(noJobState); + + const completedJobs = siteJobs.filter( + (job) => !isActiveJobStatus?.(job.status) + ); + + if (completedJobs.length === 0) { + const empty = document.createElement("p"); + empty.className = "detail"; + empty.textContent = emptyCompletedMessage; + latestResultsList.appendChild(empty); + return; + } + + const groupedJobs = completedJobs.slice(0, 6); + const latestJob = groupedJobs[0] || null; + const recentJobs = groupedJobs.slice(1, 6); + + if (latestJob) { + latestResultsList.appendChild( + makeResultCard(latestJob, { + context, + onViewJob, + onExportJob, + }) + ); + } + + recentJobs.forEach((job) => { + recentResultsList.appendChild( + makeResultCard(job, { + context, + compact: true, + onViewJob, + onExportJob, + }) + ); + }); +} + +export function renderMiniChart(options = {}) { + const { + miniChart, + chartScaleLabels = [], + jobs = [], + siteDomain = "", + siteDomainCandidates = [], + onViewJob, + } = options; + if (!miniChart) { + return; + } + + clearNode(miniChart); + + const completedJobs = filterJobsByDomains(jobs, { + siteDomain, + siteDomainCandidates, + }) + .filter( + (job) => + String(job.status || "") + .trim() + .toLowerCase() === "completed" + ) + .slice(0, 12); + + if (completedJobs.length === 0) { + chartScaleLabels.forEach((label) => { + label.textContent = "0"; + }); + return; + } + + const chartRows = completedJobs + .filter((job) => Boolean(job.stats)) + .map((job) => { + const { brokenLinks, verySlow, slow } = getIssueCounts(job); + const errorCount = brokenLinks; + const okCount = verySlow + slow; + const totalPages = Math.max(0, Number(job.total_tasks || 0)); + return { + job, + errorCount, + okCount, + issueTotal: errorCount + okCount, + totalPages, + }; + }) + .filter((row) => row.issueTotal > 0 && row.totalPages > 0) + .reverse(); + + if (chartRows.length === 0) { + chartScaleLabels.forEach((label) => { + label.textContent = "0"; + }); + return; + } + + const maxIssues = Math.max(...chartRows.map((row) => row.issueTotal), 1); + const ticks = [ + maxIssues, + Math.round(maxIssues * 0.5), + Math.round(maxIssues * 0.25), + 0, + ]; + + chartScaleLabels.forEach((label, index) => { + label.textContent = String(ticks[index] ?? 0); + }); + + for (const row of chartRows) { + const bar = document.createElement("div"); + bar.className = "chart-bar"; + bar.role = "button"; + bar.tabIndex = 0; + bar.title = `${formatDateTime(row.job.completed_at || row.job.created_at)}\nStatus: Completed\nOK: ${row.okCount}\nError: ${row.errorCount}\nTotal pages: ${Number(row.job.total_tasks || 0).toLocaleString()}`; + + const detailPath = `/jobs/${encodeURIComponent(row.job.id)}`; + const openDetail = () => { + if (typeof onViewJob === "function") { + onViewJob(detailPath, row.job); + } + }; + + bar.addEventListener("click", openDetail); + bar.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openDetail(); + } + }); + + if (row.okCount > 0) { + const okSegment = document.createElement("div"); + okSegment.className = "chart-bar--warning"; + okSegment.style.height = `${Math.max( + 2, + Math.min((row.okCount / maxIssues) * 100, 100) + )}%`; + bar.appendChild(okSegment); + } + + if (row.errorCount > 0) { + const errorSegment = document.createElement("div"); + errorSegment.className = "chart-bar--danger"; + errorSegment.style.height = `${Math.max( + 2, + Math.min((row.errorCount / maxIssues) * 100, 100) + )}%`; + bar.appendChild(errorSegment); + } + + if (bar.children.length > 0) { + miniChart.appendChild(bar); + } + } +} + +export default { + renderJobState, + renderMiniChart, + renderOrganisations, + renderRecentResults, + renderScheduleState, + renderUsage, + renderUserAvatar, +}; diff --git a/web/static/app/pages/dashboard.js b/web/static/app/pages/dashboard.js index 4f4748bf5..d029900ce 100644 --- a/web/static/app/pages/dashboard.js +++ b/web/static/app/pages/dashboard.js @@ -8,13 +8,20 @@ import { get, post } from "/app/lib/api-client.js"; import { onAuthStateChange, getSession } from "/app/lib/auth-session.js"; -import { createJobCard } from "/app/components/hover-job-card.js"; import { showToast } from "/app/components/hover-toast.js"; -import { formatDateTime, getInitials } from "/app/lib/formatters.js"; import { loadOrganisationContext, switchOrganisation as switchOrganisationApi, } from "/app/lib/organisation-api.js"; +import { + renderJobState as renderSharedJobState, + renderMiniChart as renderSharedMiniChart, + renderOrganisations as renderSharedOrganisations, + renderRecentResults as renderSharedRecentResults, + renderScheduleState as renderSharedScheduleState, + renderUsage as renderSharedUsage, + renderUserAvatar, +} from "/app/lib/site-view.js"; import { findSchedulerByDomain, saveSchedulerForDomain, @@ -25,7 +32,6 @@ import { buildChartJobsSignature, buildCompletedJobsSignature, fetchJobs, - filterJobsByDomains, normaliseDomain, pickLatestJobByDomains, subscribeToJobUpdates, @@ -134,35 +140,6 @@ function isActiveJobStatus(status) { return ACTIVE_JOB_STATUSES.has(normaliseJobStatus(status)); } -function asCount(value) { - if (typeof value !== "number" || !Number.isFinite(value)) { - return 0; - } - return Math.max(0, Math.floor(value)); -} - -function getIssueCounts(job) { - const buckets = job.stats?.slow_page_buckets; - const statsBrokenLinks = asCount(job.stats?.total_broken_links); - const fallbackBrokenLinks = asCount(job.failed_tasks); - - if (job.stats && buckets) { - const verySlow = asCount(buckets.over_10s) + asCount(buckets["5_to_10s"]); - const slow = asCount(buckets["3_to_5s"]); - return { - brokenLinks: Math.max(statsBrokenLinks, fallbackBrokenLinks), - verySlow, - slow, - }; - } - - return { - brokenLinks: fallbackBrokenLinks, - verySlow: 0, - slow: 0, - }; -} - function buildAuthUrl(mode = "login") { const authUrl = new URL(APP_ROUTES.auth, window.location.origin); authUrl.searchParams.set("return_to", window.location.href); @@ -210,291 +187,90 @@ function renderAuthState(isAuthed) { hide(ui.authState); } -function updateAvatarFromState() { - if (!ui.profileAvatar) { - return; - } - - ui.profileAvatar.innerHTML = ""; - if (state.userAvatarUrl) { - const img = document.createElement("img"); - img.src = state.userAvatarUrl; - img.alt = state.userEmail || "Account"; - ui.profileAvatar.appendChild(img); - return; - } - - ui.profileAvatar.textContent = getInitials(state.userEmail || "Hover"); +async function updateAvatarFromState() { + await renderUserAvatar({ + element: ui.profileAvatar, + displayName: state.userEmail, + email: state.userEmail, + avatarUrl: state.userAvatarUrl, + }); } function renderUsage() { - if (!state.usage) { - if (ui.planNameText) { - ui.planNameText.innerHTML = "Plan: \u2014"; - } - setText(ui.planRemainingValue, "\u2014"); - return; - } - - const plan = state.usage.plan_display_name || state.usage.plan_name || "Plan"; - const limit = Number(state.usage.daily_limit || 0).toLocaleString(); - const remaining = Number(state.usage.daily_remaining || 0).toLocaleString(); - - if (ui.planNameText) { - ui.planNameText.innerHTML = `Plan: ${plan} (${limit} / day)`; - } - setText(ui.planRemainingValue, `${remaining} remaining`); + renderSharedUsage({ + usage: state.usage, + planNameText: ui.planNameText, + planRemainingValue: ui.planRemainingValue, + }); } function renderOrganisations() { - const select = ui.orgSelect; - if (!(select instanceof HTMLSelectElement)) { - return; - } - - select.innerHTML = ""; - - if (!state.organisations.length) { - const option = document.createElement("option"); - option.value = ""; - option.textContent = "No organisations"; - select.appendChild(option); - select.disabled = true; - return; - } - - select.disabled = false; - state.organisations.forEach((organisation) => { - const option = document.createElement("option"); - option.value = organisation.id; - option.textContent = organisation.name; - option.selected = organisation.id === state.activeOrganisationId; - select.appendChild(option); + renderSharedOrganisations({ + select: ui.orgSelect, + organisations: state.organisations, + activeOrganisationId: state.activeOrganisationId, }); } function renderScheduleState() { - const select = ui.scheduleSelect; - if (!(select instanceof HTMLSelectElement)) { - return; - } - - if (!state.currentScheduler || !state.currentScheduler.is_enabled) { - select.value = SCHEDULE_PLACEHOLDER; - return; - } - - const hours = String(state.currentScheduler.schedule_interval_hours); - select.value = SCHEDULE_OPTIONS.has(hours) ? hours : SCHEDULE_PLACEHOLDER; -} - -function clearNode(node) { - if (!node) return; - while (node.firstChild) { - node.removeChild(node.firstChild); - } -} - -function renderNoJobState(message, canRun = false) { - setText(ui.noJobText, message); - if (ui.runFirstCheckButton) { - ui.runFirstCheckButton.hidden = !canRun; - } - show(ui.noJobState); -} - -function hideNoJobState() { - if (ui.runFirstCheckButton) { - ui.runFirstCheckButton.hidden = false; - } - hide(ui.noJobState); + renderSharedScheduleState({ + select: ui.scheduleSelect, + currentScheduler: state.currentScheduler, + placeholder: SCHEDULE_PLACEHOLDER, + allowedValues: [...SCHEDULE_OPTIONS], + }); } function renderJobState(job) { - const section = ui.jobSection; - if (!section) { - return; - } - - clearNode(section); - - if (!job || !isActiveJobStatus(job.status)) { - hide(section); - return; - } - - const card = createJobCard(job, { context: "extension" }); - card.addEventListener("hover-job-card:view", (event) => { - window.location.href = event.detail.path; - }); - card.addEventListener("hover-job-card:export", (event) => { - void exportJob(event.detail.jobId); + renderSharedJobState({ + jobSection: ui.jobSection, + job, + isActiveJobStatus, + context: "extension", + onViewJob: (path) => { + window.location.href = path; + }, + onExportJob: (jobId) => { + void exportJob(jobId); + }, }); - section.appendChild(card); - show(section); } function renderRecentResults(jobs) { - const latestContainer = ui.latestResultsList; - const recentContainer = ui.recentResultsList; - - if (!latestContainer || !recentContainer) { - return; - } - - clearNode(latestContainer); - clearNode(recentContainer); - - const siteJobs = filterJobsByDomains(jobs, { + renderSharedRecentResults({ + latestResultsList: ui.latestResultsList, + recentResultsList: ui.recentResultsList, + noJobState: ui.noJobState, + noJobText: ui.noJobText, + noJobActionButton: ui.runFirstCheckButton, + jobs, siteDomain: state.selectedDomain, siteDomainCandidates: state.siteDomainCandidates, - }); - - if (!state.selectedDomain) { - renderNoJobState("Select a site to review its latest report."); - return; - } - - if (siteJobs.length === 0) { - renderNoJobState(`No runs yet for ${state.selectedDomain}.`, true); - return; - } - - hideNoJobState(); - - const completedJobs = siteJobs.filter( - (job) => !isActiveJobStatus(job.status) - ); - - if (completedJobs.length === 0) { - const empty = document.createElement("p"); - empty.className = "detail"; - empty.textContent = "No completed runs yet."; - latestContainer.appendChild(empty); - return; - } - - const groupedJobs = completedJobs.slice(0, 6); - const latestJob = groupedJobs[0] || null; - const recentJobs = groupedJobs.slice(1, 6); - - function makeCard(cardJob, compact) { - const card = createJobCard(cardJob, { - context: "extension", - compact, - }); - card.addEventListener("hover-job-card:view", (event) => { - window.location.href = event.detail.path; - }); - card.addEventListener("hover-job-card:export", (event) => { - void exportJob(event.detail.jobId); - }); - return card; - } - - latestContainer.appendChild(makeCard(latestJob, false)); - recentJobs.forEach((job) => { - recentContainer.appendChild(makeCard(job, true)); + isActiveJobStatus, + context: "extension", + onViewJob: (path) => { + window.location.href = path; + }, + onExportJob: (jobId) => { + void exportJob(jobId); + }, + emptySelectionMessage: "Select a site to review its latest report.", + emptySiteMessage: `No runs yet for ${state.selectedDomain}.`, + showEmptyAction: Boolean(state.selectedDomain), }); } function renderMiniChart(jobs) { - const container = ui.miniChart; - if (!container) { - return; - } - - clearNode(container); - - const completedJobs = filterJobsByDomains(jobs, { + renderSharedMiniChart({ + miniChart: ui.miniChart, + chartScaleLabels: ui.chartScaleLabels, + jobs, siteDomain: state.selectedDomain, siteDomainCandidates: state.siteDomainCandidates, - }) - .filter((job) => normaliseJobStatus(job.status) === "completed") - .slice(0, 12); - - if (completedJobs.length === 0) { - ui.chartScaleLabels.forEach((label) => { - label.textContent = "0"; - }); - return; - } - - const chartRows = completedJobs - .filter((job) => Boolean(job.stats)) - .map((job) => { - const { brokenLinks, verySlow, slow } = getIssueCounts(job); - const errorCount = brokenLinks; - const okCount = verySlow + slow; - const totalPages = Math.max(0, Number(job.total_tasks || 0)); - return { - job, - errorCount, - okCount, - issueTotal: errorCount + okCount, - totalPages, - }; - }) - .filter((row) => row.issueTotal > 0 && row.totalPages > 0) - .reverse(); - - if (!chartRows.length) { - ui.chartScaleLabels.forEach((label) => { - label.textContent = "0"; - }); - return; - } - - const maxIssues = Math.max(...chartRows.map((row) => row.issueTotal), 1); - const ticks = [ - maxIssues, - Math.round(maxIssues * 0.5), - Math.round(maxIssues * 0.25), - 0, - ]; - - ui.chartScaleLabels.forEach((label, index) => { - label.textContent = String(ticks[index] ?? 0); + onViewJob: (path) => { + window.location.href = path; + }, }); - - for (const row of chartRows) { - const bar = document.createElement("div"); - bar.className = "chart-bar"; - bar.role = "button"; - bar.tabIndex = 0; - bar.title = `${formatDateTime(row.job.completed_at || row.job.created_at)}\nStatus: Completed\nOK: ${row.okCount}\nError: ${row.errorCount}\nTotal pages: ${Number(row.job.total_tasks || 0).toLocaleString()}`; - - const detailPath = `${APP_ROUTES.viewJob}/${encodeURIComponent(row.job.id)}`; - const openDetail = () => { - window.location.href = detailPath; - }; - - bar.addEventListener("click", openDetail); - bar.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - openDetail(); - } - }); - - if (row.okCount > 0) { - const okSegment = document.createElement("div"); - okSegment.className = "chart-bar--warning"; - okSegment.style.height = `${Math.max(2, Math.min((row.okCount / maxIssues) * 100, 100))}%`; - bar.appendChild(okSegment); - } - - if (row.errorCount > 0) { - const errorSegment = document.createElement("div"); - errorSegment.className = "chart-bar--danger"; - errorSegment.style.height = `${Math.max(2, Math.min((row.errorCount / maxIssues) * 100, 100))}%`; - bar.appendChild(errorSegment); - } - - if (bar.children.length > 0) { - container.appendChild(bar); - } - } } function setDisabledAll(disabled) { @@ -691,7 +467,7 @@ async function refreshDashboard(options = {}) { await Promise.all([loadCurrentSchedule(), refreshSiteResults()]); renderUsage(); renderOrganisations(); - updateAvatarFromState(); + void updateAvatarFromState(); renderAuthState(true); startJobSubscription(); } catch (error) { @@ -952,7 +728,7 @@ async function syncAuthState(session) { state.session = session; state.userEmail = session?.user?.email || ""; state.userAvatarUrl = session?.user?.user_metadata?.avatar_url || ""; - updateAvatarFromState(); + void updateAvatarFromState(); if (!session) { cleanupJobSubscription(); diff --git a/webflow-designer-extension-cli/public/lib/bridge.js b/webflow-designer-extension-cli/public/lib/bridge.js index 4ec20f7f9..768cfb221 100644 --- a/webflow-designer-extension-cli/public/lib/bridge.js +++ b/webflow-designer-extension-cli/public/lib/bridge.js @@ -14,6 +14,7 @@ import * as integrationHttp from "/app/lib/integration-http.js"; import * as organisationApi from "/app/lib/organisation-api.js"; import * as schedulerApi from "/app/lib/scheduler-api.js"; import * as siteJobs from "/app/lib/site-jobs.js"; +import * as siteView from "/app/lib/site-view.js"; import * as webflowSites from "/app/lib/webflow-sites.js"; // Expose shared modules for index.js consumption @@ -24,6 +25,7 @@ window.HoverLib = { organisations: organisationApi, schedulers: schedulerApi, jobs: siteJobs, + view: siteView, webflow: webflowSites, }; diff --git a/webflow-designer-extension-cli/scripts/sync-shared.js b/webflow-designer-extension-cli/scripts/sync-shared.js index b58c5ee45..9100fc492 100644 --- a/webflow-designer-extension-cli/scripts/sync-shared.js +++ b/webflow-designer-extension-cli/scripts/sync-shared.js @@ -28,6 +28,7 @@ const COMPONENTS = [ // Lib modules required by bridge.js or other shared extension runtime paths. const REQUIRED_LIB_MODULES = [ "lib/site-jobs.js", + "lib/site-view.js", "lib/webflow-sites.js", "lib/organisation-api.js", "lib/scheduler-api.js", diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index e1cb11296..2c89b68c6 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -211,6 +211,65 @@ declare const HoverLib: { onSubscriptionIssue?: (status?: string, err?: Error) => void; }) => () => void; }; + view: { + renderUserAvatar: (options?: { + element?: HTMLElement | null; + displayName?: string; + email?: string; + avatarUrl?: string; + }) => Promise; + renderUsage: (options?: { + usage?: unknown | null; + planNameText?: Element | null; + planRemainingValue?: Element | null; + }) => void; + renderOrganisations: (options?: { + select?: HTMLSelectElement | null; + organisations?: unknown[]; + activeOrganisationId?: string; + emptyLabel?: string; + }) => void; + renderScheduleState: (options?: { + select?: HTMLSelectElement | null; + currentScheduler?: unknown | null; + placeholder?: string; + allowedValues?: string[]; + }) => void; + renderJobState: (options?: { + jobSection?: HTMLElement | null; + job?: unknown | null; + isActiveJobStatus?: (status: string) => boolean; + context?: string; + onViewJob?: (path: string, job?: unknown) => void; + onExportJob?: (jobId: string, job?: unknown) => void; + }) => void; + renderRecentResults: (options?: { + latestResultsList?: HTMLElement | null; + recentResultsList?: HTMLElement | null; + noJobState?: HTMLElement | null; + noJobText?: Element | null; + noJobActionButton?: HTMLElement | null; + jobs?: unknown[]; + siteDomain?: string | null; + siteDomainCandidates?: string[]; + isActiveJobStatus?: (status: string) => boolean; + context?: string; + onViewJob?: (path: string, job?: unknown) => void; + onExportJob?: (jobId: string, job?: unknown) => void; + emptySelectionMessage?: string; + emptySiteMessage?: string; + emptyCompletedMessage?: string; + showEmptyAction?: boolean; + }) => void; + renderMiniChart: (options?: { + miniChart?: HTMLElement | null; + chartScaleLabels?: Element[]; + jobs?: unknown[]; + siteDomain?: string | null; + siteDomainCandidates?: string[]; + onViewJob?: (path: string, job?: unknown) => void; + }) => void; + }; webflow: { startWebflowConnection: () => Promise<{ auth_url?: string }>; listWebflowConnections: () => Promise; @@ -364,103 +423,18 @@ function extractErrorMessage(rawBody?: string): string { return rawBody; } -// --------------------------------------------------------------------------- -// Avatar helpers -// --------------------------------------------------------------------------- - -async function getGravatarUrl(email: string, size = 80): Promise { - const normalised = (email || "").trim().toLowerCase(); - if (!normalised || !globalThis.crypto?.subtle) return ""; - try { - const data = new TextEncoder().encode(normalised); - const digest = await globalThis.crypto.subtle.digest("SHA-256", data); - const hash = [...new Uint8Array(digest)] - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - const params = new URLSearchParams({ s: String(size), d: "404" }); - return `https://www.gravatar.com/avatar/${hash}?${params.toString()}`; - } catch { - return ""; - } -} - -async function renderAvatar( - target: HTMLElement, - email: string, - initials: string -): Promise { - const existingImg = target.querySelector("img"); - if (existingImg) existingImg.remove(); - - target.textContent = initials; - - const url = await getGravatarUrl(email, 80); - if (!url) return; - - const img = document.createElement("img"); - img.src = url; - img.alt = "User avatar"; - img.loading = "lazy"; - img.decoding = "async"; - img.addEventListener( - "load", - () => { - target.textContent = ""; - target.appendChild(img); - }, - { once: true } - ); - img.addEventListener( - "error", - () => { - if (img.parentNode) img.parentNode.removeChild(img); - target.textContent = initials; - }, - { once: true } - ); -} - async function updateAvatarFromState(): Promise { const avatarEl = document.querySelector( ".topbar-profile-avatar" ); if (!avatarEl) return; - const displayName = state.userDisplayName || state.userEmail || ""; - const initials = displayName ? HoverLib.fmt.getInitials(displayName) : "?"; - - // Use the OAuth avatar_url from the auth postMessage if available, - // otherwise fall back to Gravatar via the shared renderAvatar helper. - if (state.userAvatarUrl) { - const existingImg = avatarEl.querySelector("img"); - if (existingImg) existingImg.remove(); - avatarEl.textContent = initials; - - const img = document.createElement("img"); - img.src = state.userAvatarUrl; - img.alt = "User avatar"; - img.loading = "lazy"; - img.decoding = "async"; - img.addEventListener( - "load", - () => { - avatarEl.textContent = ""; - avatarEl.appendChild(img); - }, - { once: true } - ); - img.addEventListener( - "error", - () => { - if (img.parentNode) img.parentNode.removeChild(img); - avatarEl.textContent = initials; - }, - { once: true } - ); - return; - } - - await renderAvatar(avatarEl, state.userEmail ?? "", initials); + await HoverLib.view.renderUserAvatar({ + element: avatarEl, + displayName: state.userDisplayName || state.userEmail || "", + email: state.userEmail || "", + avatarUrl: state.userAvatarUrl || "", + }); } const ui = { @@ -1091,152 +1065,39 @@ function renderJobState(job: JobItem | null): void { const section = asNode(ui.jobSection); if (!job || !isActiveJobStatus(job.status)) { stopJobStatusPolling(); - hide(section); - return; } - - const hoverJobCard = (window as any).HoverJobCard; - const card: HTMLElement = hoverJobCard - ? hoverJobCard.createJobCard(job, { context: "extension" }) - : buildResultCardFallback(job, false); - - if (section) { - section.replaceChildren(card); - card.addEventListener("hover-job-card:view", (e: Event) => - openSettingsPage((e as CustomEvent).detail.path) - ); - card.addEventListener("hover-job-card:export", (e: Event) => { - void exportJob((e as CustomEvent).detail.jobId); - }); - show(section); - } -} - -function asCount(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return 0; - } - return Math.max(0, Math.floor(value)); -} - -function getIssueCounts(job: JobItem): { - brokenLinks: number; - verySlow: number; - slow: number; -} { - const buckets = job.stats?.slow_page_buckets; - const statsBrokenLinks = asCount(job.stats?.total_broken_links); - const fallbackBrokenLinks = asCount(job.failed_tasks); - - if (job.stats && buckets) { - const verySlow = asCount(buckets.over_10s) + asCount(buckets["5_to_10s"]); - const slow = asCount(buckets["3_to_5s"]); - return { - brokenLinks: Math.max(statsBrokenLinks, fallbackBrokenLinks), - verySlow, - slow, - }; - } - - return { - brokenLinks: fallbackBrokenLinks, - verySlow: 0, - slow: 0, - }; + HoverLib.view.renderJobState({ + jobSection: section, + job, + isActiveJobStatus, + context: "extension", + onViewJob: (path) => { + openSettingsPage(path); + }, + onExportJob: (jobId) => { + void exportJob(jobId); + }, + }); } -// --------------------------------------------------------------------------- -// Recent results list (completed jobs only) -// --------------------------------------------------------------------------- - -function filterSiteJobs(jobs: JobItem[]): JobItem[] { - return HoverLib.jobs.filterJobsByDomains(jobs, { +function renderRecentResults(jobs: JobItem[]): void { + HoverLib.view.renderRecentResults({ + latestResultsList: asNode(ui.latestResultsList), + recentResultsList: asNode(ui.recentResultsList), + noJobState: asNode(ui.noJobState), + jobs, siteDomain: state.siteDomain, siteDomainCandidates: state.siteDomainCandidates, - }) as JobItem[]; -} - -function renderRecentResults(jobs: JobItem[]): void { - const latestContainer = ui.latestResultsList; - const recentContainer = ui.recentResultsList; - if (!latestContainer || !recentContainer) { - return; - } - - while (latestContainer.firstChild) { - latestContainer.removeChild(latestContainer.firstChild); - } - - while (recentContainer.firstChild) { - recentContainer.removeChild(recentContainer.firstChild); - } - - const siteJobs = filterSiteJobs(jobs); - - // All completed / non-active jobs go here - const completedJobs = siteJobs.filter( - (job) => !isActiveJobStatus(job.status) - ); - - // Show/hide no-job state based on whether there are ANY jobs - if (siteJobs.length === 0) { - show(asNode(ui.noJobState)); - } else { - hide(asNode(ui.noJobState)); - } - - if (completedJobs.length === 0) { - const empty = document.createElement("p"); - empty.className = "detail"; - empty.textContent = "No completed runs yet."; - latestContainer.appendChild(empty); - return; - } - - const groupedJobs = completedJobs.slice(0, 6); - const latestJob = groupedJobs[0] || null; - const recentJobs = groupedJobs.slice(1, 6); - - const hoverJobCard = (window as any).HoverJobCard; - - function makeCard(cardJob: JobItem, compact: boolean): HTMLElement { - const card: HTMLElement = hoverJobCard - ? hoverJobCard.createJobCard(cardJob, { context: "extension", compact }) - : buildResultCardFallback(cardJob, compact); - card.addEventListener("hover-job-card:view", (e: Event) => - openSettingsPage((e as CustomEvent).detail.path) - ); - card.addEventListener("hover-job-card:export", (e: Event) => { - void exportJob((e as CustomEvent).detail.jobId); - }); - return card; - } - - if (latestJob) { - latestContainer.appendChild(makeCard(latestJob, false)); - } - - if (recentJobs.length > 0) { - for (const job of recentJobs) { - recentContainer.appendChild(makeCard(job, true)); - } - } -} - -// --------------------------------------------------------------------------- -// Result card fallback (used only if hover-job-card.js fails to load) -// --------------------------------------------------------------------------- - -function buildResultCardFallback(job: JobItem, compact = false): HTMLElement { - // Minimal fallback used only if hover-job-card.js fails to load. - const card = document.createElement("div"); - card.className = compact - ? "result-card result-card--complete result-card--compact" - : "result-card result-card--complete"; - const label = document.createElement("p"); - label.textContent = String(job.status || "unknown"); - card.appendChild(label); - return card; + isActiveJobStatus, + context: "extension", + onViewJob: (path) => { + openSettingsPage(path); + }, + onExportJob: (jobId) => { + void exportJob(jobId); + }, + emptySiteMessage: "No runs yet for this site.", + }); } // Job export // --------------------------------------------------------------------------- @@ -1310,164 +1171,31 @@ const sanitizeForFilename = (value: string): string => // --------------------------------------------------------------------------- function renderMiniChart(jobs: JobItem[]): void { - const container = ui.miniChart; - if (!container) { - return; - } - - while (container.firstChild) { - container.removeChild(container.firstChild); - } - - const completedJobs = filterSiteJobs(jobs) - .filter((job) => normalizeJobStatus(job.status) === "completed") - .slice(0, 12); - - if (completedJobs.length === 0) { - for (const label of ui.chartScaleLabels || []) { - label.textContent = "0"; - } - return; - } - - const chartRows = completedJobs - .filter( - (job) => - normalizeJobStatus(job.status) === "completed" && Boolean(job.stats) - ) - .map((job) => { - const { brokenLinks, verySlow, slow } = getIssueCounts(job); - const errorCount = brokenLinks; - const okCount = verySlow + slow; - const totalPages = Math.max(0, job.total_tasks); - return { - job, - errorCount, - okCount, - issueTotal: errorCount + okCount, - totalPages, - }; - }) - .filter((row) => row.issueTotal > 0 && row.totalPages > 0) - .reverse(); - - if (chartRows.length === 0) { - for (const label of ui.chartScaleLabels || []) { - label.textContent = "0"; - } - return; - } - - const maxIssues = Math.max(...chartRows.map((row) => row.issueTotal), 1); - - const tickTop = maxIssues; - const tickMid = Math.round(maxIssues * 0.5); - const tickQuarter = Math.round(maxIssues * 0.25); - const tickValues = [tickTop, tickMid, tickQuarter, 0]; - - (ui.chartScaleLabels || []).forEach((label, index) => { - const value = tickValues[index] ?? 0; - label.textContent = String(value); + HoverLib.view.renderMiniChart({ + miniChart: asNode(ui.miniChart), + chartScaleLabels: ui.chartScaleLabels, + jobs, + siteDomain: state.siteDomain, + siteDomainCandidates: state.siteDomainCandidates, + onViewJob: (path) => { + openSettingsPage(path); + }, }); - - const minSegmentHeightPercent = 2; - - for (const row of chartRows) { - const job = row.job; - const bar = document.createElement("div"); - bar.className = "chart-bar"; - bar.role = "button"; - bar.tabIndex = 0; - const dateStr = HoverLib.fmt.formatDateTime( - job.completed_at || job.created_at - ); - bar.title = `${dateStr}\nStatus: Completed\nOK: ${row.okCount}\nError: ${row.errorCount}\nTotal pages: ${job.total_tasks.toLocaleString()}`; - - const detailPath = `${APP_ROUTES.viewJob}/${encodeURIComponent(job.id)}`; - bar.addEventListener("click", () => { - openSettingsPage(detailPath); - }); - bar.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - openSettingsPage(detailPath); - } - }); - - if (row.okCount > 0) { - const seg = document.createElement("div"); - seg.className = "chart-bar--warning"; - const okHeight = Math.max( - minSegmentHeightPercent, - Math.min((row.okCount / maxIssues) * 100, 100) - ); - seg.style.height = `${okHeight}%`; - bar.appendChild(seg); - } - - if (row.errorCount > 0) { - const seg = document.createElement("div"); - seg.className = "chart-bar--danger"; - const errorHeight = Math.max( - minSegmentHeightPercent, - Math.min((row.errorCount / maxIssues) * 100, 100) - ); - seg.style.height = `${errorHeight}%`; - bar.appendChild(seg); - } - - if (bar.children.length > 0) { - container.appendChild(bar); - } - } } function renderUsage(usage: UsageStats | null): void { - if (!usage) { - if (ui.planNameText) { - ui.planNameText.innerHTML = "Plan: \u2014"; - } - setText(ui.planRemainingValue, "\u2014"); - return; - } - - const plan = usage.plan_display_name || usage.plan_name || "Plan"; - const limit = usage.daily_limit.toLocaleString(); - - if (ui.planNameText) { - ui.planNameText.innerHTML = `Plan: ${plan} (${limit} / day)`; - } - - const remaining = usage.daily_remaining.toLocaleString(); - setText(ui.planRemainingValue, `${remaining} remaining`); + HoverLib.view.renderUsage({ + usage, + planNameText: ui.planNameText, + planRemainingValue: ui.planRemainingValue, + }); } function renderOrganisations() { - const select = asSelect(ui.orgSelect); - if (!select) { - return; - } - - while (select.firstChild) { - select.removeChild(select.firstChild); - } - - if (state.organisations.length === 0) { - const placeholder = document.createElement("option"); - placeholder.textContent = "No organisations"; - placeholder.value = ""; - select.appendChild(placeholder); - select.disabled = true; - return; - } - - select.disabled = false; - state.organisations.forEach((org) => { - const option = document.createElement("option"); - option.value = org.id; - option.textContent = org.name; - option.selected = org.id === state.activeOrganisationId; - select.appendChild(option); + HoverLib.view.renderOrganisations({ + select: asSelect(ui.orgSelect), + organisations: state.organisations, + activeOrganisationId: state.activeOrganisationId, }); } @@ -1481,20 +1209,12 @@ function renderWebflowStatus(isConnected: boolean) { } function renderScheduleState(): void { - const scheduleSelect = asSelect(ui.scheduleSelect); - if (!scheduleSelect) { - return; - } - - if (!state.currentScheduler || !state.currentScheduler.is_enabled) { - scheduleSelect.value = SCHEDULE_PLACEHOLDER; - return; - } - - const hours = String(state.currentScheduler.schedule_interval_hours); - if (SCHEDULE_OPTIONS.includes(hours as any)) { - scheduleSelect.value = hours; - } + HoverLib.view.renderScheduleState({ + select: asSelect(ui.scheduleSelect), + currentScheduler: state.currentScheduler, + placeholder: SCHEDULE_PLACEHOLDER, + allowedValues: [...SCHEDULE_OPTIONS], + }); } function buildAppUrl(path: string): string { From 9e0247cc821e00bf837e71c1f11e486a227096e7 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:42:57 +1000 Subject: [PATCH 19/41] Share job export flow --- web/static/app/lib/job-export.js | 68 +++++++++++++++ web/static/app/pages/dashboard.js | 32 +------ .../public/lib/bridge.js | 2 + .../scripts/sync-shared.js | 1 + webflow-designer-extension-cli/src/index.ts | 85 ++++--------------- 5 files changed, 92 insertions(+), 96 deletions(-) create mode 100644 web/static/app/lib/job-export.js diff --git a/web/static/app/lib/job-export.js b/web/static/app/lib/job-export.js new file mode 100644 index 000000000..0af6dc6d8 --- /dev/null +++ b/web/static/app/lib/job-export.js @@ -0,0 +1,68 @@ +/** + * lib/job-export.js — shared job export helpers + * + * Shared between the dashboard and Webflow Designer extension. + */ + +import * as apiClient from "/app/lib/api-client.js"; +import { + escapeCSVValue, + sanitiseForFilename, + triggerFileDownload, +} from "/app/lib/formatters.js"; + +function resolveApi(api) { + return api || apiClient; +} + +function prepareExportColumns(columns, tasks) { + if (Array.isArray(columns) && columns.length > 0) { + return { + keys: columns.map((column) => column.key), + headers: columns.map((column) => column.label || column.key), + }; + } + + const keySet = new Set(); + for (const task of tasks) { + Object.keys(task || {}).forEach((key) => keySet.add(key)); + } + + const keys = [...keySet]; + return { keys, headers: keys }; +} + +export async function downloadJobExport(jobId, options = {}) { + const api = resolveApi(options.api); + const payload = await api.get(`/v1/jobs/${encodeURIComponent(jobId)}/export`); + const tasks = Array.isArray(payload?.tasks) ? payload.tasks : []; + + if (tasks.length === 0) { + return { + empty: true, + filename: "", + taskCount: 0, + }; + } + + const { keys, headers } = prepareExportColumns(payload.columns, tasks); + const csvRows = [headers.join(",")]; + for (const task of tasks) { + const values = keys.map((key) => escapeCSVValue(task[key])); + csvRows.push(values.join(",")); + } + + const filenameBase = sanitiseForFilename(payload.domain || `job-${jobId}`); + const filename = `${filenameBase}-hover-export.csv`; + triggerFileDownload(csvRows.join("\n"), "text/csv", filename); + + return { + empty: false, + filename, + taskCount: tasks.length, + }; +} + +export default { + downloadJobExport, +}; diff --git a/web/static/app/pages/dashboard.js b/web/static/app/pages/dashboard.js index d029900ce..61e8293ae 100644 --- a/web/static/app/pages/dashboard.js +++ b/web/static/app/pages/dashboard.js @@ -6,13 +6,14 @@ * latest/past report surfaces. */ -import { get, post } from "/app/lib/api-client.js"; +import { post } from "/app/lib/api-client.js"; import { onAuthStateChange, getSession } from "/app/lib/auth-session.js"; import { showToast } from "/app/components/hover-toast.js"; import { loadOrganisationContext, switchOrganisation as switchOrganisationApi, } from "/app/lib/organisation-api.js"; +import { downloadJobExport } from "/app/lib/job-export.js"; import { renderJobState as renderSharedJobState, renderMiniChart as renderSharedMiniChart, @@ -558,42 +559,17 @@ async function runNow() { async function exportJob(jobId) { try { - const data = await get(`/v1/jobs/${encodeURIComponent(jobId)}/export`, { - headers: { Accept: "application/json" }, - }); - const tasks = Array.isArray(data?.tasks) ? data.tasks : []; - if (!tasks.length) { + const result = await downloadJobExport(jobId); + if (result.empty) { showToast("No tasks to export.", { variant: "warning" }); return; } - - const keys = Object.keys(tasks[0]); - const csv = [ - keys.join(","), - ...tasks.map((task) => keys.map((key) => csvEscape(task[key])).join(",")), - ].join("\n"); - - const blob = new Blob([csv], { type: "text/csv" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = `job-${jobId}.csv`; - link.click(); - URL.revokeObjectURL(url); showToast("Export downloaded.", { variant: "success" }); } catch (error) { showToast(`Export failed: ${error.message}`, { variant: "error" }); } } -function csvEscape(value) { - if (value == null) return ""; - const text = String(value); - return text.includes(",") || text.includes('"') || text.includes("\n") - ? `"${text.replace(/"/g, '""')}"` - : text; -} - async function setJobSchedule() { if (!(ui.scheduleSelect instanceof HTMLSelectElement)) { return; diff --git a/webflow-designer-extension-cli/public/lib/bridge.js b/webflow-designer-extension-cli/public/lib/bridge.js index 768cfb221..ca995d89d 100644 --- a/webflow-designer-extension-cli/public/lib/bridge.js +++ b/webflow-designer-extension-cli/public/lib/bridge.js @@ -11,6 +11,7 @@ import * as apiClient from "/app/lib/api-client.js"; import * as formatters from "/app/lib/formatters.js"; import * as integrationHttp from "/app/lib/integration-http.js"; +import * as jobExport from "/app/lib/job-export.js"; import * as organisationApi from "/app/lib/organisation-api.js"; import * as schedulerApi from "/app/lib/scheduler-api.js"; import * as siteJobs from "/app/lib/site-jobs.js"; @@ -20,6 +21,7 @@ import * as webflowSites from "/app/lib/webflow-sites.js"; // Expose shared modules for index.js consumption window.HoverLib = { api: apiClient, + exports: jobExport, fmt: formatters, http: integrationHttp, organisations: organisationApi, diff --git a/webflow-designer-extension-cli/scripts/sync-shared.js b/webflow-designer-extension-cli/scripts/sync-shared.js index 9100fc492..6cc131138 100644 --- a/webflow-designer-extension-cli/scripts/sync-shared.js +++ b/webflow-designer-extension-cli/scripts/sync-shared.js @@ -29,6 +29,7 @@ const COMPONENTS = [ const REQUIRED_LIB_MODULES = [ "lib/site-jobs.js", "lib/site-view.js", + "lib/job-export.js", "lib/webflow-sites.js", "lib/organisation-api.js", "lib/scheduler-api.js", diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 2c89b68c6..962758a14 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -110,6 +110,18 @@ declare const HoverLib: { del: (path: string, init?: RequestInit) => Promise; request: (path: string, init?: RequestInit) => Promise; }; + exports: { + downloadJobExport: ( + jobId: string, + options?: { + api?: typeof HoverLib.api; + } + ) => Promise<{ + empty: boolean; + filename: string; + taskCount: number; + }>; + }; fmt: { formatDate: (value: string | null | undefined) => string; formatDateTime: (value: string | null | undefined) => string; @@ -344,21 +356,6 @@ type JobItem = { }; }; -type ExportColumn = { - key: string; - label: string; -}; - -type JobExportPayload = { - job_id: string; - domain?: string; - export_time?: string; - completed_at?: string | null; - export_type?: string; - columns?: ExportColumn[]; - tasks?: Record[]; -}; - type Scheduler = { id: string; domain: string; @@ -1104,23 +1101,12 @@ function renderRecentResults(jobs: JobItem[]): void { async function exportJob(jobId: string): Promise { try { - const payload = (await HoverLib.api.get( - `/v1/jobs/${jobId}/export` - )) as JobExportPayload; - - const tasks = Array.isArray(payload.tasks) ? payload.tasks : []; - const { keys, headers } = prepareExportColumns(payload.columns, tasks); - - const csvRows = [headers.join(",")]; - for (const task of tasks) { - const values = keys.map((key) => escapeCSVValue(task[key])); - csvRows.push(values.join(",")); + const result = await HoverLib.exports.downloadJobExport(jobId, { + api: HoverLib.api, + }); + if (result.empty) { + setStatus("Export unavailable", "No tasks to export."); } - - const csvContent = csvRows.join("\n"); - const filenameBase = sanitizeForFilename(payload.domain || `job-${jobId}`); - const filename = `${filenameBase}-hover-export.csv`; - triggerFileDownload(csvContent, "text/csv", filename); } catch (error) { setStatus( "Export failed", @@ -1129,43 +1115,6 @@ async function exportJob(jobId: string): Promise { } } -function prepareExportColumns( - columns: ExportColumn[] | undefined, - tasks: Record[] -): { keys: string[]; headers: string[] } { - if (Array.isArray(columns) && columns.length > 0) { - return { - keys: columns.map((column) => column.key), - headers: columns.map((column) => column.label || column.key), - }; - } - - const keySet = new Set(); - for (const task of tasks) { - Object.keys(task || {}).forEach((key) => keySet.add(key)); - } - - const keys = [...keySet]; - return { keys, headers: keys }; -} - -// CSV/file utilities — delegated to shared formatters via HoverLib bridge -const escapeCSVValue = (value: unknown): string => - HoverLib?.fmt?.escapeCSVValue?.(value) ?? String(value ?? ""); - -function triggerFileDownload( - content: string, - mimeType: string, - filename: string -): void { - if (HoverLib?.fmt?.triggerFileDownload) { - HoverLib.fmt.triggerFileDownload(content, mimeType, filename); - } -} - -const sanitizeForFilename = (value: string): string => - HoverLib?.fmt?.sanitiseForFilename?.(value) ?? value; - // --------------------------------------------------------------------------- // Mini chart // --------------------------------------------------------------------------- From 738995759047b148bc39687ecccab3d67b36d393 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:41:43 +1000 Subject: [PATCH 20/41] Share shell navigation menus --- dashboard.html | 136 +++++- web/static/app/lib/shell-nav.js | 389 ++++++++++++++++++ web/static/app/lib/site-view.js | 42 +- web/static/app/pages/dashboard.js | 143 ++++++- web/static/app/styles/dashboard-extension.css | 223 ++++++++-- .../public/index.html | 142 ++++++- .../public/lib/bridge.js | 2 + .../public/styles.css | 209 ++++++++-- .../scripts/sync-shared.js | 1 + webflow-designer-extension-cli/src/index.ts | 154 ++++++- 10 files changed, 1303 insertions(+), 138 deletions(-) create mode 100644 web/static/app/lib/shell-nav.js diff --git a/dashboard.html b/dashboard.html index 5b100fd1a..bd5774d85 100644 --- a/dashboard.html +++ b/dashboard.html @@ -143,28 +143,134 @@ diff --git a/webflow-designer-extension-cli/public/lib/bridge.js b/webflow-designer-extension-cli/public/lib/bridge.js index 131379c5b..8aa290d25 100644 --- a/webflow-designer-extension-cli/public/lib/bridge.js +++ b/webflow-designer-extension-cli/public/lib/bridge.js @@ -15,6 +15,7 @@ import * as jobExport from "/app/lib/job-export.js"; import * as organisationApi from "/app/lib/organisation-api.js"; import * as schedulerApi from "/app/lib/scheduler-api.js"; import * as shellNav from "/app/lib/shell-nav.js"; +import * as settingsAccount from "/app/lib/settings/account.js"; import * as siteJobs from "/app/lib/site-jobs.js"; import * as siteView from "/app/lib/site-view.js"; import * as webflowSites from "/app/lib/webflow-sites.js"; @@ -28,6 +29,9 @@ window.HoverLib = { organisations: organisationApi, schedulers: schedulerApi, shell: shellNav, + settings: { + account: settingsAccount, + }, jobs: siteJobs, view: siteView, webflow: webflowSites, diff --git a/webflow-designer-extension-cli/public/lib/settings/account.js b/webflow-designer-extension-cli/public/lib/settings/account.js new file mode 100644 index 000000000..73bce4120 --- /dev/null +++ b/webflow-designer-extension-cli/public/lib/settings/account.js @@ -0,0 +1,636 @@ +/** + * lib/settings/account.js — account section logic + * + * Handles user profile (name, email), auth method management (connect/remove + * OAuth providers), and password reset. Surface-agnostic: render functions + * accept a container element so they work in settings.html or any future + * surface (e.g. extension panel). + * + * Usage: + * import { loadAccountDetails, setupAccountActions } from "/app/lib/settings/account.js"; + * + * await loadAccountDetails(document.getElementById("account")); + */ + +import { get, patch } from "/app/lib/api-client.js"; +import { getSession } from "/app/lib/auth-session.js"; +import { showToast as _showToast } from "/app/components/hover-toast.js"; + +const MAX_NAME_LENGTH = 80; + +/** Adapter: gnh-settings uses (variant, message); hover-toast uses (message, {variant}). */ +function toast(variant, message) { + _showToast(message, { variant }); +} + +// ── Auth method definitions ──────────────────────────────────────────────────── + +const AUTH_METHOD_DEFS = [ + { + key: "google", + label: "Google", + icon_url: "/assets/auth-providers/google.svg", + supported: true, + }, + { + key: "github", + label: "GitHub", + icon_url: "/assets/auth-providers/github.svg", + supported: true, + }, + { + key: "email", + label: "Email/Password", + icon_url: "", + supported: true, + }, + { + key: "azure", + label: "Microsoft", + icon_url: "/assets/auth-providers/microsoft.svg", + supported: true, + }, + { + key: "facebook", + label: "Facebook", + icon_url: "/assets/auth-providers/facebook.png", + supported: true, + }, + { + key: "slack_oidc", + label: "Slack", + icon_url: "/assets/auth-providers/slack.svg", + supported: true, + }, +]; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function normaliseAuthProvider(provider) { + const value = (provider || "").trim().toLowerCase(); + if (value === "slack") return "slack_oidc"; + if ( + value === "google" || + value === "github" || + value === "email" || + value === "azure" || + value === "facebook" || + value === "slack_oidc" + ) { + return value; + } + return ""; +} + +function getAuthMethodDef(provider) { + return ( + AUTH_METHOD_DEFS.find((m) => m.key === provider) || { + key: provider, + label: provider || "Unknown", + icon_url: "", + supported: true, + } + ); +} + +function formatAuthMethod(method) { + const value = normaliseAuthProvider(method) || method; + return getAuthMethodDef(value).label; +} + +function providerIconHtml(provider) { + const def = getAuthMethodDef(provider); + if (def.icon_url) { + const img = document.createElement("img"); + img.src = def.icon_url; + img.alt = ""; + img.loading = "lazy"; + img.decoding = "async"; + img.referrerPolicy = "no-referrer"; + return img; + } + const span = document.createElement("span"); + span.className = "settings-auth-fallback-icon"; + span.setAttribute("aria-hidden", "true"); + span.textContent = "\u2022"; + return span; +} + +function providerSubtitle(method) { + if (method.connected) { + return method.email || "Connected"; + } + if (method.provider === "email") { + return "Set a password to enable email sign-in"; + } + return "Not connected"; +} + +function splitName(fullName) { + const value = (fullName || "").trim(); + if (!value) return { firstName: "", lastName: "" }; + const parts = value.split(/\s+/).filter(Boolean); + if (parts.length === 0) return { firstName: "", lastName: "" }; + if (parts.length === 1) return { firstName: parts[0], lastName: "" }; + return { firstName: parts[0], lastName: parts.slice(1).join(" ") }; +} + +function getOAuthQueryParams(provider) { + switch (provider) { + case "google": + return { prompt: "select_account consent" }; + case "azure": + return { prompt: "select_account" }; + case "facebook": + return { auth_type: "reauthenticate" }; + case "slack_oidc": + return { prompt: "consent" }; + default: + return {}; + } +} + +function getAppOrigin() { + const fromExtension = + window.HOVER_EXTENSION_CONFIG?.appOrigin || + window.GNH_APP?.apiBaseUrl || + ""; + if (fromExtension) { + try { + return new URL(fromExtension).origin; + } catch { + // fall through to current origin + } + } + return window.location.origin; +} + +// ── Auth method actions ──────────────────────────────────────────────────────── + +/** + * Connect an OAuth provider. Preserves the redirect contract: + * stores return path in sessionStorage, redirects via Supabase linkIdentity. + */ +export async function connectAuthMethod(provider) { + if (!window.supabase?.auth) return; + + try { + if (provider === "email") { + await sendPasswordReset(); + toast( + "success", + "Password setup email sent. This enables email sign-in." + ); + return; + } + + const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (currentPath && currentPath !== "/") { + try { + window.sessionStorage.setItem( + "gnh_post_auth_return_target", + currentPath + ); + } catch { + // Ignore storage failures and continue OAuth flow. + } + } + + const callbackTarget = new URL(`${getAppOrigin()}/auth/callback`); + if (currentPath && currentPath !== "/") { + callbackTarget.searchParams.set("return_to", currentPath); + } + const callbackUrl = callbackTarget.toString(); + const queryParams = getOAuthQueryParams(provider); + + if (typeof window.supabase.auth.linkIdentity === "function") { + const { data, error } = await window.supabase.auth.linkIdentity({ + provider, + options: { redirectTo: callbackUrl, queryParams }, + }); + if (error) throw error; + if (data?.url) { + window.location.assign(data.url); + return; + } + } else { + const { data, error } = await window.supabase.auth.signInWithOAuth({ + provider, + options: { redirectTo: callbackUrl, queryParams }, + }); + if (error) throw error; + if (data?.url) { + window.location.assign(data.url); + return; + } + } + + toast("success", `${formatAuthMethod(provider)} connected`); + } catch (err) { + console.error(`Failed to connect ${provider}:`, err); + toast( + "error", + err?.message || `Failed to connect ${formatAuthMethod(provider)}` + ); + } +} + +async function unlinkIdentityViaApi(identityId) { + const session = await getSession(); + const accessToken = session?.access_token; + const authUrl = window.GNH_CONFIG?.supabaseUrl; + const anonKey = window.GNH_CONFIG?.supabaseAnonKey; + if (!accessToken || !authUrl || !anonKey) { + throw new Error("Missing auth session details"); + } + + const response = await fetch( + `${authUrl}/auth/v1/user/identities/${encodeURIComponent(identityId)}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${accessToken}`, + apikey: anonKey, + "Content-Type": "application/json", + }, + } + ); + + if (!response.ok) { + const responseJson = await response.json().catch(() => ({})); + throw new Error( + responseJson?.msg || responseJson?.error || "Unlink failed" + ); + } +} + +export async function removeAuthMethod(method, connectedCount) { + if (connectedCount <= 1) { + toast("error", "You must keep at least one sign-in method."); + return; + } + if (method.provider === "email") { + toast("warning", "Email/password removal isn't supported in settings yet."); + return; + } + if (!method.identity?.identity_id) { + toast("error", "Unable to remove this method."); + return; + } + if (!confirm(`Remove ${formatAuthMethod(method.provider)} sign-in?`)) { + return; + } + + try { + if (typeof window.supabase.auth.unlinkIdentity === "function") { + const { error } = await window.supabase.auth.unlinkIdentity( + method.identity + ); + if (error) throw error; + } else { + await unlinkIdentityViaApi(method.identity.identity_id); + } + + try { + await window.supabase.auth.refreshSession(); + } catch (err) { + console.warn("Failed to refresh session after unlink:", err); + } + + toast("success", `${formatAuthMethod(method.provider)} removed`); + } catch (err) { + console.error(`Failed to remove ${method.provider}:`, err); + toast( + "error", + err?.message || `Failed to remove ${formatAuthMethod(method.provider)}` + ); + } +} + +// ── Rendering ────────────────────────────────────────────────────────────────── + +/** + * Render auth method cards into a container element. + * @param {HTMLElement} container — element to render cards into (cleared first) + * @param {object[]} methods — auth method models + * @param {object} [options] + * @param {function} [options.onRefresh] — called after a connect/remove action + */ +export function renderAuthMethods(container, methods, options = {}) { + if (!container || !Array.isArray(methods)) return; + + container.replaceChildren(); + const connectedCount = methods.filter((m) => m.connected).length; + const visibleMethods = methods.filter((m) => m.provider !== "email"); + + visibleMethods.forEach((method) => { + const card = document.createElement("div"); + card.className = "settings-auth-method-card"; + + const details = document.createElement("div"); + details.className = "settings-auth-method-details"; + + const icon = document.createElement("span"); + icon.className = `settings-auth-provider-icon settings-auth-provider-${method.provider}`; + icon.appendChild(providerIconHtml(method.provider)); + + const text = document.createElement("div"); + text.className = "settings-auth-method-text"; + + const name = document.createElement("strong"); + name.textContent = formatAuthMethod(method.provider); + + const subtitle = document.createElement("span"); + subtitle.className = "settings-muted"; + subtitle.textContent = providerSubtitle(method); + + text.appendChild(name); + text.appendChild(subtitle); + details.appendChild(icon); + details.appendChild(text); + + const actionBtn = document.createElement("button"); + actionBtn.className = "gnh-button gnh-button-outline settings-btn-sm"; + actionBtn.type = "button"; + actionBtn.textContent = method.connected ? "Remove" : "Connect"; + + const permanentlyDisabled = + method.connected && (connectedCount <= 1 || method.provider === "email"); + + if (permanentlyDisabled) { + actionBtn.disabled = true; + actionBtn.title = "At least one sign-in method must remain"; + } + + actionBtn.addEventListener("click", async () => { + if (permanentlyDisabled) return; + actionBtn.disabled = true; + const originalText = actionBtn.textContent; + actionBtn.textContent = method.connected + ? "Removing..." + : "Connecting..."; + if (method.connected) { + await removeAuthMethod(method, connectedCount); + } else { + await connectAuthMethod(method.provider); + } + actionBtn.textContent = originalText; + actionBtn.disabled = permanentlyDisabled; + if (options.onRefresh) options.onRefresh(); + }); + + card.appendChild(details); + card.appendChild(actionBtn); + container.appendChild(card); + }); +} + +// ── Data loading ─────────────────────────────────────────────────────────────── + +/** + * Load and render account details into a container. + * @param {HTMLElement} container — the account section element + * @returns {Promise} + */ +export async function loadAccountDetails(container) { + const session = await getSession(); + if (!session?.user) return; + + const fallbackEmail = session.user.email || ""; + const fallbackFirstName = + session.user.user_metadata?.given_name || + session.user.user_metadata?.first_name || + ""; + const fallbackLastName = + session.user.user_metadata?.family_name || + session.user.user_metadata?.last_name || + ""; + const fallbackFullName = + session.user.user_metadata?.full_name || + session.user.user_metadata?.name || + ""; + const fallbackMethods = session.user.app_metadata?.providers || []; + + let email = fallbackEmail; + let firstName = fallbackFirstName; + let lastName = fallbackLastName; + let fullName = fallbackFullName; + let methods = fallbackMethods; + let identities = []; + let authUser = session.user; + + try { + // Use supabase.auth.getUser() directly for fresh identity data + // (auth-session.getUser() returns session.user which may be stale). + const userResult = await window.supabase.auth.getUser(); + authUser = userResult?.data?.user || session.user; + identities = Array.isArray(authUser?.identities) ? authUser.identities : []; + } catch (err) { + console.warn("Failed to load auth identities:", err); + } + + try { + const response = await get("/v1/auth/profile"); + const profileUser = response?.user || {}; + if (profileUser.email) email = profileUser.email; + if (Object.hasOwn(profileUser, "full_name")) { + fullName = (profileUser.full_name || "").trim(); + } + if (Object.hasOwn(profileUser, "first_name")) { + firstName = (profileUser.first_name || "").trim(); + } + if (Object.hasOwn(profileUser, "last_name")) { + lastName = (profileUser.last_name || "").trim(); + } + if (Array.isArray(response?.auth_methods)) { + methods = response.auth_methods; + } + } catch { + console.warn("Failed to load profile from API."); + } + + if (!firstName && !lastName && fullName) { + const split = splitName(fullName); + firstName = split.firstName; + lastName = split.lastName; + } + + // Update module state + const connectedProviders = new Set(); + const hasIdentityData = Array.isArray(identities) && identities.length > 0; + if (hasIdentityData) { + identities.forEach((identity) => { + const normalised = normaliseAuthProvider(identity.provider); + if (normalised) connectedProviders.add(normalised); + }); + } else { + (Array.isArray(methods) ? methods : []).forEach((provider) => { + const normalised = normaliseAuthProvider(provider); + if (normalised) connectedProviders.add(normalised); + }); + } + + // Build method models for rendering + const methodModels = AUTH_METHOD_DEFS.map((def) => { + const identity = identities.find( + (c) => normaliseAuthProvider(c.provider) === def.key + ); + return { + provider: def.key, + supported: true, + connected: connectedProviders.has(def.key), + email: + identity?.identity_data?.email || + identity?.email || + authUser?.email || + email, + identity: identity || null, + }; + }); + + // Render into container (or fall back to document for legacy compat) + const root = container || document; + const emailEl = root.querySelector("#settingsUserEmail"); + const firstNameEl = root.querySelector("#settingsUserFirstNameInput"); + const lastNameEl = root.querySelector("#settingsUserLastNameInput"); + const passwordStatusEl = root.querySelector("#settingsPasswordMethodStatus"); + const authMethodsEl = root.querySelector("#settingsAuthMethods"); + + if (emailEl) emailEl.textContent = email || "Not set"; + if (firstNameEl) firstNameEl.value = firstName || ""; + if (lastNameEl) lastNameEl.value = lastName || ""; + + if (passwordStatusEl) { + const emailMethod = methodModels.find((m) => m.provider === "email"); + passwordStatusEl.textContent = emailMethod?.connected + ? "Email/password sign-in enabled. Use reset email to change your password." + : "Email/password sign-in not connected yet. Send reset email to set it up."; + } + + if (authMethodsEl) { + renderAuthMethods(authMethodsEl, methodModels, { + onRefresh: () => loadAccountDetails(container), + }); + } +} + +// ── Profile actions ──────────────────────────────────────────────────────────── + +/** + * Save profile name from form inputs within a container. + * @param {HTMLElement} container — the account section element + * @param {object} [options] + * @param {function} [options.onSaved] — called after successful save + */ +export async function saveProfileName(container, options = {}) { + const root = container || document; + const firstNameEl = root.querySelector("#settingsUserFirstNameInput"); + const lastNameEl = root.querySelector("#settingsUserLastNameInput"); + const saveBtn = root.querySelector("#settingsSaveName"); + if (!firstNameEl || !lastNameEl || !saveBtn) return; + + const firstName = firstNameEl.value.trim(); + const lastName = lastNameEl.value.trim(); + const fullName = `${firstName} ${lastName}`.trim(); + + if (firstName.length > MAX_NAME_LENGTH) { + toast("error", `First name must be ${MAX_NAME_LENGTH} characters or fewer`); + return; + } + if (lastName.length > MAX_NAME_LENGTH) { + toast("error", `Last name must be ${MAX_NAME_LENGTH} characters or fewer`); + return; + } + + saveBtn.disabled = true; + const originalText = saveBtn.textContent; + saveBtn.textContent = "Saving..."; + + try { + let metadataUpdateSucceeded = true; + try { + await window.supabase.auth.updateUser({ + data: { + first_name: firstName || "", + last_name: lastName || "", + given_name: firstName || "", + family_name: lastName || "", + full_name: fullName || "", + name: fullName || "", + }, + }); + } catch { + console.warn("Failed to update auth metadata name."); + metadataUpdateSucceeded = false; + } + + await patch("/v1/auth/profile", { + first_name: firstName, + last_name: lastName, + full_name: fullName, + }); + + if (metadataUpdateSucceeded) { + toast("success", "Name updated"); + } else { + toast( + "warning", + "Name saved, but auth metadata sync failed. Please re-login if needed." + ); + } + + await loadAccountDetails(container); + if (options.onSaved) options.onSaved(); + } catch { + console.error("Failed to save profile name."); + toast("error", "Failed to update name"); + } finally { + saveBtn.disabled = false; + saveBtn.textContent = originalText || "Save name"; + } +} + +/** + * Send password reset email for the current user. + */ +export async function sendPasswordReset() { + const session = await getSession(); + const email = session?.user?.email; + if (!email) { + toast("error", "Email address not available"); + return; + } + + try { + const { error } = await window.supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${getAppOrigin()}/settings/account#security`, + }); + if (error) throw error; + toast("success", "Password reset email sent"); + } catch (err) { + console.error("Failed to send password reset:", err); + toast("error", "Failed to send password reset email"); + } +} + +/** + * Wire up account section event listeners within a container. + * @param {HTMLElement} container — the account section element + * @param {object} [options] + * @param {function} [options.onNameSaved] — called after name save (e.g. refresh members) + */ +export function setupAccountActions(container, options = {}) { + const root = container || document; + + const saveNameBtn = root.querySelector("#settingsSaveName"); + if (saveNameBtn) { + saveNameBtn.addEventListener("click", () => + saveProfileName(container, { onSaved: options.onNameSaved }) + ); + } + + const resetBtn = root.querySelector("#settingsResetPassword"); + if (resetBtn) { + resetBtn.addEventListener("click", sendPasswordReset); + } +} diff --git a/webflow-designer-extension-cli/public/styles.css b/webflow-designer-extension-cli/public/styles.css index 2c21fc5a6..35c6a08f4 100644 --- a/webflow-designer-extension-cli/public/styles.css +++ b/webflow-designer-extension-cli/public/styles.css @@ -1063,6 +1063,97 @@ body { padding-right: calc(var(--size--scrollbar)); } +.extension-settings-view { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + padding-right: calc(var(--size--scrollbar)); +} + +.extension-settings-header { + display: flex; + flex-direction: column; + gap: 10px; +} + +.extension-settings-back { + align-self: flex-start; + color: var(--colour--brand-primary); +} + +.extension-settings-eyebrow, +.extension-settings-subtitle, +.settings-muted { + margin: 0; + font-size: 12px; + color: var(--text-colour--secondary); +} + +.extension-settings-eyebrow { + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.extension-settings-title { + margin: 0; + font-size: 20px; + line-height: 1.2; + color: var(--text-colour--primary); +} + +.extension-settings-section { + display: flex; + flex-direction: column; + gap: 14px; +} + +.settings-card { + border: 1px solid rgba(142, 218, 239, 0.24); + background: rgba(97, 208, 239, 0.05); + padding: 14px; +} + +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.settings-row + .settings-row { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid rgba(142, 218, 239, 0.18); +} + +.settings-profile-edit { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 10px; + margin-top: 10px; +} + +.settings-input { + width: 100%; + min-height: 36px; + padding: 8px 10px; + border: 1px solid rgba(142, 218, 239, 0.35); + background: rgba(3, 4, 35, 0.72); + color: var(--text-colour--primary); + font: inherit; +} + +.settings-input::placeholder { + color: rgba(223, 239, 255, 0.42); +} + +.extension-settings-save { + align-self: end; +} + /* ─── Objects: Status Dots ──────────────────────────────────────────────── */ .dot { diff --git a/webflow-designer-extension-cli/scripts/sync-shared.js b/webflow-designer-extension-cli/scripts/sync-shared.js index 9e93c6dc2..49b3f458b 100644 --- a/webflow-designer-extension-cli/scripts/sync-shared.js +++ b/webflow-designer-extension-cli/scripts/sync-shared.js @@ -30,6 +30,7 @@ const REQUIRED_LIB_MODULES = [ "lib/site-jobs.js", "lib/site-view.js", "lib/shell-nav.js", + "lib/settings/account.js", "lib/job-export.js", "lib/webflow-sites.js", "lib/organisation-api.js", diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index ebf112f3d..83dc51e4e 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -39,10 +39,12 @@ const SCHEDULE_PLACEHOLDER = "off"; const SCHEDULE_OPTIONS = ["off", "6", "12", "24", "48"] as const; const JOB_POLLING_INTERVAL_MS = 6000; const FALLBACK_POLLING_INTERVAL_MS = 1000; +const ACCOUNT_SETTINGS_EXTENSION_SIZE = { width: 450, height: 620 } as const; const APP_ROUTES = { dashboard: "/dashboard", viewJob: "/jobs", + account: "/settings/account", changePlan: "/settings/plans", manageTeam: "/settings/team", } as const; @@ -258,6 +260,15 @@ declare const HoverLib: { usage?: unknown | null; }) => void; }; + settings: { + account: { + loadAccountDetails: (container: HTMLElement | null) => Promise; + setupAccountActions: ( + container: HTMLElement | null, + options?: { onNameSaved?: () => void } + ) => void; + }; + }; view: { renderUserAvatar: (options?: { element?: HTMLElement | null; @@ -341,6 +352,7 @@ declare const HoverLib: { }; type ScheduleOption = (typeof SCHEDULE_OPTIONS)[number] | ""; +type ExtensionView = "dashboard" | "settings-account"; type ApiError = { status: number; @@ -509,6 +521,7 @@ const ui = { orgSelect: document.getElementById("orgSelect") as HTMLSelectElement | null, // Action bar + actionBar: document.querySelector(".action-bar"), runNowButton: document.getElementById("runNowButton"), scheduleSelect: document.getElementById( "scheduleSelect" @@ -535,6 +548,11 @@ const ui = { // Footer feedbackButton: document.getElementById("feedbackButton"), helpButton: document.getElementById("helpButton"), + panelFooter: document.querySelector(".panel-footer"), + contentScroll: document.querySelector(".content-scroll"), + settingsAccountView: document.getElementById("settingsAccountView"), + settingsBackButton: document.getElementById("settingsBackButton"), + extensionAccountSection: document.getElementById("extensionAccountSection"), }; type ExtensionState = { @@ -582,6 +600,8 @@ let lastChartJobsSignature = ""; let crossSurfaceOrgRefreshInFlight = false; let shellChrome: ReturnType | null = null; +let extensionView: ExtensionView = "dashboard"; +let accountSettingsBound = false; // Supabase realtime state let supabaseClient: SupabaseClient | null = null; @@ -1095,11 +1115,23 @@ async function setExtensionSizeForAuthState(isAuthed: boolean): Promise { } } +async function setExtensionSizeForView(view: ExtensionView): Promise { + try { + await webflow.setExtensionSize( + view === "settings-account" + ? ACCOUNT_SETTINGS_EXTENSION_SIZE + : AUTHENTICATED_EXTENSION_SIZE + ); + } catch (error) { + console.warn("Unable to set extension size", error); + } +} + function renderAuthState(isAuthed: boolean): void { if (isAuthed) { hide(asNode(ui.unauthState)); show(asNode(ui.authState)); - void setExtensionSizeForAuthState(true); + void setExtensionSizeForView(extensionView); return; } @@ -1108,6 +1140,51 @@ function renderAuthState(isAuthed: boolean): void { void setExtensionSizeForAuthState(false); } +function renderView(): void { + const showSettingsAccount = extensionView === "settings-account"; + + if (showSettingsAccount) { + hide(asNode(ui.actionBar)); + hide(asNode(ui.statusBlock)); + hide(asNode(ui.contentScroll)); + hide(asNode(ui.panelFooter)); + show(asNode(ui.settingsAccountView)); + } else { + show(asNode(ui.actionBar)); + show(asNode(ui.statusBlock)); + show(asNode(ui.contentScroll)); + show(asNode(ui.panelFooter)); + hide(asNode(ui.settingsAccountView)); + } + + if (state.token) { + void setExtensionSizeForView(extensionView); + } +} + +async function openAccountSettingsView(): Promise { + if (!state.token || !ui.extensionAccountSection) { + return; + } + + extensionView = "settings-account"; + renderView(); + + if (!accountSettingsBound) { + HoverLib.settings.account.setupAccountActions(ui.extensionAccountSection); + accountSettingsBound = true; + } + + await HoverLib.settings.account.loadAccountDetails( + ui.extensionAccountSection + ); +} + +function openDashboardView(): void { + extensionView = "dashboard"; + renderView(); +} + // --------------------------------------------------------------------------- // Rendering helpers // --------------------------------------------------------------------------- @@ -1241,13 +1318,6 @@ function buildAppUrl(path: string): string { } } -function buildSurfaceAppUrl(path: string): string { - const targetUrl = new URL(buildAppUrl(path)); - targetUrl.searchParams.set("surface", "webflow-extension"); - targetUrl.searchParams.set("return_to", window.location.href); - return targetUrl.toString(); -} - function setLoading(element: Element | null, disabled: boolean): void { if ( element instanceof HTMLButtonElement || @@ -1613,6 +1683,7 @@ async function refreshDashboard(): Promise { renderOrganisations(); renderScheduleState(); renderWebflowStatus(false); + openDashboardView(); return; } @@ -1693,7 +1764,16 @@ async function switchOrganisation(): Promise { } function openSettingsPage(path: string): void { - window.location.assign(buildSurfaceAppUrl(path)); + if (path === APP_ROUTES.account) { + void openAccountSettingsView(); + return; + } + + const targetUrl = buildAppUrl(path); + const popup = window.open(targetUrl, "_blank", "noopener,noreferrer"); + if (!popup) { + setStatus("Popup blocked. Allow popups and try again.", ""); + } } async function subscribeToNotificationsChannel( @@ -1852,7 +1932,10 @@ function initEventHandlers(): void { }); ui.homeButton?.addEventListener("click", () => { - openSettingsPage(APP_ROUTES.dashboard); + openDashboardView(); + }); + ui.settingsBackButton?.addEventListener("click", () => { + openDashboardView(); }); // Auth: org switcher @@ -1962,6 +2045,7 @@ async function initialise(): Promise { subscribeToNotifications: (orgId, onEvent) => subscribeToNotificationsChannel(orgId, onEvent), }); + renderView(); await refreshDashboard(); renderAuthState(Boolean(state.token)); From 936318534da83963cb61320befcb0fc886d8f1b3 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:48:07 +1000 Subject: [PATCH 25/41] Fix extension account auth --- web/static/app/lib/auth-session.js | 6 ++-- web/static/app/lib/settings/account.js | 35 ++++++++++++------- .../public/lib/settings/account.js | 35 ++++++++++++------- webflow-designer-extension-cli/src/index.ts | 4 +++ 4 files changed, 54 insertions(+), 26 deletions(-) diff --git a/web/static/app/lib/auth-session.js b/web/static/app/lib/auth-session.js index a595c93ef..b39573bfd 100644 --- a/web/static/app/lib/auth-session.js +++ b/web/static/app/lib/auth-session.js @@ -37,13 +37,15 @@ * @returns {import("@supabase/supabase-js").SupabaseAuthClient} */ function authClient() { - if (!window.supabase?.auth) { + const auth = + window.HOVER_EXTENSION_SUPABASE_CLIENT?.auth || window.supabase?.auth; + if (!auth) { throw new Error( "auth-session: window.supabase is not initialised. " + "Ensure the Supabase SDK script and /config.js load before this module runs." ); } - return window.supabase.auth; + return auth; } /** diff --git a/web/static/app/lib/settings/account.js b/web/static/app/lib/settings/account.js index 73bce4120..bf8a451ea 100644 --- a/web/static/app/lib/settings/account.js +++ b/web/static/app/lib/settings/account.js @@ -165,6 +165,14 @@ function getAppOrigin() { return window.location.origin; } +function getSupabaseClient() { + return window.HOVER_EXTENSION_SUPABASE_CLIENT?.auth + ? window.HOVER_EXTENSION_SUPABASE_CLIENT + : window.supabase?.auth + ? window.supabase + : null; +} + // ── Auth method actions ──────────────────────────────────────────────────────── /** @@ -172,7 +180,8 @@ function getAppOrigin() { * stores return path in sessionStorage, redirects via Supabase linkIdentity. */ export async function connectAuthMethod(provider) { - if (!window.supabase?.auth) return; + const client = getSupabaseClient(); + if (!client?.auth) return; try { if (provider === "email") { @@ -203,8 +212,8 @@ export async function connectAuthMethod(provider) { const callbackUrl = callbackTarget.toString(); const queryParams = getOAuthQueryParams(provider); - if (typeof window.supabase.auth.linkIdentity === "function") { - const { data, error } = await window.supabase.auth.linkIdentity({ + if (typeof client.auth.linkIdentity === "function") { + const { data, error } = await client.auth.linkIdentity({ provider, options: { redirectTo: callbackUrl, queryParams }, }); @@ -214,7 +223,7 @@ export async function connectAuthMethod(provider) { return; } } else { - const { data, error } = await window.supabase.auth.signInWithOAuth({ + const { data, error } = await client.auth.signInWithOAuth({ provider, options: { redirectTo: callbackUrl, queryParams }, }); @@ -265,6 +274,7 @@ async function unlinkIdentityViaApi(identityId) { } export async function removeAuthMethod(method, connectedCount) { + const client = getSupabaseClient(); if (connectedCount <= 1) { toast("error", "You must keep at least one sign-in method."); return; @@ -282,17 +292,15 @@ export async function removeAuthMethod(method, connectedCount) { } try { - if (typeof window.supabase.auth.unlinkIdentity === "function") { - const { error } = await window.supabase.auth.unlinkIdentity( - method.identity - ); + if (typeof client?.auth?.unlinkIdentity === "function") { + const { error } = await client.auth.unlinkIdentity(method.identity); if (error) throw error; } else { await unlinkIdentityViaApi(method.identity.identity_id); } try { - await window.supabase.auth.refreshSession(); + await client?.auth?.refreshSession?.(); } catch (err) { console.warn("Failed to refresh session after unlink:", err); } @@ -393,6 +401,7 @@ export function renderAuthMethods(container, methods, options = {}) { * @returns {Promise} */ export async function loadAccountDetails(container) { + const client = getSupabaseClient(); const session = await getSession(); if (!session?.user) return; @@ -422,7 +431,7 @@ export async function loadAccountDetails(container) { try { // Use supabase.auth.getUser() directly for fresh identity data // (auth-session.getUser() returns session.user which may be stale). - const userResult = await window.supabase.auth.getUser(); + const userResult = await client?.auth?.getUser(); authUser = userResult?.data?.user || session.user; identities = Array.isArray(authUser?.identities) ? authUser.identities : []; } catch (err) { @@ -524,6 +533,7 @@ export async function loadAccountDetails(container) { */ export async function saveProfileName(container, options = {}) { const root = container || document; + const client = getSupabaseClient(); const firstNameEl = root.querySelector("#settingsUserFirstNameInput"); const lastNameEl = root.querySelector("#settingsUserLastNameInput"); const saveBtn = root.querySelector("#settingsSaveName"); @@ -549,7 +559,7 @@ export async function saveProfileName(container, options = {}) { try { let metadataUpdateSucceeded = true; try { - await window.supabase.auth.updateUser({ + await client?.auth?.updateUser({ data: { first_name: firstName || "", last_name: lastName || "", @@ -594,6 +604,7 @@ export async function saveProfileName(container, options = {}) { * Send password reset email for the current user. */ export async function sendPasswordReset() { + const client = getSupabaseClient(); const session = await getSession(); const email = session?.user?.email; if (!email) { @@ -602,7 +613,7 @@ export async function sendPasswordReset() { } try { - const { error } = await window.supabase.auth.resetPasswordForEmail(email, { + const { error } = await client?.auth?.resetPasswordForEmail(email, { redirectTo: `${getAppOrigin()}/settings/account#security`, }); if (error) throw error; diff --git a/webflow-designer-extension-cli/public/lib/settings/account.js b/webflow-designer-extension-cli/public/lib/settings/account.js index 73bce4120..bf8a451ea 100644 --- a/webflow-designer-extension-cli/public/lib/settings/account.js +++ b/webflow-designer-extension-cli/public/lib/settings/account.js @@ -165,6 +165,14 @@ function getAppOrigin() { return window.location.origin; } +function getSupabaseClient() { + return window.HOVER_EXTENSION_SUPABASE_CLIENT?.auth + ? window.HOVER_EXTENSION_SUPABASE_CLIENT + : window.supabase?.auth + ? window.supabase + : null; +} + // ── Auth method actions ──────────────────────────────────────────────────────── /** @@ -172,7 +180,8 @@ function getAppOrigin() { * stores return path in sessionStorage, redirects via Supabase linkIdentity. */ export async function connectAuthMethod(provider) { - if (!window.supabase?.auth) return; + const client = getSupabaseClient(); + if (!client?.auth) return; try { if (provider === "email") { @@ -203,8 +212,8 @@ export async function connectAuthMethod(provider) { const callbackUrl = callbackTarget.toString(); const queryParams = getOAuthQueryParams(provider); - if (typeof window.supabase.auth.linkIdentity === "function") { - const { data, error } = await window.supabase.auth.linkIdentity({ + if (typeof client.auth.linkIdentity === "function") { + const { data, error } = await client.auth.linkIdentity({ provider, options: { redirectTo: callbackUrl, queryParams }, }); @@ -214,7 +223,7 @@ export async function connectAuthMethod(provider) { return; } } else { - const { data, error } = await window.supabase.auth.signInWithOAuth({ + const { data, error } = await client.auth.signInWithOAuth({ provider, options: { redirectTo: callbackUrl, queryParams }, }); @@ -265,6 +274,7 @@ async function unlinkIdentityViaApi(identityId) { } export async function removeAuthMethod(method, connectedCount) { + const client = getSupabaseClient(); if (connectedCount <= 1) { toast("error", "You must keep at least one sign-in method."); return; @@ -282,17 +292,15 @@ export async function removeAuthMethod(method, connectedCount) { } try { - if (typeof window.supabase.auth.unlinkIdentity === "function") { - const { error } = await window.supabase.auth.unlinkIdentity( - method.identity - ); + if (typeof client?.auth?.unlinkIdentity === "function") { + const { error } = await client.auth.unlinkIdentity(method.identity); if (error) throw error; } else { await unlinkIdentityViaApi(method.identity.identity_id); } try { - await window.supabase.auth.refreshSession(); + await client?.auth?.refreshSession?.(); } catch (err) { console.warn("Failed to refresh session after unlink:", err); } @@ -393,6 +401,7 @@ export function renderAuthMethods(container, methods, options = {}) { * @returns {Promise} */ export async function loadAccountDetails(container) { + const client = getSupabaseClient(); const session = await getSession(); if (!session?.user) return; @@ -422,7 +431,7 @@ export async function loadAccountDetails(container) { try { // Use supabase.auth.getUser() directly for fresh identity data // (auth-session.getUser() returns session.user which may be stale). - const userResult = await window.supabase.auth.getUser(); + const userResult = await client?.auth?.getUser(); authUser = userResult?.data?.user || session.user; identities = Array.isArray(authUser?.identities) ? authUser.identities : []; } catch (err) { @@ -524,6 +533,7 @@ export async function loadAccountDetails(container) { */ export async function saveProfileName(container, options = {}) { const root = container || document; + const client = getSupabaseClient(); const firstNameEl = root.querySelector("#settingsUserFirstNameInput"); const lastNameEl = root.querySelector("#settingsUserLastNameInput"); const saveBtn = root.querySelector("#settingsSaveName"); @@ -549,7 +559,7 @@ export async function saveProfileName(container, options = {}) { try { let metadataUpdateSucceeded = true; try { - await window.supabase.auth.updateUser({ + await client?.auth?.updateUser({ data: { first_name: firstName || "", last_name: lastName || "", @@ -594,6 +604,7 @@ export async function saveProfileName(container, options = {}) { * Send password reset email for the current user. */ export async function sendPasswordReset() { + const client = getSupabaseClient(); const session = await getSession(); const email = session?.user?.email; if (!email) { @@ -602,7 +613,7 @@ export async function sendPasswordReset() { } try { - const { error } = await window.supabase.auth.resetPasswordForEmail(email, { + const { error } = await client?.auth?.resetPasswordForEmail(email, { redirectTo: `${getAppOrigin()}/settings/account#security`, }); if (error) throw error; diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 83dc51e4e..26cf072ee 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -88,6 +88,7 @@ type ExtensionWindow = Window & { HOVER_EXTENSION_CONFIG?: { appOrigin?: string; }; + HOVER_EXTENSION_SUPABASE_CLIENT?: SupabaseClient | null; }; // Shared modules exposed by lib/bridge.js via window.HoverLib @@ -713,6 +714,7 @@ async function initSupabaseClient(): Promise { access_token: state.token, refresh_token: "", }); + (window as ExtensionWindow).HOVER_EXTENSION_SUPABASE_CLIENT = supabaseClient; return supabaseClient; } @@ -1632,6 +1634,7 @@ function handleAuthError(error: unknown): void { cleanupRealtimeSubscription(); shellChrome?.setActiveOrganisation(""); supabaseClient = null; + (window as ExtensionWindow).HOVER_EXTENSION_SUPABASE_CLIENT = null; renderAuthState(false); setStatus("Session expired. Sign in again.", ""); return; @@ -1674,6 +1677,7 @@ async function refreshDashboard(): Promise { cleanupRealtimeSubscription(); shellChrome?.setActiveOrganisation(""); supabaseClient = null; + (window as ExtensionWindow).HOVER_EXTENSION_SUPABASE_CLIENT = null; renderJobState(null); renderRecentResults([]); renderMiniChart([]); From 49204d03393807633bc1036200e232899a8cf080 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:00:26 +1000 Subject: [PATCH 26/41] Load extension account values --- web/static/app/lib/settings/account.js | 36 +++++++++++-------- .../public/lib/settings/account.js | 36 +++++++++++-------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/web/static/app/lib/settings/account.js b/web/static/app/lib/settings/account.js index bf8a451ea..68440048c 100644 --- a/web/static/app/lib/settings/account.js +++ b/web/static/app/lib/settings/account.js @@ -402,23 +402,23 @@ export function renderAuthMethods(container, methods, options = {}) { */ export async function loadAccountDetails(container) { const client = getSupabaseClient(); - const session = await getSession(); - if (!session?.user) return; + const session = await getSession().catch(() => null); + const sessionUser = session?.user || null; - const fallbackEmail = session.user.email || ""; + const fallbackEmail = sessionUser?.email || ""; const fallbackFirstName = - session.user.user_metadata?.given_name || - session.user.user_metadata?.first_name || + sessionUser?.user_metadata?.given_name || + sessionUser?.user_metadata?.first_name || ""; const fallbackLastName = - session.user.user_metadata?.family_name || - session.user.user_metadata?.last_name || + sessionUser?.user_metadata?.family_name || + sessionUser?.user_metadata?.last_name || ""; const fallbackFullName = - session.user.user_metadata?.full_name || - session.user.user_metadata?.name || + sessionUser?.user_metadata?.full_name || + sessionUser?.user_metadata?.name || ""; - const fallbackMethods = session.user.app_metadata?.providers || []; + const fallbackMethods = sessionUser?.app_metadata?.providers || []; let email = fallbackEmail; let firstName = fallbackFirstName; @@ -426,13 +426,13 @@ export async function loadAccountDetails(container) { let fullName = fallbackFullName; let methods = fallbackMethods; let identities = []; - let authUser = session.user; + let authUser = sessionUser; try { // Use supabase.auth.getUser() directly for fresh identity data // (auth-session.getUser() returns session.user which may be stale). const userResult = await client?.auth?.getUser(); - authUser = userResult?.data?.user || session.user; + authUser = userResult?.data?.user || sessionUser; identities = Array.isArray(authUser?.identities) ? authUser.identities : []; } catch (err) { console.warn("Failed to load auth identities:", err); @@ -605,8 +605,16 @@ export async function saveProfileName(container, options = {}) { */ export async function sendPasswordReset() { const client = getSupabaseClient(); - const session = await getSession(); - const email = session?.user?.email; + const session = await getSession().catch(() => null); + let email = session?.user?.email || ""; + if (!email) { + try { + const response = await get("/v1/auth/profile"); + email = response?.user?.email || ""; + } catch { + // Fall back to the session email only. + } + } if (!email) { toast("error", "Email address not available"); return; diff --git a/webflow-designer-extension-cli/public/lib/settings/account.js b/webflow-designer-extension-cli/public/lib/settings/account.js index bf8a451ea..68440048c 100644 --- a/webflow-designer-extension-cli/public/lib/settings/account.js +++ b/webflow-designer-extension-cli/public/lib/settings/account.js @@ -402,23 +402,23 @@ export function renderAuthMethods(container, methods, options = {}) { */ export async function loadAccountDetails(container) { const client = getSupabaseClient(); - const session = await getSession(); - if (!session?.user) return; + const session = await getSession().catch(() => null); + const sessionUser = session?.user || null; - const fallbackEmail = session.user.email || ""; + const fallbackEmail = sessionUser?.email || ""; const fallbackFirstName = - session.user.user_metadata?.given_name || - session.user.user_metadata?.first_name || + sessionUser?.user_metadata?.given_name || + sessionUser?.user_metadata?.first_name || ""; const fallbackLastName = - session.user.user_metadata?.family_name || - session.user.user_metadata?.last_name || + sessionUser?.user_metadata?.family_name || + sessionUser?.user_metadata?.last_name || ""; const fallbackFullName = - session.user.user_metadata?.full_name || - session.user.user_metadata?.name || + sessionUser?.user_metadata?.full_name || + sessionUser?.user_metadata?.name || ""; - const fallbackMethods = session.user.app_metadata?.providers || []; + const fallbackMethods = sessionUser?.app_metadata?.providers || []; let email = fallbackEmail; let firstName = fallbackFirstName; @@ -426,13 +426,13 @@ export async function loadAccountDetails(container) { let fullName = fallbackFullName; let methods = fallbackMethods; let identities = []; - let authUser = session.user; + let authUser = sessionUser; try { // Use supabase.auth.getUser() directly for fresh identity data // (auth-session.getUser() returns session.user which may be stale). const userResult = await client?.auth?.getUser(); - authUser = userResult?.data?.user || session.user; + authUser = userResult?.data?.user || sessionUser; identities = Array.isArray(authUser?.identities) ? authUser.identities : []; } catch (err) { console.warn("Failed to load auth identities:", err); @@ -605,8 +605,16 @@ export async function saveProfileName(container, options = {}) { */ export async function sendPasswordReset() { const client = getSupabaseClient(); - const session = await getSession(); - const email = session?.user?.email; + const session = await getSession().catch(() => null); + let email = session?.user?.email || ""; + if (!email) { + try { + const response = await get("/v1/auth/profile"); + email = response?.user?.email || ""; + } catch { + // Fall back to the session email only. + } + } if (!email) { toast("error", "Email address not available"); return; From 1a5a91708de1917f27706c860f97133fe42d9d63 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:02:09 +1000 Subject: [PATCH 27/41] Allow PATCH in CORS --- internal/api/middleware.go | 2 +- internal/api/middleware_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 830210e95..4a8d253cf 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -164,7 +164,7 @@ func CORSMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Set CORS headers w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-ID") w.Header().Set("Access-Control-Expose-Headers", "X-Request-ID") diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index f1f5fcdce..5de8ce4d0 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -297,7 +297,7 @@ func TestCORSMiddleware(t *testing.T) { // Check CORS headers are set assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin")) - assert.Equal(t, "GET, POST, PUT, DELETE, OPTIONS", rec.Header().Get("Access-Control-Allow-Methods")) + assert.Equal(t, "GET, POST, PUT, PATCH, DELETE, OPTIONS", rec.Header().Get("Access-Control-Allow-Methods")) assert.Equal(t, "Content-Type, Authorization, X-Request-ID", rec.Header().Get("Access-Control-Allow-Headers")) assert.Equal(t, "X-Request-ID", rec.Header().Get("Access-Control-Expose-Headers")) From cc7f00227cb38f1cc4880ad95d65c32430bb52fa Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:14:52 +1000 Subject: [PATCH 28/41] Refine settings shell layout --- .../public/index.html | 225 ++++++++++++------ .../public/styles.css | 92 ++++++- webflow-designer-extension-cli/src/index.ts | 81 ++++++- 3 files changed, 318 insertions(+), 80 deletions(-) diff --git a/webflow-designer-extension-cli/public/index.html b/webflow-designer-extension-cli/public/index.html index 47897c736..a100b9dab 100644 --- a/webflow-designer-extension-cli/public/index.html +++ b/webflow-designer-extension-cli/public/index.html @@ -401,90 +401,167 @@ + + diff --git a/webflow-designer-extension-cli/public/styles.css b/webflow-designer-extension-cli/public/styles.css index 35c6a08f4..71020f613 100644 --- a/webflow-designer-extension-cli/public/styles.css +++ b/webflow-designer-extension-cli/public/styles.css @@ -1063,19 +1063,30 @@ body { padding-right: calc(var(--size--scrollbar)); } -.extension-settings-view { +.extension-settings-shell { flex: 1; overflow-y: auto; + padding-right: calc(var(--size--scrollbar)); +} + +.extension-settings-layout { + display: grid; + grid-template-columns: 168px minmax(0, 1fr); + gap: 18px; + min-height: 100%; +} + +.extension-settings-sidebar { display: flex; flex-direction: column; - gap: 16px; - padding-right: calc(var(--size--scrollbar)); + gap: 14px; + padding-right: 6px; } -.extension-settings-header { +.extension-settings-sidebar-copy { display: flex; flex-direction: column; - gap: 10px; + gap: 8px; } .extension-settings-back { @@ -1083,6 +1094,70 @@ body { color: var(--colour--brand-primary); } +.extension-settings-sidebar-title { + margin: 0; + font-size: 18px; + line-height: 1.15; + color: var(--text-colour--primary); +} + +.extension-settings-sidebar-subtitle { + margin: 0; + font-size: 12px; + color: var(--text-colour--secondary); +} + +.extension-settings-nav { + display: flex; + flex-direction: column; + gap: 5px; + padding-top: 4px; +} + +.extension-settings-nav-link { + appearance: none; + width: 100%; + padding: 10px 12px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--text-colour--secondary); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; + transition: + color var(--motion-duration--fast) var(--motion-easing--standard), + background var(--motion-duration--fast) var(--motion-easing--standard), + border-color var(--motion-duration--fast) var(--motion-easing--standard); +} + +.extension-settings-nav-link:hover { + color: var(--text-colour--primary); + background: rgba(142, 218, 239, 0.08); + border-color: rgba(142, 218, 239, 0.18); +} + +.extension-settings-nav-link.active { + color: var(--text-colour--primary); + background: rgba(97, 208, 239, 0.12); + border-color: rgba(142, 218, 239, 0.3); + font-weight: 700; +} + +.extension-settings-main { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.extension-settings-header { + display: flex; + flex-direction: column; + gap: 10px; +} + .extension-settings-eyebrow, .extension-settings-subtitle, .settings-muted { @@ -1104,6 +1179,13 @@ body { color: var(--text-colour--primary); } +.extension-settings-tabs { + padding: 8px; + border: 1px solid rgba(142, 218, 239, 0.2); + background: rgba(97, 208, 239, 0.04); + border-radius: 4px; +} + .extension-settings-section { display: flex; flex-direction: column; diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 26cf072ee..2f6be9b31 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -39,12 +39,16 @@ const SCHEDULE_PLACEHOLDER = "off"; const SCHEDULE_OPTIONS = ["off", "6", "12", "24", "48"] as const; const JOB_POLLING_INTERVAL_MS = 6000; const FALLBACK_POLLING_INTERVAL_MS = 1000; -const ACCOUNT_SETTINGS_EXTENSION_SIZE = { width: 450, height: 620 } as const; +const ACCOUNT_SETTINGS_EXTENSION_SIZE = { width: 620, height: 660 } as const; const APP_ROUTES = { dashboard: "/dashboard", viewJob: "/jobs", account: "/settings/account", + billing: "/settings/billing", + notifications: "/settings/notifications", + analytics: "/settings/analytics", + automatedJobs: "/settings/automated-jobs", changePlan: "/settings/plans", manageTeam: "/settings/team", } as const; @@ -354,6 +358,10 @@ declare const HoverLib: { type ScheduleOption = (typeof SCHEDULE_OPTIONS)[number] | ""; type ExtensionView = "dashboard" | "settings-account"; +type HoverTabsElement = HTMLElement & { + tabs: Array<{ key: string; label: string }>; + active: string; +}; type ApiError = { status: number; @@ -554,6 +562,7 @@ const ui = { settingsAccountView: document.getElementById("settingsAccountView"), settingsBackButton: document.getElementById("settingsBackButton"), extensionAccountSection: document.getElementById("extensionAccountSection"), + settingsAccountTabs: document.getElementById("settingsAccountTabs"), }; type ExtensionState = { @@ -603,6 +612,7 @@ let shellChrome: ReturnType | null = null; let extensionView: ExtensionView = "dashboard"; let accountSettingsBound = false; +let accountSettingsLayoutBound = false; // Supabase realtime state let supabaseClient: SupabaseClient | null = null; @@ -731,6 +741,10 @@ function asSelect(element: Element | null): HTMLSelectElement | null { return element instanceof HTMLSelectElement ? element : null; } +function asTabs(element: Element | null): HoverTabsElement | null { + return element instanceof HTMLElement ? (element as HoverTabsElement) : null; +} + function hide(el: HTMLElement | null): void { if (el) { el.classList.add("hidden"); @@ -1164,12 +1178,77 @@ function renderView(): void { } } +function renderSettingsSidebar(path: string): void { + document + .querySelectorAll("[data-settings-path]") + .forEach((element) => { + const isActive = element.dataset.settingsPath === path; + element.classList.toggle("active", isActive); + if (isActive) { + element.setAttribute("aria-current", "page"); + } else { + element.removeAttribute("aria-current"); + } + }); +} + +function scrollSettingsSectionIntoView(targetId: string): void { + const section = document.getElementById(targetId); + if (!section) { + return; + } + section.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function bindAccountSettingsLayout(): void { + if (accountSettingsLayoutBound) { + return; + } + + const tabs = asTabs(ui.settingsAccountTabs); + if (tabs) { + tabs.tabs = [ + { key: "profile", label: "Profile" }, + { key: "security", label: "Security" }, + ]; + tabs.active = "profile"; + tabs.addEventListener("hover-tabs:change", (event: Event) => { + const detail = (event as CustomEvent<{ key: string }>).detail; + if (!detail?.key) { + return; + } + tabs.active = detail.key; + scrollSettingsSectionIntoView(detail.key); + }); + } + + document + .querySelectorAll("[data-settings-path]") + .forEach((element) => { + element.addEventListener("click", () => { + const path = element.dataset.settingsPath; + if (!path) { + return; + } + openSettingsPage(path); + }); + }); + + accountSettingsLayoutBound = true; +} + async function openAccountSettingsView(): Promise { if (!state.token || !ui.extensionAccountSection) { return; } extensionView = "settings-account"; + renderSettingsSidebar(APP_ROUTES.account); + bindAccountSettingsLayout(); + const tabs = asTabs(ui.settingsAccountTabs); + if (tabs) { + tabs.active = "profile"; + } renderView(); if (!accountSettingsBound) { From a8bb5fe394c1d596714a76736a84d284f497ca1b Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:27:36 +1000 Subject: [PATCH 29/41] Tighten settings layout hierarchy --- .../public/index.html | 152 +++++++++--------- .../public/styles.css | 50 +++--- webflow-designer-extension-cli/src/index.ts | 46 ++---- 3 files changed, 120 insertions(+), 128 deletions(-) diff --git a/webflow-designer-extension-cli/public/index.html b/webflow-designer-extension-cli/public/index.html index a100b9dab..32bb9fdf1 100644 --- a/webflow-designer-extension-cli/public/index.html +++ b/webflow-designer-extension-cli/public/index.html @@ -405,94 +405,102 @@ >
-

Settings

Account

-

- Update your name and security settings without leaving the - extension. -

+
- -
; - active: string; -}; type ApiError = { status: number; @@ -562,7 +558,7 @@ const ui = { settingsAccountView: document.getElementById("settingsAccountView"), settingsBackButton: document.getElementById("settingsBackButton"), extensionAccountSection: document.getElementById("extensionAccountSection"), - settingsAccountTabs: document.getElementById("settingsAccountTabs"), + settingsOrgGroupTitle: document.getElementById("settingsOrgGroupTitle"), }; type ExtensionState = { @@ -741,10 +737,6 @@ function asSelect(element: Element | null): HTMLSelectElement | null { return element instanceof HTMLSelectElement ? element : null; } -function asTabs(element: Element | null): HoverTabsElement | null { - return element instanceof HTMLElement ? (element as HoverTabsElement) : null; -} - function hide(el: HTMLElement | null): void { if (el) { el.classList.add("hidden"); @@ -1192,12 +1184,17 @@ function renderSettingsSidebar(path: string): void { }); } -function scrollSettingsSectionIntoView(targetId: string): void { - const section = document.getElementById(targetId); - if (!section) { +function renderSettingsOrganisationGroupTitle(): void { + const heading = asNode(ui.settingsOrgGroupTitle); + if (!heading) { return; } - section.scrollIntoView({ behavior: "smooth", block: "start" }); + const activeOrg = state.organisations.find( + (organisation) => organisation.id === state.activeOrganisationId + ); + heading.textContent = activeOrg?.name + ? `Manage ${activeOrg.name}` + : "Manage organisation"; } function bindAccountSettingsLayout(): void { @@ -1205,23 +1202,6 @@ function bindAccountSettingsLayout(): void { return; } - const tabs = asTabs(ui.settingsAccountTabs); - if (tabs) { - tabs.tabs = [ - { key: "profile", label: "Profile" }, - { key: "security", label: "Security" }, - ]; - tabs.active = "profile"; - tabs.addEventListener("hover-tabs:change", (event: Event) => { - const detail = (event as CustomEvent<{ key: string }>).detail; - if (!detail?.key) { - return; - } - tabs.active = detail.key; - scrollSettingsSectionIntoView(detail.key); - }); - } - document .querySelectorAll("[data-settings-path]") .forEach((element) => { @@ -1244,11 +1224,8 @@ async function openAccountSettingsView(): Promise { extensionView = "settings-account"; renderSettingsSidebar(APP_ROUTES.account); + renderSettingsOrganisationGroupTitle(); bindAccountSettingsLayout(); - const tabs = asTabs(ui.settingsAccountTabs); - if (tabs) { - tabs.active = "profile"; - } renderView(); if (!accountSettingsBound) { @@ -1368,6 +1345,7 @@ function renderOrganisations() { organisations: state.organisations, activeOrganisationId: state.activeOrganisationId, }); + renderSettingsOrganisationGroupTitle(); } function renderWebflowStatus(isConnected: boolean) { From 6e99e1542cc095671fda27f65e7b5da45eef4258 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:30:14 +1000 Subject: [PATCH 30/41] Stop tracking dev override --- .gitignore | 3 +++ webflow-designer-extension-cli/public/dev-config.js | 1 - webflow-designer-extension-cli/public/index.html | 5 ++++- webflow-designer-extension-cli/scripts/dev.js | 6 ++++-- 4 files changed, 11 insertions(+), 4 deletions(-) delete mode 100644 webflow-designer-extension-cli/public/dev-config.js diff --git a/.gitignore b/.gitignore index b39dff396..460222f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ alloy.river # Worktrees .worktrees/ + +# Local Webflow extension dev overrides +webflow-designer-extension-cli/public/dev-runtime-config.js diff --git a/webflow-designer-extension-cli/public/dev-config.js b/webflow-designer-extension-cli/public/dev-config.js deleted file mode 100644 index 3bf192f80..000000000 --- a/webflow-designer-extension-cli/public/dev-config.js +++ /dev/null @@ -1 +0,0 @@ -window.HOVER_EXTENSION_CONFIG = window.HOVER_EXTENSION_CONFIG || {}; diff --git a/webflow-designer-extension-cli/public/index.html b/webflow-designer-extension-cli/public/index.html index 32bb9fdf1..27c2036b1 100644 --- a/webflow-designer-extension-cli/public/index.html +++ b/webflow-designer-extension-cli/public/index.html @@ -590,7 +590,10 @@

Account

- + + diff --git a/webflow-designer-extension-cli/scripts/dev.js b/webflow-designer-extension-cli/scripts/dev.js index 6ac06f498..cc2b1c492 100644 --- a/webflow-designer-extension-cli/scripts/dev.js +++ b/webflow-designer-extension-cli/scripts/dev.js @@ -6,7 +6,7 @@ const { spawn, spawnSync } = require("child_process"); const ROOT = path.resolve(__dirname, ".."); const PUBLIC_DIR = path.join(ROOT, "public"); -const DEV_CONFIG_PATH = path.join(PUBLIC_DIR, "dev-config.js"); +const DEV_CONFIG_PATH = path.join(PUBLIC_DIR, "dev-runtime-config.js"); const DEFAULT_DEV_CONFIG = "window.HOVER_EXTENSION_CONFIG = window.HOVER_EXTENSION_CONFIG || {};\n"; const NPM_CMD = process.platform === "win32" ? "npm.cmd" : "npm"; @@ -78,7 +78,9 @@ function restoreDevConfig() { } restored = true; try { - writeDevConfig(""); + if (fs.existsSync(DEV_CONFIG_PATH)) { + fs.unlinkSync(DEV_CONFIG_PATH); + } } catch (_error) { // Best-effort cleanup only. } From 08fda73c1345350becedf029c61dea222dc27072 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:42:02 +1000 Subject: [PATCH 31/41] Match account hierarchy better --- .../public/index.html | 18 +++- .../public/styles.css | 93 ++++++++++++++++++- webflow-designer-extension-cli/src/index.ts | 44 ++++++++- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/webflow-designer-extension-cli/public/index.html b/webflow-designer-extension-cli/public/index.html index 27c2036b1..da786993e 100644 --- a/webflow-designer-extension-cli/public/index.html +++ b/webflow-designer-extension-cli/public/index.html @@ -417,8 +417,11 @@
-

- Manage your profile +

+ Profile

+
+
+ Authentication methods +
+ Loading... +
+
+
diff --git a/webflow-designer-extension-cli/public/styles.css b/webflow-designer-extension-cli/public/styles.css index f1a673b88..98e70dd1c 100644 --- a/webflow-designer-extension-cli/public/styles.css +++ b/webflow-designer-extension-cli/public/styles.css @@ -1110,10 +1110,11 @@ body { .extension-settings-group-title { margin: 0; - font-size: 12px; + font-size: 15px; font-weight: 700; letter-spacing: 0.02em; - color: var(--text-colour--secondary); + line-height: 1.2; + color: var(--text-colour--primary); } .extension-settings-nav-divider { @@ -1147,6 +1148,94 @@ body { border-color var(--motion-duration--fast) var(--motion-easing--standard); } +.settings-auth-methods { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; +} + +.settings-auth-method-card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid rgba(142, 218, 239, 0.24); + background: rgba(3, 4, 35, 0.52); +} + +.settings-auth-method-details { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.settings-auth-method-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.settings-auth-provider-icon { + width: 28px; + height: 28px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 1px solid rgba(142, 218, 239, 0.18); + background: rgba(97, 208, 239, 0.06); + overflow: hidden; +} + +.settings-auth-provider-icon img { + width: 18px; + height: 18px; + display: block; +} + +.settings-auth-provider-azure img { + width: 19px; + height: 19px; +} + +.settings-auth-fallback-icon { + font-size: 14px; + line-height: 1; + color: var(--text-colour--secondary); +} + +.gnh-button { + appearance: none; + border-radius: 4px; + font-family: inherit; + cursor: pointer; + transition: + color var(--motion-duration--fast) var(--motion-easing--standard), + background var(--motion-duration--fast) var(--motion-easing--standard), + border-color var(--motion-duration--fast) var(--motion-easing--standard); +} + +.gnh-button-outline { + border: 1px solid rgba(142, 218, 239, 0.34); + background: transparent; + color: var(--colour--brand-primary); +} + +.gnh-button-outline:hover { + border-color: var(--border-colour--active); + color: var(--text-colour--primary); +} + +.settings-btn-sm { + font-size: 12px; + padding: 6px 10px; +} + .extension-settings-nav-link:hover { color: var(--text-colour--primary); background: rgba(142, 218, 239, 0.08); diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 5a27b745d..27ae31e18 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -558,6 +558,9 @@ const ui = { settingsAccountView: document.getElementById("settingsAccountView"), settingsBackButton: document.getElementById("settingsBackButton"), extensionAccountSection: document.getElementById("extensionAccountSection"), + settingsProfileGroupTitle: document.getElementById( + "settingsProfileGroupTitle" + ), settingsOrgGroupTitle: document.getElementById("settingsOrgGroupTitle"), }; @@ -1197,6 +1200,26 @@ function renderSettingsOrganisationGroupTitle(): void { : "Manage organisation"; } +function renderSettingsProfileGroupTitle(): void { + const heading = asNode(ui.settingsProfileGroupTitle); + if (!heading) { + return; + } + + const firstName = asInput( + document.getElementById("settingsUserFirstNameInput") + )?.value.trim(); + const lastName = asInput( + document.getElementById("settingsUserLastNameInput") + )?.value.trim(); + const displayNameFromFields = [firstName, lastName].filter(Boolean).join(" "); + heading.textContent = + displayNameFromFields || + state.userDisplayName || + state.userEmail || + "Profile"; +} + function bindAccountSettingsLayout(): void { if (accountSettingsLayoutBound) { return; @@ -1229,13 +1252,32 @@ async function openAccountSettingsView(): Promise { renderView(); if (!accountSettingsBound) { - HoverLib.settings.account.setupAccountActions(ui.extensionAccountSection); + HoverLib.settings.account.setupAccountActions(ui.extensionAccountSection, { + onNameSaved: () => { + const firstName = asInput( + document.getElementById("settingsUserFirstNameInput") + )?.value.trim(); + const lastName = asInput( + document.getElementById("settingsUserLastNameInput") + )?.value.trim(); + const nextDisplayName = [firstName, lastName] + .filter(Boolean) + .join(" ") + .trim(); + if (nextDisplayName) { + state.userDisplayName = nextDisplayName; + void updateAvatarFromState(); + } + renderSettingsProfileGroupTitle(); + }, + }); accountSettingsBound = true; } await HoverLib.settings.account.loadAccountDetails( ui.extensionAccountSection ); + renderSettingsProfileGroupTitle(); } function openDashboardView(): void { From 59544b09556f6620ce36da462302495e3d68c3a6 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:57:55 +1000 Subject: [PATCH 32/41] Fix settings asset URLs --- web/static/app/lib/settings/account.js | 12 ++++- web/static/app/lib/site-view.js | 29 +++++++++- .../public/lib/settings/account.js | 12 ++++- webflow-designer-extension-cli/src/index.ts | 53 +++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/web/static/app/lib/settings/account.js b/web/static/app/lib/settings/account.js index 68440048c..1ba961162 100644 --- a/web/static/app/lib/settings/account.js +++ b/web/static/app/lib/settings/account.js @@ -98,11 +98,21 @@ function formatAuthMethod(method) { return getAuthMethodDef(value).label; } +function resolveAssetUrl(path) { + if (!path) { + return ""; + } + if (/^(?:[a-z]+:)?\/\//i.test(path) || path.startsWith("data:")) { + return path; + } + return new URL(path, `${getAppOrigin()}/`).toString(); +} + function providerIconHtml(provider) { const def = getAuthMethodDef(provider); if (def.icon_url) { const img = document.createElement("img"); - img.src = def.icon_url; + img.src = resolveAssetUrl(def.icon_url); img.alt = ""; img.loading = "lazy"; img.decoding = "async"; diff --git a/web/static/app/lib/site-view.js b/web/static/app/lib/site-view.js index cd6dbd06c..cc5d59a2f 100644 --- a/web/static/app/lib/site-view.js +++ b/web/static/app/lib/site-view.js @@ -46,6 +46,31 @@ function setText(node, value) { } } +function getAppOrigin() { + const fromExtension = + window.HOVER_EXTENSION_CONFIG?.appOrigin || + window.GNH_APP?.apiBaseUrl || + ""; + if (fromExtension) { + try { + return new URL(fromExtension).origin; + } catch { + // Fall through to current origin. + } + } + return window.location.origin; +} + +function resolveAssetUrl(path) { + if (!path) { + return ""; + } + if (/^(?:[a-z]+:)?\/\//i.test(path) || path.startsWith("data:")) { + return path; + } + return new URL(path, `${getAppOrigin()}/`).toString(); +} + function getIssueCounts(job) { const buckets = job.stats?.slow_page_buckets; const statsBrokenLinks = asCount(job.stats?.total_broken_links); @@ -120,7 +145,9 @@ export async function renderUserAvatar(options = {}) { element.textContent = initials; - const resolvedAvatarUrl = avatarUrl || (await getGravatarUrl(email, 80)); + const resolvedAvatarUrl = resolveAssetUrl( + avatarUrl || (await getGravatarUrl(email, 80)) + ); if (!resolvedAvatarUrl) { return; } diff --git a/webflow-designer-extension-cli/public/lib/settings/account.js b/webflow-designer-extension-cli/public/lib/settings/account.js index 68440048c..1ba961162 100644 --- a/webflow-designer-extension-cli/public/lib/settings/account.js +++ b/webflow-designer-extension-cli/public/lib/settings/account.js @@ -98,11 +98,21 @@ function formatAuthMethod(method) { return getAuthMethodDef(value).label; } +function resolveAssetUrl(path) { + if (!path) { + return ""; + } + if (/^(?:[a-z]+:)?\/\//i.test(path) || path.startsWith("data:")) { + return path; + } + return new URL(path, `${getAppOrigin()}/`).toString(); +} + function providerIconHtml(provider) { const def = getAuthMethodDef(provider); if (def.icon_url) { const img = document.createElement("img"); - img.src = def.icon_url; + img.src = resolveAssetUrl(def.icon_url); img.alt = ""; img.loading = "lazy"; img.decoding = "async"; diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 27ae31e18..53cfdf63b 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -12,6 +12,14 @@ type SupabaseClient = { access_token: string; refresh_token: string; }) => Promise; + getUser?: () => Promise<{ + data?: { + user?: { + email?: string | null; + user_metadata?: Record | null; + } | null; + }; + }>; }; channel: (name: string) => RealtimeChannel; removeChannel: (channel: RealtimeChannel) => Promise; @@ -728,6 +736,50 @@ async function initSupabaseClient(): Promise { return supabaseClient; } +function pickUserDisplayName( + metadata: Record | null | undefined +): string { + const firstName = String( + metadata?.given_name || metadata?.first_name || "" + ).trim(); + const lastName = String( + metadata?.family_name || metadata?.last_name || "" + ).trim(); + const fullName = String(metadata?.full_name || metadata?.name || "").trim(); + return fullName || [firstName, lastName].filter(Boolean).join(" ").trim(); +} + +async function loadCurrentUserIdentity(): Promise { + const client = await initSupabaseClient(); + if (!client?.auth?.getUser) { + return; + } + + try { + const result = await client.auth.getUser(); + const user = result?.data?.user; + const metadata = + (user?.user_metadata as Record | null | undefined) || + null; + + if (user?.email) { + state.userEmail = user.email; + } + + const displayName = pickUserDisplayName(metadata); + if (displayName) { + state.userDisplayName = displayName; + } + + const avatarUrl = String(metadata?.avatar_url || "").trim(); + if (avatarUrl) { + state.userAvatarUrl = avatarUrl; + } + } catch (error) { + console.warn("Unable to load current user identity", error); + } +} + function asNode(element: Element | null): HTMLElement | null { return element instanceof HTMLElement ? element : null; } @@ -1793,6 +1845,7 @@ async function refreshDashboard(): Promise { try { await Promise.all([ loadUsageAndOrgs(), + loadCurrentUserIdentity(), loadLatestJob(), loadCurrentSchedule(), findConnectedWebflowSite(), From 2a18b43bc8954f87de669115f2ab950d27dc6e04 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:28:06 +1000 Subject: [PATCH 33/41] Fix cached avatar rendering --- web/static/app/lib/site-view.js | 35 +++++++++++++++++---------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/web/static/app/lib/site-view.js b/web/static/app/lib/site-view.js index cc5d59a2f..ba99c29ce 100644 --- a/web/static/app/lib/site-view.js +++ b/web/static/app/lib/site-view.js @@ -153,26 +153,27 @@ export async function renderUserAvatar(options = {}) { } const img = document.createElement("img"); - img.src = resolvedAvatarUrl; img.alt = displayName || email || "User avatar"; img.loading = "lazy"; img.decoding = "async"; - img.addEventListener( - "load", - () => { - element.textContent = ""; - element.appendChild(img); - }, - { once: true } - ); - img.addEventListener( - "error", - () => { - if (img.parentNode) img.parentNode.removeChild(img); - element.textContent = initials; - }, - { once: true } - ); + const showImage = () => { + element.textContent = ""; + element.appendChild(img); + }; + const showInitials = () => { + if (img.parentNode) img.parentNode.removeChild(img); + element.textContent = initials; + }; + img.addEventListener("load", showImage, { once: true }); + img.addEventListener("error", showInitials, { once: true }); + img.src = resolvedAvatarUrl; + if (img.complete) { + if (img.naturalWidth > 0) { + showImage(); + } else { + showInitials(); + } + } } export function renderUsage(options = {}) { From 202602b6d687831ad056d6e7dac65d5d7d08167e Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:38:10 +1000 Subject: [PATCH 34/41] Load avatars eagerly --- web/static/app/lib/site-view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/static/app/lib/site-view.js b/web/static/app/lib/site-view.js index ba99c29ce..04be1003c 100644 --- a/web/static/app/lib/site-view.js +++ b/web/static/app/lib/site-view.js @@ -154,7 +154,7 @@ export async function renderUserAvatar(options = {}) { const img = document.createElement("img"); img.alt = displayName || email || "User avatar"; - img.loading = "lazy"; + img.loading = "eager"; img.decoding = "async"; const showImage = () => { element.textContent = ""; From 21c89d011a714227493d0bf7283f4dca134a46eb Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:47:10 +1000 Subject: [PATCH 35/41] Restore shared avatar loading --- web/static/app/lib/site-view.js | 4 +- webflow-designer-extension-cli/src/index.ts | 59 +++++++++++++++------ 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/web/static/app/lib/site-view.js b/web/static/app/lib/site-view.js index 04be1003c..d875c290b 100644 --- a/web/static/app/lib/site-view.js +++ b/web/static/app/lib/site-view.js @@ -158,7 +158,7 @@ export async function renderUserAvatar(options = {}) { img.decoding = "async"; const showImage = () => { element.textContent = ""; - element.appendChild(img); + img.style.display = "block"; }; const showInitials = () => { if (img.parentNode) img.parentNode.removeChild(img); @@ -166,6 +166,8 @@ export async function renderUserAvatar(options = {}) { }; img.addEventListener("load", showImage, { once: true }); img.addEventListener("error", showInitials, { once: true }); + img.style.display = "none"; + element.appendChild(img); img.src = resolvedAvatarUrl; if (img.complete) { if (img.naturalWidth > 0) { diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 53cfdf63b..2bff07d72 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -751,32 +751,61 @@ function pickUserDisplayName( async function loadCurrentUserIdentity(): Promise { const client = await initSupabaseClient(); - if (!client?.auth?.getUser) { + if (client?.auth?.getUser) { + try { + const result = await client.auth.getUser(); + const user = result?.data?.user; + const metadata = + (user?.user_metadata as Record | null | undefined) || + null; + + if (user?.email) { + state.userEmail = user.email; + } + + const displayName = pickUserDisplayName(metadata); + if (displayName) { + state.userDisplayName = displayName; + } + + const avatarUrl = String(metadata?.avatar_url || "").trim(); + if (avatarUrl) { + state.userAvatarUrl = avatarUrl; + } + } catch (error) { + console.warn("Unable to load current user identity", error); + } + } + + if (state.userEmail && state.userDisplayName) { return; } try { - const result = await client.auth.getUser(); - const user = result?.data?.user; - const metadata = - (user?.user_metadata as Record | null | undefined) || - null; - + const profile = (await HoverLib.api.get("/v1/auth/profile")) as { + user?: { + email?: string | null; + first_name?: string | null; + last_name?: string | null; + full_name?: string | null; + }; + }; + const user = profile?.user; if (user?.email) { state.userEmail = user.email; } - - const displayName = pickUserDisplayName(metadata); + const displayName = + String(user?.full_name || "").trim() || + [user?.first_name || "", user?.last_name || ""] + .map((value) => String(value).trim()) + .filter(Boolean) + .join(" ") + .trim(); if (displayName) { state.userDisplayName = displayName; } - - const avatarUrl = String(metadata?.avatar_url || "").trim(); - if (avatarUrl) { - state.userAvatarUrl = avatarUrl; - } } catch (error) { - console.warn("Unable to load current user identity", error); + console.warn("Unable to load profile identity fallback", error); } } From 4d5939b8642bb381a679134a26a38237fb2f11eb Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:50:35 +1000 Subject: [PATCH 36/41] Fix avatar DOM swap --- web/static/app/lib/site-view.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/static/app/lib/site-view.js b/web/static/app/lib/site-view.js index d875c290b..fbb674dd8 100644 --- a/web/static/app/lib/site-view.js +++ b/web/static/app/lib/site-view.js @@ -157,11 +157,11 @@ export async function renderUserAvatar(options = {}) { img.loading = "eager"; img.decoding = "async"; const showImage = () => { - element.textContent = ""; img.style.display = "block"; + element.replaceChildren(img); }; const showInitials = () => { - if (img.parentNode) img.parentNode.removeChild(img); + element.replaceChildren(); element.textContent = initials; }; img.addEventListener("load", showImage, { once: true }); From 8d55eab66eee0971cc6cb3ef490a62ceb2a8f1ba Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:53:46 +1000 Subject: [PATCH 37/41] Allow Google avatar CSP --- internal/api/middleware.go | 2 +- internal/api/middleware_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 4a8d253cf..01e52e3f6 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -296,7 +296,7 @@ func SecurityHeadersMiddleware(next http.Handler) http.Handler { connect-src %s; frame-src https://challenges.cloudflare.com; frame-ancestors %s; - img-src 'self' data: https://www.google-analytics.com https://www.googletagmanager.com https://ssl.gstatic.com https://www.gravatar.com; + img-src 'self' data: https://www.google-analytics.com https://www.googletagmanager.com https://ssl.gstatic.com https://www.gravatar.com https://lh3.googleusercontent.com https://*.googleusercontent.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 5de8ce4d0..49bed4193 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -385,6 +385,7 @@ func TestSecurityHeadersMiddleware(t *testing.T) { assert.Contains(t, rec.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") assert.Contains(t, rec.Header().Get("Content-Security-Policy"), "http://127.0.0.1:8765") assert.Contains(t, rec.Header().Get("Content-Security-Policy"), "http://localhost:8765") + assert.Contains(t, rec.Header().Get("Content-Security-Policy"), "https://lh3.googleusercontent.com") assert.Equal(t, "max-age=63072000; includeSubDomains", rec.Header().Get("Strict-Transport-Security")) } From 6349c47c016e0725d812f3085e7b4fbb3913c043 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:59:02 +1000 Subject: [PATCH 38/41] Match avatar icon styling --- web/static/app/styles/dashboard-extension.css | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/web/static/app/styles/dashboard-extension.css b/web/static/app/styles/dashboard-extension.css index 909259a35..597d55c00 100644 --- a/web/static/app/styles/dashboard-extension.css +++ b/web/static/app/styles/dashboard-extension.css @@ -302,10 +302,12 @@ body.page-dashboard .btn--sm { } body.page-dashboard .btn--icon-square { - width: 36px; - height: 36px; + width: 29px; + height: 29px; padding: 0; flex-shrink: 0; + font-size: 12px; + line-height: 1; } body.page-dashboard .btn--border-thin { @@ -344,10 +346,12 @@ body.page-dashboard .corners--right { border-top-right-radius: var(--radius--notch); border-bottom-right-radius: var(--radius--notch); border-bottom-left-radius: 0; + corner-shape: square notch notch square; } body.page-dashboard .corners--top-left { border-radius: var(--radius--notch) 0 0 0; + corner-shape: notch square square square; } body.page-dashboard .topbar { @@ -404,17 +408,21 @@ body.page-dashboard .topbar-profile-avatar { width: 100%; height: 100%; background: var(--colour--brand-primary); - font-size: 0.7rem; + font-size: 0.6rem; font-weight: 700; letter-spacing: 0.03em; color: #fff; overflow: hidden; + border-radius: inherit; + user-select: none; } body.page-dashboard .topbar-profile-avatar img { + display: block; width: 100%; height: 100%; object-fit: cover; + border-radius: inherit; } body.page-dashboard .topbar-org-select { From b364413ff1c5c9a2dd8a9fbb8d9cba4f9b2d310f Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:19:46 +1000 Subject: [PATCH 39/41] Share shell avatar styling --- dashboard.html | 1 + web/static/app/styles/dashboard-extension.css | 129 ------------------ web/static/app/styles/shell.css | 128 +++++++++++++++++ .../public/index.html | 1 + .../public/styles.css | 129 ------------------ .../public/styles/shell.css | 128 +++++++++++++++++ .../scripts/sync-shared.js | 13 ++ 7 files changed, 271 insertions(+), 258 deletions(-) create mode 100644 web/static/app/styles/shell.css create mode 100644 webflow-designer-extension-cli/public/styles/shell.css diff --git a/dashboard.html b/dashboard.html index 04a8116c1..df7e3e063 100644 --- a/dashboard.html +++ b/dashboard.html @@ -14,6 +14,7 @@ + diff --git a/web/static/app/styles/dashboard-extension.css b/web/static/app/styles/dashboard-extension.css index 597d55c00..d5fef2947 100644 --- a/web/static/app/styles/dashboard-extension.css +++ b/web/static/app/styles/dashboard-extension.css @@ -290,30 +290,6 @@ body.page-dashboard .btn--lg { gap: 4px; } -body.page-dashboard .btn--sm { - min-width: 0; - height: 36px; - padding: 0 12px; - font-size: var(--font-size--button-primary); - font-weight: 700; - line-height: 1; - gap: 3px; - box-shadow: var(--shadow--surface); -} - -body.page-dashboard .btn--icon-square { - width: 29px; - height: 29px; - padding: 0; - flex-shrink: 0; - font-size: 12px; - line-height: 1; -} - -body.page-dashboard .btn--border-thin { - border-width: var(--border-size--default); -} - body.page-dashboard .btn--square { border-radius: 0; } @@ -349,11 +325,6 @@ body.page-dashboard .corners--right { corner-shape: square notch notch square; } -body.page-dashboard .corners--top-left { - border-radius: var(--radius--notch) 0 0 0; - corner-shape: notch square square square; -} - body.page-dashboard .topbar { display: flex; align-items: center; @@ -363,111 +334,11 @@ body.page-dashboard .topbar { background: var(--background-colour--panel); } -body.page-dashboard .topbar-brand-link { - display: inline-flex; - align-items: center; - justify-content: flex-start; - padding: 0; - background: transparent; - border: 0; - cursor: pointer; -} - -body.page-dashboard .brand-logo--topbar { - width: 88px; -} - -body.page-dashboard .topbar-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--spacing--lg); - margin-left: auto; - min-width: 0; -} - -body.page-dashboard .topbar-profile { - justify-content: center; - background: transparent; - border-color: var(--border-colour--default); - box-shadow: none; - padding: 0; - overflow: hidden; -} - -body.page-dashboard .topbar-profile:not(:disabled):hover { - background: transparent; - border-color: var(--border-colour--default); - opacity: 0.85; -} - -body.page-dashboard .topbar-profile-avatar { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - background: var(--colour--brand-primary); - font-size: 0.6rem; - font-weight: 700; - letter-spacing: 0.03em; - color: #fff; - overflow: hidden; - border-radius: inherit; - user-select: none; -} - -body.page-dashboard .topbar-profile-avatar img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; - border-radius: inherit; -} - body.page-dashboard .topbar-org-select { width: 220px; min-width: 0; } -body.page-dashboard .shell-profile-menu, -body.page-dashboard .shell-notifications { - position: relative; - flex-shrink: 0; -} - -body.page-dashboard .shell-notifications-button { - position: relative; -} - -body.page-dashboard .shell-notifications-icon { - width: 18px; - height: 18px; - fill: none; - stroke: currentcolor; - stroke-width: 1.9; - stroke-linecap: round; - stroke-linejoin: round; -} - -body.page-dashboard .shell-notifications-badge { - position: absolute; - top: -6px; - right: -6px; - min-width: 18px; - height: 18px; - padding: 0 5px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 999px; - background: var(--status-colour--danger); - color: #fff; - font-size: 11px; - font-weight: 700; - line-height: 1; -} - body.page-dashboard .shell-dropdown { position: absolute; top: calc(100% + 10px); diff --git a/web/static/app/styles/shell.css b/web/static/app/styles/shell.css new file mode 100644 index 000000000..0c7a02068 --- /dev/null +++ b/web/static/app/styles/shell.css @@ -0,0 +1,128 @@ +.topbar-brand-link { + display: inline-flex; + align-items: center; + justify-content: flex-start; + padding: 0; + background: transparent; + border: 0; + cursor: pointer; +} + +.brand-logo--topbar { + width: 88px; +} + +.topbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing--lg); + margin-left: auto; + min-width: 0; +} + +.topbar-profile { + justify-content: center; + background: transparent; + border-color: var(--border-colour--default); + box-shadow: none; + padding: 0; + overflow: hidden; +} + +.topbar-profile:not(:disabled):hover { + background: transparent; + border-color: var(--border-colour--default); + opacity: 0.85; +} + +.topbar-profile-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: var(--colour--brand-primary); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.03em; + color: #fff; + overflow: hidden; + border-radius: inherit; + user-select: none; +} + +.topbar-profile-avatar img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} + +.shell-profile-menu, +.shell-notifications { + position: relative; + flex-shrink: 0; +} + +.shell-notifications-button { + position: relative; +} + +.shell-notifications-icon { + width: 18px; + height: 18px; + fill: none; + stroke: currentcolor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.shell-notifications-badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 18px; + height: 18px; + padding: 0 5px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--status-colour--danger); + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.corners--top-left { + border-radius: var(--radius--notch) 0 0 0; + corner-shape: notch square square square; +} + +.btn--sm { + min-width: 0; + height: 21px; + padding: 0 8px; + font-size: var(--font-size--button-primary); + font-weight: 700; + line-height: 1; + gap: 3px; + box-shadow: var(--shadow--surface); +} + +.btn--icon-square { + width: 29px; + height: 29px; + padding: 0; + flex-shrink: 0; + font-size: 12px; + line-height: 1; +} + +.btn--border-thin { + border-width: var(--border-size--default); +} diff --git a/webflow-designer-extension-cli/public/index.html b/webflow-designer-extension-cli/public/index.html index da786993e..669c1d090 100644 --- a/webflow-designer-extension-cli/public/index.html +++ b/webflow-designer-extension-cli/public/index.html @@ -3,6 +3,7 @@ + Hover for Webflow diff --git a/webflow-designer-extension-cli/public/styles.css b/webflow-designer-extension-cli/public/styles.css index 98e70dd1c..a1e99488b 100644 --- a/webflow-designer-extension-cli/public/styles.css +++ b/webflow-designer-extension-cli/public/styles.css @@ -397,111 +397,11 @@ body { background: var(--background-colour--panel); } -.topbar-brand-link { - display: inline-flex; - align-items: center; - justify-content: flex-start; - padding: 0; - background: transparent; - border: 0; - cursor: pointer; -} - -.brand-logo--topbar { - width: 88px; -} - -.topbar-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: var(--spacing--lg); - margin-left: auto; - min-width: 0; -} - -.topbar-profile { - justify-content: center; - background: transparent; - border-color: var(--border-colour--default); - box-shadow: none; - padding: 0; - overflow: hidden; -} - -.topbar-profile:not(:disabled):hover { - background: transparent; - border-color: var(--border-colour--default); - opacity: 0.85; -} - -.topbar-profile-avatar { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - background: var(--colour--brand-primary); - font-size: 0.6rem; - font-weight: 700; - letter-spacing: 0.03em; - color: #fff; - overflow: hidden; - border-radius: inherit; - user-select: none; -} - -.topbar-profile-avatar img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; - border-radius: inherit; -} - .topbar-org-select { width: 150px; min-width: 0; } -.shell-profile-menu, -.shell-notifications { - position: relative; - flex-shrink: 0; -} - -.shell-notifications-button { - position: relative; -} - -.shell-notifications-icon { - width: 18px; - height: 18px; - fill: none; - stroke: currentcolor; - stroke-width: 1.9; - stroke-linecap: round; - stroke-linejoin: round; -} - -.shell-notifications-badge { - position: absolute; - top: -6px; - right: -6px; - min-width: 18px; - height: 18px; - padding: 0 5px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 999px; - background: var(--status-colour--danger); - color: #fff; - font-size: 11px; - font-weight: 700; - line-height: 1; -} - .shell-dropdown { position: absolute; top: calc(100% + 10px); @@ -778,35 +678,6 @@ body { corner-shape: square notch notch square; } -.corners--top-left { - border-radius: var(--radius--notch) 0 0 0; - corner-shape: notch square square square; -} - -.btn--sm { - min-width: 0; - height: 21px; - padding: 0 8px; - font-size: var(--font-size--button-primary); - font-weight: 700; - line-height: 1; - gap: 3px; - box-shadow: var(--shadow--surface); -} - -.btn--icon-square { - width: 29px; - height: 29px; - padding: 0; - flex-shrink: 0; - font-size: 12px; - line-height: 1; -} - -.btn--border-thin { - border-width: var(--border-size--default); -} - .btn--square { border-radius: 0; corner-shape: initial; diff --git a/webflow-designer-extension-cli/public/styles/shell.css b/webflow-designer-extension-cli/public/styles/shell.css new file mode 100644 index 000000000..0c7a02068 --- /dev/null +++ b/webflow-designer-extension-cli/public/styles/shell.css @@ -0,0 +1,128 @@ +.topbar-brand-link { + display: inline-flex; + align-items: center; + justify-content: flex-start; + padding: 0; + background: transparent; + border: 0; + cursor: pointer; +} + +.brand-logo--topbar { + width: 88px; +} + +.topbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--spacing--lg); + margin-left: auto; + min-width: 0; +} + +.topbar-profile { + justify-content: center; + background: transparent; + border-color: var(--border-colour--default); + box-shadow: none; + padding: 0; + overflow: hidden; +} + +.topbar-profile:not(:disabled):hover { + background: transparent; + border-color: var(--border-colour--default); + opacity: 0.85; +} + +.topbar-profile-avatar { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + background: var(--colour--brand-primary); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.03em; + color: #fff; + overflow: hidden; + border-radius: inherit; + user-select: none; +} + +.topbar-profile-avatar img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} + +.shell-profile-menu, +.shell-notifications { + position: relative; + flex-shrink: 0; +} + +.shell-notifications-button { + position: relative; +} + +.shell-notifications-icon { + width: 18px; + height: 18px; + fill: none; + stroke: currentcolor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.shell-notifications-badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 18px; + height: 18px; + padding: 0 5px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--status-colour--danger); + color: #fff; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.corners--top-left { + border-radius: var(--radius--notch) 0 0 0; + corner-shape: notch square square square; +} + +.btn--sm { + min-width: 0; + height: 21px; + padding: 0 8px; + font-size: var(--font-size--button-primary); + font-weight: 700; + line-height: 1; + gap: 3px; + box-shadow: var(--shadow--surface); +} + +.btn--icon-square { + width: 29px; + height: 29px; + padding: 0; + flex-shrink: 0; + font-size: 12px; + line-height: 1; +} + +.btn--border-thin { + border-width: var(--border-size--default); +} diff --git a/webflow-designer-extension-cli/scripts/sync-shared.js b/webflow-designer-extension-cli/scripts/sync-shared.js index 49b3f458b..83b649237 100644 --- a/webflow-designer-extension-cli/scripts/sync-shared.js +++ b/webflow-designer-extension-cli/scripts/sync-shared.js @@ -47,6 +47,8 @@ const OPTIONAL_LIB_MODULES = [ "lib/invite-flow.js", ]; +const REQUIRED_STYLE_FILES = ["styles/shell.css"]; + function ensureDir(dir) { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); @@ -97,4 +99,15 @@ for (const file of OPTIONAL_LIB_MODULES) { } } +for (const file of REQUIRED_STYLE_FILES) { + const src = path.join(APP_ROOT, file); + const dest = path.join(PUBLIC, file); + if (fs.existsSync(src)) { + syncFile(src, dest); + } else { + console.error(` ERROR: required shared style missing: ${file}`); + process.exit(1); + } +} + console.log("Sync complete."); From 05570d0e245db09acc6206a10cbb9ce71fb219e1 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:09:31 +1000 Subject: [PATCH 40/41] Align extension avatar sources --- web/static/app/pages/webflow-login.js | 13 ++++++++- webflow-designer-extension-cli/src/index.ts | 31 ++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/web/static/app/pages/webflow-login.js b/web/static/app/pages/webflow-login.js index 6e3a82c1e..95e79ef8b 100644 --- a/web/static/app/pages/webflow-login.js +++ b/web/static/app/pages/webflow-login.js @@ -63,6 +63,17 @@ let requestedMode = getPopupContextValue( SEARCH_PARAMS.get("mode") || "login" ); +function getUserMetadataAvatarUrl(user) { + return ( + user?.user_metadata?.avatar_url || + user?.user_metadata?.picture || + user?.user_metadata?.avatar || + user?.identities?.find?.((identity) => identity?.identity_data?.avatar_url) + ?.identity_data?.avatar_url || + "" + ); +} + // ── Element references ───────────────────────────────────────────────────────── /** @returns {HTMLElement|null} */ @@ -157,7 +168,7 @@ async function handleAuthenticated(session) { user: { id: session.user?.id ?? "", email: session.user?.email ?? "", - avatarUrl: session.user?.user_metadata?.avatar_url ?? "", + avatarUrl: getUserMetadataAvatarUrl(session.user), }, }, targetOrigin diff --git a/webflow-designer-extension-cli/src/index.ts b/webflow-designer-extension-cli/src/index.ts index 2bff07d72..bef00373e 100644 --- a/webflow-designer-extension-cli/src/index.ts +++ b/webflow-designer-extension-cli/src/index.ts @@ -749,6 +749,35 @@ function pickUserDisplayName( return fullName || [firstName, lastName].filter(Boolean).join(" ").trim(); } +function pickUserAvatarUrl( + user: + | { + user_metadata?: Record | null; + identities?: Array<{ + identity_data?: Record | null; + } | null> | null; + } + | null + | undefined +): string { + const metadata = + (user?.user_metadata as Record | null | undefined) || null; + const directAvatar = String( + metadata?.avatar_url || metadata?.picture || metadata?.avatar || "" + ).trim(); + if (directAvatar) { + return directAvatar; + } + + const identityAvatar = user?.identities + ?.map((identity) => + String(identity?.identity_data?.avatar_url || "").trim() + ) + .find(Boolean); + + return identityAvatar || ""; +} + async function loadCurrentUserIdentity(): Promise { const client = await initSupabaseClient(); if (client?.auth?.getUser) { @@ -768,7 +797,7 @@ async function loadCurrentUserIdentity(): Promise { state.userDisplayName = displayName; } - const avatarUrl = String(metadata?.avatar_url || "").trim(); + const avatarUrl = pickUserAvatarUrl(user); if (avatarUrl) { state.userAvatarUrl = avatarUrl; } From a386c0aa17c753c812fec0c3fcbc77403eb385b6 Mon Sep 17 00:00:00 2001 From: Simon Smallchua <40650011+simonsmallchua@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:31:00 +1000 Subject: [PATCH 41/41] Refresh reuse docs --- CHANGELOG.md | 46 ++++++++-- .../webflow-extension-reuse-follow-up.md | 83 ++++++++++++------- 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 300b7812f..a3ca3c46b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,13 +28,49 @@ On merge, CI will: ## [Unreleased] +### Added + +- shared frontend modules for extension and dashboard reuse: + `web/static/app/lib/site-jobs.js`, `web/static/app/lib/webflow-sites.js`, + `web/static/app/lib/organisation-api.js`, + `web/static/app/lib/scheduler-api.js`, `web/static/app/lib/site-view.js`, + `web/static/app/lib/job-export.js`, and `web/static/app/lib/shell-nav.js` +- shared shell styling in `web/static/app/styles/shell.css`, now loaded by both + `/dashboard` and the Webflow Designer extension +- a native module-based `/extension-auth` flow in + `web/static/app/pages/webflow-login.js`, replacing the old extension popup + dependency on the legacy auth bundle +- the first native in-extension settings section, `Account`, using shared + account settings logic inside the extension shell rather than loading the app + page + ### Changed -- shared Webflow extension job fetching, site scoping, and realtime fallback - logic into `web/static/app/lib/site-jobs.js`, reducing duplication between the - app layer and extension bridge runtime -- aligned extension reuse docs with the current ES modules migration state and - the remaining hybrid auth popup architecture +- centralised Webflow extension job fetching, site scoping, and realtime + fallback logic into `web/static/app/lib/site-jobs.js`, reducing duplication + between the app layer and extension bridge runtime +- rewired the Webflow Designer extension to consume shared jobs, Webflow site, + organisation, scheduler, export, shell, and site-view helpers through the + bridge and sync pipeline +- moved `/dashboard` onto the extension-style shell and shared site-focused + module runtime, replacing the older dashboard-specific shell/layout +- aligned extension reuse docs with the current migration state, including the + shared shell layer, module-native popup auth, and remaining native-extension + settings/detail work + +### Fixed + +- extension popup auth return-to-extension handling, including stable popup + callback state restoration and module-native sign-in handoff +- cross-surface organisation sync so dashboard org changes refresh extension + organisation context instead of leaving stale labels behind +- extension account settings loading and profile save support, including CORS + `PATCH` allowance for `/v1/auth/profile` +- avatar rendering regressions across dashboard and extension, including Google + avatar CSP allowance, shared avatar DOM rendering, and extension avatar source + alignment with dashboard identity data +- required shared module/style sync failures now stop the extension build + instead of silently shipping missing bridge dependencies ## Full changelog history diff --git a/docs/plans/webflow-extension-reuse-follow-up.md b/docs/plans/webflow-extension-reuse-follow-up.md index 88738de5f..87b824294 100644 --- a/docs/plans/webflow-extension-reuse-follow-up.md +++ b/docs/plans/webflow-extension-reuse-follow-up.md @@ -1,6 +1,6 @@ # Webflow Extension Reuse Follow-up -Date: 2026-04-05 Status: Proposed Scope: Webflow Designer extension +Date: 2026-04-10 Status: In progress Scope: Webflow Designer extension consolidation and shared frontend reuse ## Current state @@ -22,10 +22,13 @@ The Webflow Designer extension has already adopted part of that shared layer: - shared module sync into the extension build - a bridge/import-map pattern so extension code can consume shared modules in a cross-origin runtime +- shared shell styling via `web/static/app/styles/shell.css` -That means the transition is partly complete. Shared primitives exist and are in -use, but the extension has not yet reached the same level of modular -consolidation as the main app. +That means the transition is no longer only a primitives-level migration. Shared +frontend logic now covers jobs, Webflow site configuration, organisation +context, scheduling, shell navigation, job export, and top-level site view +rendering. `/dashboard` has also moved onto the extension-style shell/layout +instead of the older dashboard-specific shell. ## What is already complete @@ -36,7 +39,20 @@ consolidation as the main app. - `pages/` for page orchestration - the extension reuses shared primitives and API helpers through the bridge/sync approach +- shared extension/dashboard logic now includes: + - `web/static/app/lib/site-jobs.js` + - `web/static/app/lib/webflow-sites.js` + - `web/static/app/lib/organisation-api.js` + - `web/static/app/lib/scheduler-api.js` + - `web/static/app/lib/site-view.js` + - `web/static/app/lib/job-export.js` + - `web/static/app/lib/shell-nav.js` - design tokens exist in the app layer and mirror the extension theme +- shared shell styling now lives in `web/static/app/styles/shell.css` +- the extension auth popup is now module-native through + `web/static/app/pages/webflow-login.js`, not the old `/js/auth.js` popup path +- the extension has its first native in-panel settings section (`Account`) + driven by shared settings logic rather than a framed app page - the completed migration is already documented in `CHANGELOG.md` ## Remaining gaps @@ -44,32 +60,38 @@ consolidation as the main app. - The extension still keeps most page orchestration in `webflow-designer-extension-cli/src/index.ts` rather than in shared `/app` modules. -- The current job-list sharing story is incomplete. The repository contains - `web/static/app/pages/webflow-jobs.js`, but the live extension still owns much - of its own job rendering and refresh flow. -- The extension auth popup still depends on legacy `/js/auth.js`. -- Extension shell styling remains separate from app styling. The app tokens - mirror the extension theme, but the extension shell has not been migrated to - the app style layer. -- Some documentation still overstates how far the extension has been - consolidated into the shared module system. +- Native extension settings coverage is incomplete. `Account` is native in the + extension shell, but the rest of the settings sections still need the same + treatment. +- Job details still need a native in-extension implementation rather than + relying on app-page stop-gaps. +- Extension CSS convergence is only partly complete. Shared shell styling now + exists, but the extension still carries a large local stylesheet in + `webflow-designer-extension-cli/public/styles.css`. +- Identity/avatar selection logic is aligned between dashboard and extension, + but still duplicated rather than centralised into one helper. +- Preview Webflow OAuth and run-on-publish flows still depend on callback URL + registration outside this frontend workstream. +- Some documentation and PR metadata still understate how far this branch has + moved beyond the original jobs-only extraction. ## Recommended next phase -This follow-up should be treated as a JavaScript-first reuse pass, not a full UI -unification project. +This follow-up should now be treated as a native extension-surface completion +pass. The branch has already moved beyond a jobs-only or JS-only reuse phase. - Extract surface-agnostic extension logic from `webflow-designer-extension-cli/src/index.ts` into shared `/app` modules. -- Make the job-list sharing story truthful and consistent: - - either move extension job-list behaviour onto shared `pages/` modules - - or narrow the shared module claims so the code and docs say exactly what is - shared +- Continue the native extension settings rollout: + - `Team` next + - then the remaining org-scoped settings sections +- Implement native in-extension job details using shared modules and shared + layout primitives instead of framed app-page fallbacks. - Keep the bridge/import-map approach for cross-origin extension use. -- Continue sharing reusable logic and UI primitives first, before attempting to - merge the full extension shell layout into the main app. -- Replace the remaining legacy auth dependency in `/extension-auth` so the popup - flow no longer relies on `/js/auth.js`. +- Continue moving extension shell/component CSS into shared app styles so the + dashboard and extension stop carrying parallel styling for the same UI. +- Centralise shared identity/avatar selection logic so dashboard and extension + stop duplicating provider-avatar fallback rules. - Update architecture and planning docs so they reflect the current state accurately. @@ -77,9 +99,8 @@ unification project. - Recreating the deleted March 2026 ES modules plan - Re-running the full `/dashboard` modernisation effort -- Forcing full shell or layout convergence between the extension and the main - app in this phase - Replacing working backend Webflow APIs as part of this documentation update +- Solving preview-domain Webflow OAuth registration inside frontend code ## Acceptance criteria @@ -87,10 +108,12 @@ unification project. archived in the changelog. - The new plan clearly states that both ES modules branch checkpoints are already contained in `main`. -- The new plan distinguishes between shared primitives that already exist and - extension page orchestration that is still local. -- The new plan sets a JS-first extension consolidation direction without - implying that the extension already shares all page-level logic with - `/dashboard`. +- The new plan distinguishes between what is already genuinely shared today and + what still remains extension-local. +- The new plan reflects that `/dashboard` has already moved onto the + extension-style shell/layout and that the popup auth flow is already + module-native. +- The new plan makes the remaining work explicit: native settings coverage, + native job details, `index.ts` thinning, and further CSS convergence. - The new plan supersedes the old branch-era planning context without recreating archived migration history.