diff --git a/admin/src/app/dashboard/OrganizationsPage.test.tsx b/admin/src/app/dashboard/OrganizationsPage.test.tsx new file mode 100644 index 000000000..a9f3d132f --- /dev/null +++ b/admin/src/app/dashboard/OrganizationsPage.test.tsx @@ -0,0 +1,48 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import type { Column } from "@/components/data/DataTable"; +import type { AdminOrgListItem } from "@/lib/api/models/admin"; +import OrganizationsPage from "./OrganizationsPage"; + +const state = vi.hoisted(() => ({ rows: [] as AdminOrgListItem[], columns: [] as Column[] })); +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: { data: state.rows, pagination: { total: state.rows.length } }, isLoading: false }), + keepPreviousData: undefined, +})); +vi.mock("@/components/data/DataTable", () => ({ + DataTable: ({ columns, rows }: { columns: Column[]; rows: AdminOrgListItem[] }) => { + state.columns = columns; + const channel = columns.find((column) => column.id === "channel")!; + return <>{rows.map((row) =>
{channel.cell(row)}
)}; + }, +})); + +function render(acquisition: Partial) { + state.rows = [{ id: "workspace", ...acquisition } as AdminOrgListItem]; + const html = renderToStaticMarkup(); + return new DOMParser().parseFromString(html, "text/html"); +} + +describe("organization signup channel", () => { + it("shows a referrer-only signup instead of calling it direct", () => { + const document = render({ referrer_host: "www.google.com" }); + expect(document.body.textContent).toContain("www.google.com"); + expect(state.columns.find((column) => column.id === "channel")?.defaultHidden).not.toBe(true); + }); + + it("keeps the campaign, referrer and landing path visible and exportable together", () => { + const document = render({ utm_source: "reddit", utm_medium: "social", utm_campaign: "launch", referrer_host: "www.reddit.com", landing_path: "/pricing" }); + expect(document.body.textContent).toContain("redditsocialwww.reddit.com/pricing"); + expect(document.querySelector('[title="www.reddit.com · launch · /pricing"]')).not.toBeNull(); + expect(state.columns.find((column) => column.id === "channel")?.csv?.(state.rows[0])).toBe("reddit | social | launch | www.reddit.com | /pricing"); + }); + + it("does not invent direct or an external source for unknown or landing-only acquisition", () => { + for (const acquisition of [{}, { landing_path: "/pricing" }]) { + const document = render(acquisition); + expect(document.body.textContent).toContain("Unknown"); + expect(document.body.textContent).not.toContain("direct"); + } + }); +}); diff --git a/admin/src/app/dashboard/OrganizationsPage.tsx b/admin/src/app/dashboard/OrganizationsPage.tsx index cb6ece32a..4743063fb 100644 --- a/admin/src/app/dashboard/OrganizationsPage.tsx +++ b/admin/src/app/dashboard/OrganizationsPage.tsx @@ -133,22 +133,18 @@ const columns: Column[] = [ { id: "channel", header: "Channel", - // Where the workspace came from, recorded once at signup. Hidden by - // default: most signups are direct and the column would read empty. - defaultHidden: true, - // "direct" means no acquisition data at all, which is the same thing - // the "No acquisition data" filter selects. A row with only a landing - // path is not direct, so it shows the path rather than falling through. cell: (o) => - o.utm_source || o.utm_medium || o.utm_campaign || o.landing_path ? ( -
- {o.utm_source || o.landing_path || "—"} + o.utm_source || o.utm_medium || o.utm_campaign || o.landing_path || o.referrer_host ? ( +
+ {o.utm_source || o.referrer_host || "Unknown"} {o.utm_medium && {o.utm_medium}} + {o.utm_source && o.referrer_host && {o.referrer_host}} + {o.landing_path && {o.landing_path}}
) : ( - direct + Unknown ), - csv: (o) => [o.utm_source, o.utm_medium, o.utm_campaign, o.landing_path].filter(Boolean).join(" | "), + csv: (o) => [o.utm_source, o.utm_medium, o.utm_campaign, o.referrer_host, o.landing_path].filter(Boolean).join(" | "), }, { id: "posture", @@ -238,10 +234,10 @@ export default function OrganizationsPage() { const [ownerBanned, setOwnerBanned] = useState(false); const [hasActiveCampaigns, setHasActiveCampaigns] = useState(false); const [hasEmailAccounts, setHasEmailAccounts] = useState(false); - // Acquisition channel. utmSource/utmMedium match exactly; the two toggles - // split "arrived through a tagged link" from "came in directly". + // Acquisition filters match the values recorded at signup exactly. const [utmSource, setUtmSource] = useState(""); const [utmMedium, setUtmMedium] = useState(""); + const [referrerHost, setReferrerHost] = useState(""); const [hasAcquisition, setHasAcquisition] = useState(false); const [noAcquisition, setNoAcquisition] = useState(false); // Count ranges @@ -264,7 +260,7 @@ export default function OrganizationsPage() { const filterKey = JSON.stringify({ query, status, visibility, subStatus, enterprise, managedPlan, hasOverrides, risk, cancelAtPeriodEnd, hasActiveSubscription, noSubscription, ownerBanned, hasActiveCampaigns, hasEmailAccounts, - utmSource, utmMedium, hasAcquisition, noAcquisition, + utmSource, utmMedium, referrerHost, hasAcquisition, noAcquisition, memMin, memMax, mbMin, mbMax, campMin, campMax, created, trialEnd, periodEnd, updated, sort, }); @@ -293,6 +289,7 @@ export default function OrganizationsPage() { has_email_accounts: hasEmailAccounts || undefined, utm_source: utmSource.trim() || undefined, utm_medium: utmMedium.trim() || undefined, + referrer_host: referrerHost.trim().toLowerCase() || undefined, has_acquisition: hasAcquisition || undefined, no_acquisition: noAcquisition || undefined, member_count_min: memMin, @@ -331,6 +328,7 @@ export default function OrganizationsPage() { (risk ? 1 : 0) + (utmSource ? 1 : 0) + (utmMedium ? 1 : 0) + + (referrerHost ? 1 : 0) + bools.filter(Boolean).length + ranges.filter(([a, b]) => a !== undefined || b !== undefined).length + [created, trialEnd, periodEnd, updated].filter(rangeActive).length + @@ -352,6 +350,7 @@ export default function OrganizationsPage() { setHasEmailAccounts(false); setUtmSource(""); setUtmMedium(""); + setReferrerHost(""); setHasAcquisition(false); setNoAcquisition(false); setMemMin(undefined); @@ -437,6 +436,9 @@ export default function OrganizationsPage() {
+
+ +
{/* Mutually exclusive: the backend resolves both-at-once by ignoring one, which would leave a filter switched @@ -449,7 +451,7 @@ export default function OrganizationsPage() { { setNoAcquisition(v); if (v) setHasAcquisition(false); }} - label="No acquisition data (direct)" + label="No acquisition data (unknown source)" />
diff --git a/admin/src/lib/api/models/admin.ts b/admin/src/lib/api/models/admin.ts index 9c3033f3f..2c6648904 100644 --- a/admin/src/lib/api/models/admin.ts +++ b/admin/src/lib/api/models/admin.ts @@ -747,12 +747,12 @@ export interface AdminOrgListItem { campaign_count: number; active_campaigns: number; risk_state?: OrgRiskState | null; - /** Acquisition channel recorded once at signup. Absent for a direct - * signup, which is most of them, and always absent on a self-host. */ + /** Acquisition recorded once at signup; check referrer_host too, since an absent UTM source is unknown. */ utm_source?: string | null; utm_medium?: string | null; utm_campaign?: string | null; landing_path?: string | null; + referrer_host?: string | null; plan_name?: string | null; plan_public?: boolean | null; is_enterprise: boolean; @@ -904,6 +904,7 @@ export interface AdminOrgSearch { utm_campaign?: string; has_acquisition?: boolean; no_acquisition?: boolean; + referrer_host?: string; // Subscription state subscription_status?: string; cancel_at_period_end?: boolean; diff --git a/docs/content/docs/api/endpoints.mdx b/docs/content/docs/api/endpoints.mdx index 22c59d2b3..b776621f0 100644 --- a/docs/content/docs/api/endpoints.mdx +++ b/docs/content/docs/api/endpoints.mdx @@ -610,6 +610,7 @@ Alongside them, `api_url` is this API's own public base (for a copyable example - All of `/auth/cli/*` except the two handshake routes below (`GET /auth/cli/codes/:code`, `POST /auth/cli/codes/:code/approve`, `POST /auth/cli/codes/:code/deny`: the browser half of `warmbly auth login`, where a signed-in member reviews the code a CLI is showing and authorizes it. Approving mints an ordinary API key, so it requires the `MANAGE_API_KEYS` organization permission and a fresh authentication, is session-only (an API key must not be able to mint another one this way), and caps the key to the scopes the approver's role allows. `GET /auth/cli/codes/:code?organization_id=` adds `granted_scopes` and `granted_scope_names`: what approving into that workspace would grant) - All of `/pool-link/*` and `/cloud-link/*` (the self-hosted warmup pool link: approving an instance's code, listing and unlinking instances, reading the pool plan's price and opening its checkout, and on a self-hosted instance the connect flow and mailbox enrollment). `/cloud-link/*` exists only on a self-hosted instance. Connections belong to the selected local workspace, but linking and unlinking them (`/connect`, `/connect/poll`, `DELETE /cloud-link`) and the linked cloud workspace's mailboxes (`/workspace-mailboxes`, `.../adopt`) take a platform admin holding `manage_settings` whose session presented a second factor (`admin_mfa_required` otherwise). Reading the link and putting a workspace's own mailboxes on it (`GET /cloud-link`, `/mailboxes`, enroll, pause, resume and the Google and Microsoft sign-in under `/oauth/*`) take the workspace's `MANAGE_EMAILS`. `GET /cloud-link` includes `legacy_connected`; mailbox rows include `legacy` when they still use the old connection. A normal `DELETE /cloud-link` only disconnects the selected workspace's scoped link. `DELETE /cloud-link?legacy=true` explicitly disconnects the legacy link across all workspaces and uses the same platform-admin permission. New connections and enrollments require workspace scope, and a Cloud workspace accepts only one active link (legacy links count). `POST /pool-link/codes` requires `remote_organization_id` and returns `workspace_scoped: true` for supported clients. `POST /pool-link/codes` and `POST /pool-link/poll` are public and per-IP rate limited: they are the device-code handshake an instance uses before it has a token, and `/pool-link/instance/*` accepts only an instance token. `/pool-link/instance/oauth/*`, `/pool-link/instance/mailboxes/:id/token`, `/pool-link/instance/workspace-mailboxes` and `/pool-link/instance/mailboxes/adopt` are the cloud-managed mailbox surface (Google and Microsoft sign-in on Warmbly's OAuth apps, brokered access tokens); their instance-side counterparts are `/cloud-link/oauth/*` and `/cloud-link/workspace-mailboxes/*`. `/pool-link/instance/redirects` (list), and `GET`, `PUT`, `DELETE` and `POST .../verify` on `/pool-link/instance/redirects/:domain`, are the root redirects Warmbly Cloud serves for the calling instance; the instance drives them through `PUT /emails/domains/:domain/redirect` with `served_by: "cloud"`, and `GET /pool-link/instance` carries `redirects` (`available`, `host`, `limit`, `used`) - All of `/admin/*`. `DELETE /admin/instance/invitations/expired` requires platform admin `manage_organizations` and an MFA-verified session. It removes only expired invitation records across all workspaces, leaves active invitations and membership unchanged, records an admin audit entry, and returns `{ "cleaned": true }`. The admin panel confirms before calling it. + - `GET /admin/organizations` and `GET /admin/organizations/:id` require `view_organizations`. Their acquisition fields include the nullable `referrer_host` saved at signup, alongside UTM source, medium, campaign and landing path. The list accepts an exact `referrer_host` filter as well as its existing acquisition filters. An absent source is unknown, not proof of a direct visit. See [signup source attribution](/guides/analytics/#signup-source-attribution-platform-admins). - `GET /admin/warmup-content/overview` includes effective generation controls and requires `view_warmup_pool`; `PUT /admin/warmup-content/settings` writes effective controls with `manage_settings`. These are platform-admin routes, not organization API-key scopes. See [effective generation policy and stopping](/development/warmup-generation/). - `POST /admin/organizations/:id/sample-data`: `manage_testers` and an MFA-verified admin session. Adds the [reviewer sample dataset](/development/accounts-and-access/#adding-sample-data) only to an active dedicated Test workspace. No request body. Returns `200` with `organization_id`, `created` (false when already seeded) and `seeded_at`; retries preserve edits and deletions. An ordinary workspace, revoked or expired tester, or missing/expired managed Test grant returns `400`. Inserts and the admin audit marker commit together. No mailbox connection, email delivery, external request or credit spend occurs. - `GET /admin/testers`: `view_users` and an MFA-verified admin session. Each tester in `data` may include `test_workspace_id` for its owned dedicated Test workspace and `sample_data_seeded_at` when populated. These fields are absent for a tester merely attached to an ordinary workspace. diff --git a/docs/content/docs/guides/analytics.mdx b/docs/content/docs/guides/analytics.mdx index b17c8a5d8..a65bf6ed3 100644 --- a/docs/content/docs/guides/analytics.mdx +++ b/docs/content/docs/guides/analytics.mdx @@ -9,6 +9,16 @@ Analytics rolls every send, open, click, reply, bounce, and complaint into rates Campaign analytics are shared across the workspace. With **View analytics**, you can see the overview, daily and hourly statistics, step performance and comparisons for campaigns created by any member of the selected workspace. The campaign author does not need to grant separate access. +## Signup source attribution (platform admins) + +This is separate from email and campaign analytics. The marketing site carries the original landing path, external referrer host and any `utm_source`, `utm_medium`, `utm_campaign`, `utm_term` and `utm_content` tags through its page links into the dashboard. App redirects and switching between sign-in and registration keep those fields available at signup. + +The new workspace records acquisition once. In the admin panel, **Organizations → Channel** shows the campaign source when supplied, the saved referrer host and landing path. If a saved table layout hides Channel, enable it under **Columns**. The acquisition filters accept an exact referrer host, such as `www.google.com` or `www.reddit.com`. **Overview → Acquisition** summarizes signup channels, referrers and paid conversions. + +Sources come from campaign tags or the browser's referrer, not guesses. Warmbly's own domains are not external sources. Browsers, search engines and apps can withhold referrers; absent source information is shown as **Unknown**, even when the landing page is known. Full referrer URLs, search queries and landing-page query strings are not stored. Acquisition values are limited in length and email-shaped values are redacted by the backend. + +The marketing site stays cookieless, with no persistent PostHog identity. Attribution travels in URLs, not cookies or browser storage. It survives linked navigation but cannot reliably reconnect a later unrelated visit, another device or a URL whose attribution parameters were removed. It is not a complete anonymous browsing history. Existing saved acquisition remains unchanged, and missing historical sources are not backfilled. No new configuration or database migration is required. + ## What is counted | Metric | Counts | Feeds | diff --git a/internal/models/admin.go b/internal/models/admin.go index 95e15d48f..09045a903 100644 --- a/internal/models/admin.go +++ b/internal/models/admin.go @@ -692,18 +692,12 @@ type AdminOrgSearch struct { UpdatedAfter *time.Time `form:"updated_after" time_format:"2006-01-02" time_utc:"true"` UpdatedBefore *time.Time `form:"updated_before" time_format:"2006-01-02" time_utc:"true"` - // Acquisition channel. UTMSource/UTMMedium/UTMCampaign match exactly. - // - // HasAcquisition and NoAcquisition select on the presence of an - // acquisition record, not on a UTM tag: a signup that came from a - // marketing page with no campaign parameters has a landing path and - // counts as having acquisition data. "Direct" therefore means "arrived - // with nothing at all", which is what the admin list's Channel column - // shows too. They are mutually exclusive; setting both applies only - // HasAcquisition. + // Exact acquisition filters. Presence toggles test the row, not a known source. + // Setting both presence toggles applies only HasAcquisition. UTMSource string `form:"utm_source"` UTMMedium string `form:"utm_medium"` UTMCampaign string `form:"utm_campaign"` + ReferrerHost string `form:"referrer_host"` HasAcquisition bool `form:"has_acquisition"` NoAcquisition bool `form:"no_acquisition"` @@ -740,13 +734,12 @@ type AdminOrgListItem struct { // which workspaces a detector has acted on without a call per row. RiskState OrgRiskState `json:"risk_state,omitempty"` - // Acquisition channel recorded at signup. Inlined so revenue by channel is - // readable in the table rather than a separate report. Absent for a direct - // signup, which is most of them, and always absent on a self-host. - UTMSource *string `json:"utm_source,omitempty"` - UTMMedium *string `json:"utm_medium,omitempty"` - UTMCampaign *string `json:"utm_campaign,omitempty"` - LandingPath *string `json:"landing_path,omitempty"` + // Acquisition recorded at signup; absent values are unknown, not necessarily direct. + UTMSource *string `json:"utm_source,omitempty"` + UTMMedium *string `json:"utm_medium,omitempty"` + UTMCampaign *string `json:"utm_campaign,omitempty"` + LandingPath *string `json:"landing_path,omitempty"` + ReferrerHost *string `json:"referrer_host,omitempty"` // Plan summary (LEFT JOINed via the org's single active subscription). PlanName *string `json:"plan_name,omitempty"` diff --git a/internal/repository/org_acquisition_live_test.go b/internal/repository/org_acquisition_live_test.go index d81e9ff42..17c07de2d 100644 --- a/internal/repository/org_acquisition_live_test.go +++ b/internal/repository/org_acquisition_live_test.go @@ -7,10 +7,7 @@ import ( "github.com/warmbly/warmbly/internal/models" ) -// Acquisition is written once at signup and read back through the admin org -// list, which means a LEFT JOIN, four extra scanned columns and three new -// filters. None of that is exercised by a unit test, so it is checked here -// against a real schema. +// Acquisition round-trips through signup and admin queries against a real schema. // // WARMBLY_TEST_DB=postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable \ // go test ./internal/repository/ -run LiveOrgAcquisition -v @@ -120,7 +117,9 @@ func TestLiveOrgAcquisitionSurfacesInTheAdminList(t *testing.T) { if item.UTMSource != nil { t.Errorf("utm_source = %v for an org with no acquisition row, want nil", *item.UTMSource) } - // It counts as a direct signup and not as a tagged one. + if item.ReferrerHost != nil { + t.Errorf("referrer_host = %v for an org with no acquisition row, want nil", *item.ReferrerHost) + } if find(t, &models.AdminOrgSearch{NoAcquisition: true}) == nil { t.Error("an org with no acquisition row should match no_acquisition") } @@ -132,6 +131,7 @@ func TestLiveOrgAcquisitionSurfacesInTheAdminList(t *testing.T) { OrganizationID: f.org, UTMSource: "newsletter", UTMMedium: "email", + ReferrerHost: "newsletter.example", LandingPath: "/pricing", }); err != nil { t.Fatalf("RecordOrganizationAcquisition: %v", err) @@ -150,6 +150,25 @@ func TestLiveOrgAcquisitionSurfacesInTheAdminList(t *testing.T) { if item.LandingPath == nil || *item.LandingPath != "/pricing" { t.Errorf("landing_path = %v, want /pricing", item.LandingPath) } + if item.ReferrerHost == nil || *item.ReferrerHost != "newsletter.example" { + t.Errorf("referrer_host = %v, want newsletter.example", item.ReferrerHost) + } + detail, err := repo.GetOrganizationAdminDetail(ctx, f.org) + if err != nil { + t.Fatalf("GetOrganizationAdminDetail: %v", err) + } + if detail.ReferrerHost == nil || *detail.ReferrerHost != "newsletter.example" { + t.Errorf("detail referrer_host = %v, want newsletter.example", detail.ReferrerHost) + } + if find(t, &models.AdminOrgSearch{ReferrerHost: "newsletter.example"}) == nil { + t.Error("filtering by the saved referrer did not find the org") + } + if find(t, &models.AdminOrgSearch{ReferrerHost: "www.google.com"}) != nil { + t.Error("filtering by another referrer still found the org") + } + if find(t, &models.AdminOrgSearch{ReferrerHost: "newsletter.example' OR TRUE --"}) != nil { + t.Error("referrer filter should treat SQL syntax as a literal host") + } // The join must not duplicate the row. if item.MemberCount != 1 { t.Errorf("member_count = %d, want 1: the acquisition join should not fan out rows", item.MemberCount) @@ -179,3 +198,29 @@ func TestLiveOrgAcquisitionSurfacesInTheAdminList(t *testing.T) { } }) } + +func TestLiveOrgAcquisitionReferrerOnly(t *testing.T) { + _, pool := liveContactDB(t) + ctx := context.Background() + f := newAdminFixture(t, pool) + repo := NewOrganizationRepository(pool) + if err := repo.RecordOrganizationAcquisition(ctx, &models.OrgAcquisition{ + OrganizationID: f.org, + ReferrerHost: "www.google.com", + }); err != nil { + t.Fatal(err) + } + result, err := repo.SearchOrganizationsForAdmin(ctx, &models.AdminOrgSearch{ + Query: f.tag, ReferrerHost: "www.google.com", HasAcquisition: true, Limit: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(result.Data) != 1 { + t.Fatalf("referrer-only filter matched %d rows, want 1", len(result.Data)) + } + item := result.Data[0] + if item.ReferrerHost == nil || *item.ReferrerHost != "www.google.com" || item.UTMSource != nil { + t.Fatalf("referrer-only attribution = %+v", item) + } +} diff --git a/internal/repository/pg_organization.go b/internal/repository/pg_organization.go index 44c454817..d4c669572 100644 --- a/internal/repository/pg_organization.go +++ b/internal/repository/pg_organization.go @@ -781,10 +781,9 @@ const adminOrgListColumns = ` (SELECT COUNT(*) FROM campaigns c WHERE c.organization_id = o.id) AS campaign_count, (SELECT COUNT(*) FROM campaigns c WHERE c.organization_id = o.id AND c.status = 'active') AS active_campaigns, o.risk_state, - oa.utm_source, oa.utm_medium, oa.utm_campaign, oa.landing_path, o.category` + oa.utm_source, oa.utm_medium, oa.utm_campaign, oa.landing_path, oa.referrer_host, o.category` -// adminOrgAcquisitionJoin brings in the signup channel. LEFT because most -// workspaces have no row: a direct signup carries nothing to record. +// LEFT JOIN preserves workspaces with no recorded acquisition. const adminOrgAcquisitionJoin = ` LEFT JOIN organization_acquisition oa ON oa.organization_id = o.id` @@ -906,11 +905,8 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context addChannel("utm_source", search.UTMSource) addChannel("utm_medium", search.UTMMedium) addChannel("utm_campaign", search.UTMCampaign) - // Presence of a record, not presence of a UTM tag: a signup that came from - // a marketing page with no campaign parameters still has a landing path, - // and calling that "direct" would be wrong. The admin panel's labels and - // its Channel column use the same definition (models.AdminOrgSearch). - // Mutually exclusive; both set applies only HasAcquisition. + addChannel("referrer_host", search.ReferrerHost) + // Record presence is not proof of a known source; both set applies only HasAcquisition. if search.HasAcquisition { where += ` AND oa.organization_id IS NOT NULL` } else if search.NoAcquisition { @@ -1000,6 +996,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context &item.MemberCount, &item.EmailAccountCount, &item.CampaignCount, &item.ActiveCampaigns, &item.RiskState, &item.UTMSource, &item.UTMMedium, &item.UTMCampaign, &item.LandingPath, + &item.ReferrerHost, &item.Category, &planName, &planPublic, &isEnterprise, &managedAt, &managedReason, &managedUntil, @@ -1064,6 +1061,7 @@ func (r *organizationRepository) GetOrganizationAdminDetail(ctx context.Context, &detail.MemberCount, &detail.EmailAccountCount, &detail.CampaignCount, &detail.ActiveCampaigns, &detail.RiskState, &detail.UTMSource, &detail.UTMMedium, &detail.UTMCampaign, &detail.LandingPath, + &detail.ReferrerHost, &detail.Category, &detail.UpdatedAt, &detail.DeletionScheduledAt, &detail.PlanName, &detail.SubscriptionStatus, &isEnterprise, &detail.CurrentPeriodEnd, &detail.TrialEnd, diff --git a/site/package.json b/site/package.json index 66718afc6..51a7925ff 100644 --- a/site/package.json +++ b/site/package.json @@ -4,7 +4,7 @@ "version": "1.0.0", "private": true, "scripts": { - "test": "node scripts/check-observability.mjs", + "test": "node scripts/check-observability.mjs && node scripts/check-acquisition.mjs", "dev": "astro dev", "build": "astro build", "preview": "astro preview", diff --git a/site/scripts/check-acquisition.mjs b/site/scripts/check-acquisition.mjs new file mode 100644 index 000000000..ec3edf9fc --- /dev/null +++ b/site/scripts/check-acquisition.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const layout = readFileSync(new URL('../src/layouts/Layout.astro', import.meta.url), 'utf8'); +const script = layout.match(/ diff --git a/web/src/lib/acquisition.test.ts b/web/src/lib/acquisition.test.ts new file mode 100644 index 000000000..1cb7f8c38 --- /dev/null +++ b/web/src/lib/acquisition.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { acquisitionSearch, isEmpty, readAcquisition } from "./acquisition"; + +afterEach(() => vi.unstubAllGlobals()); + +function browser(referrer = "") { + vi.stubGlobal("window", { location: { hostname: "app.warmbly.com", origin: "https://app.warmbly.com", search: "" } }); + vi.stubGlobal("document", { referrer }); +} + +describe("cookieless acquisition", () => { + it("carries only acquisition fields through redirects, not credentials or a nested next", () => { + const search = "?utm_source=reddit&utm_medium=social&utm_campaign=launch&utm_term=email&utm_content=cta&wb_lp=%2Fpricing&wb_ref=www.reddit.com&next=%2Fapp&code=secret&email=person%40example.com"; + const carried = acquisitionSearch(search); + expect(carried).toEqual({ utm_source: "reddit", utm_medium: "social", utm_campaign: "launch", utm_term: "email", utm_content: "cta", wb_lp: "/pricing", wb_ref: "www.reddit.com" }); + browser("https://warmbly.com/pricing"); + expect(readAcquisition(new URLSearchParams(carried).toString())).toEqual({ utm_source: "reddit", utm_medium: "social", utm_campaign: "launch", utm_term: "email", utm_content: "cta", landing_path: "/pricing", referrer_host: "www.reddit.com" }); + }); + + it("trims and bounds values without fabricating missing tags", () => { + expect(acquisitionSearch(`?utm_source=%20reddit%20&utm_medium=%20&wb_ref=${"a".repeat(300)}`)).toEqual({ utm_source: "reddit", wb_ref: "a".repeat(255) }); + expect(acquisitionSearch("?next=/app&code=secret")).toEqual({}); + }); + + it("does not call our own domains a source or infer direct from an empty referrer", () => { + for (const referrer of ["", "https://warmbly.com/pricing", "https://www.warmbly.com/", "https://app.warmbly.com/auth/login"]) { + browser(referrer); + expect(isEmpty(readAcquisition(""))).toBe(true); + expect(readAcquisition("?wb_lp=/pricing")).toEqual({ landing_path: "/pricing" }); + } + }); + + it("records an external host without retaining the referrer path or query", () => { + browser("https://www.Google.com/search?q=private"); + expect(readAcquisition("")).toEqual({ referrer_host: "www.google.com" }); + expect(readAcquisition("?wb_ref=WWW.REDDIT.COM&wb_lp=/pricing%3Femail=private%23section")).toEqual({ referrer_host: "www.reddit.com", landing_path: "/pricing" }); + expect(readAcquisition("?wb_ref=https%3A%2F%2Fwww.reddit.com%2Fr%2Femail").referrer_host).toBe("www.reddit.com"); + }); + + it("rejects network-path landing URLs and malformed referrers", () => { + browser(); + expect(readAcquisition("?wb_lp=//evil.example/path&wb_ref=%25")).toEqual({}); + expect(readAcquisition(`?wb_lp=${encodeURIComponent("/\\evil.example/offer")}`)).toEqual({}); + expect(readAcquisition("?wb_lp=https://evil.example/path&wb_ref=warmbly.com")).toEqual({}); + }); +}); diff --git a/web/src/lib/acquisition.ts b/web/src/lib/acquisition.ts index 8d9850ea4..e607ff2c0 100644 --- a/web/src/lib/acquisition.ts +++ b/web/src/lib/acquisition.ts @@ -25,6 +25,7 @@ export interface Acquisition { // every ad platform and email tool already emits, so nothing has to be taught // a Warmbly-specific parameter. export const UTM_PARAMS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"] as const; +const ACQUISITION_PARAMS = [...UTM_PARAMS, "wb_lp", "wb_ref"] as const; // Values are clamped here as well as on the server, so a hand-edited link // cannot make the signup request enormous. @@ -36,8 +37,17 @@ function clamp(value: string | null): string | undefined { return trimmed || undefined; } -// readAcquisition reads the current URL. Returns an empty object for a direct -// visit, which is most of them, and the backend then stores nothing. +export function acquisitionSearch(search: string): Record { + const params = new URLSearchParams(search); + const result: Record = {}; + for (const name of ACQUISITION_PARAMS) { + const value = clamp(params.get(name)); + if (value) result[name] = value; + } + return result; +} + +// Unknown visits with no acquisition data write nothing on the backend. export function readAcquisition(search: string = window.location.search): Acquisition { const params = new URLSearchParams(search); const acquisition: Acquisition = {}; @@ -51,16 +61,22 @@ export function readAcquisition(search: string = window.location.search): Acquis // clicked through. Only a path is accepted: a full URL would carry the // referring page's own query string, which is not ours to store. const landing = clamp(params.get("wb_lp")); - if (landing && landing.startsWith("/")) acquisition.landing_path = landing; + if (landing?.startsWith("/") && !landing.startsWith("//")) { + try { + const url = new URL(landing, window.location.origin); + if (url.origin === window.location.origin) acquisition.landing_path = url.pathname; + } catch { /* Invalid landing paths carry no attribution. */ } + } // The referrer is reduced to a bare host for the same reason. - const referrer = clamp(params.get("wb_ref")) ?? hostOf(document.referrer); + const forwarded = clamp(params.get("wb_ref")); + const referrer = (forwarded ? hostOf(forwarded) : undefined) ?? hostOf(document.referrer); if (referrer) acquisition.referrer_host = referrer; return acquisition; } -// isEmpty reports a direct visit, so the caller can omit the field entirely. +// Omit the field entirely when no acquisition data is available. export function isEmpty(acquisition: Acquisition): boolean { return Object.keys(acquisition).length === 0; } @@ -68,9 +84,9 @@ export function isEmpty(acquisition: Acquisition): boolean { function hostOf(url: string): string | undefined { if (!url) return undefined; try { - const host = new URL(url).hostname.toLowerCase(); + const host = new URL(url.includes("://") ? url : `https://${url}`).hostname.toLowerCase(); // Our own origin is not a referral. - return host === window.location.hostname ? undefined : host; + return host === window.location.hostname || host === "warmbly.com" || host.endsWith(".warmbly.com") ? undefined : host; } catch { return undefined; } diff --git a/web/src/lib/boot.test.ts b/web/src/lib/boot.test.ts new file mode 100644 index 000000000..e2e71f024 --- /dev/null +++ b/web/src/lib/boot.test.ts @@ -0,0 +1,50 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { bootDashboard, requireDashboardToken, resetBoot } from "./boot"; +import getToken from "./helper/getToken"; + +vi.mock("./helper/getToken", () => ({ default: vi.fn() })); + +beforeEach(() => { + resetBoot(); + vi.mocked(getToken).mockReturnValue(null); +}); +afterEach(() => vi.restoreAllMocks()); + +function loginRedirect(href: string) { + const url = new URL(href, window.location.origin); + try { + requireDashboardToken({ pathname: url.pathname, href }); + } catch (error) { + return error as { options: { to: string; search: Record } }; + } + throw new Error("Expected an unauthenticated redirect"); +} + +describe("dashboard acquisition redirect", () => { + it("keeps acquisition at login after a dashboard link, without copying unrelated parameters", () => { + const redirected = loginRedirect("/app/emails?utm_source=reddit&wb_lp=/pricing&wb_ref=www.reddit.com&code=private"); + expect(redirected.options.to).toBe("/auth/login"); + expect(redirected.options.search).toEqual({ utm_source: "reddit", wb_lp: "/pricing", wb_ref: "www.reddit.com" }); + }); + + it("still retains the one-time Slack destination alongside acquisition", () => { + const href = "/app/slack/link?code=one-time&wb_ref=www.google.com"; + expect(loginRedirect(href).options.search).toEqual({ wb_ref: "www.google.com", next: href }); + }); + + it("does not add attribution for an untagged visit", () => { + expect(loginRedirect("/app/emails").options.search).toEqual({}); + }); + + it("retains acquisition when a remembered session expires during boot", async () => { + vi.mocked(getToken).mockReturnValue({} as NonNullable>); + const client = new QueryClient(); + vi.spyOn(client, "prefetchQuery").mockResolvedValue(undefined); + vi.spyOn(client, "ensureQueryData").mockRejectedValue({ status: 401 }); + await expect(bootDashboard(client, { pathname: "/app/emails", href: "/app/emails?wb_ref=www.reddit.com&wb_lp=/pricing" })).rejects.toMatchObject({ + options: { to: "/auth/login", search: { wb_ref: "www.reddit.com", wb_lp: "/pricing" } }, + }); + client.clear(); + }); +}); diff --git a/web/src/lib/boot.ts b/web/src/lib/boot.ts index c4e4db66c..da67a61fa 100644 --- a/web/src/lib/boot.ts +++ b/web/src/lib/boot.ts @@ -4,6 +4,7 @@ import { isRedirect, redirect } from "@tanstack/react-router"; import type { QueryClient } from "@tanstack/react-query"; import getToken from "./helper/getToken"; +import { acquisitionSearch } from "./acquisition"; import { clearClientSession } from "./session"; import { AuthError } from "./errors/auth"; import type { AppError } from "./api/client/normalizeError"; @@ -41,9 +42,14 @@ function isAuthFailure(err: unknown): boolean { export function requireDashboardToken(location: { pathname: string; href: string }) { if (getToken()) return; + redirectToLogin(location); +} + +function redirectToLogin(location: { pathname: string; href: string }) { // A Slack link code is single-use and short-lived, so it survives sign-in. const next = location.pathname === "/app/slack/link" ? location.href : undefined; - throw redirect({ to: "/auth/login", search: next ? { next } : {}, replace: true }); + const search = acquisitionSearch(new URL(location.href, window.location.origin).search); + throw redirect({ to: "/auth/login", search: next ? { ...search, next } : search, replace: true }); } /** Resolves once the dashboard session is ready; page loaders await it before org-scoped fetches. */ @@ -53,14 +59,14 @@ export function dashboardReady(): Promise { export async function bootDashboard(queryClient: QueryClient, location: { pathname: string; href: string }) { requireDashboardToken(location); - boot ??= runBoot(queryClient).catch((err) => { + boot ??= runBoot(queryClient, location).catch((err) => { boot = null; throw err; }); await boot; } -async function runBoot(queryClient: QueryClient) { +async function runBoot(queryClient: QueryClient, location: { pathname: string; href: string }) { const store = useAppStore.getState(); const remembered = store.currentOrganization; @@ -101,7 +107,7 @@ async function runBoot(queryClient: QueryClient) { } catch (err) { if (isAuthFailure(err)) { clearClientSession(queryClient); - throw redirect({ to: "/auth/login", replace: true }); + redirectToLogin(location); } if (isRedirect(err)) throw err; // Anything else is shown by the providers' error states, and the next navigation boots again. diff --git a/web/src/router.test.ts b/web/src/router.test.ts new file mode 100644 index 000000000..90a601f8a --- /dev/null +++ b/web/src/router.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; +import { router } from "./router"; + +vi.mock("./lib/helper/getToken", () => ({ default: () => null })); + +type Guard = (context: { location: { href: string; searchStr: string } }) => void; +type Redirect = { options: { to: string; search: Record } }; + +function routeRedirect(path: "/" | "/oauth" | "/onboarding", href: string): Redirect { + const route = router.routesByPath[path]; + const beforeLoad = (route.options.beforeLoad ?? route.parentRoute?.options.beforeLoad) as Guard; + try { + beforeLoad({ location: { href, searchStr: new URL(href, window.location.origin).search } }); + } catch (error) { + return error as Redirect; + } + throw new Error("Expected route to redirect"); +} + +describe("acquisition route redirects", () => { + it("keeps acquisition through the root entrypoint", () => { + const redirected = routeRedirect("/", "/?utm_source=reddit&wb_ref=www.reddit.com&wb_lp=/pricing&code=private"); + expect(redirected.options.to).toBe("/app/emails"); + expect(redirected.options.search).toEqual({ utm_source: "reddit", wb_ref: "www.reddit.com", wb_lp: "/pricing" }); + }); + + it("keeps acquisition available on sign-in as well as in the existing next destination", () => { + for (const path of ["/oauth", "/onboarding"] as const) { + const href = `${path}?wb_ref=www.google.com&wb_lp=/`; + const redirected = routeRedirect(path, href); + expect(redirected.options.to).toBe("/auth/login"); + expect(redirected.options.search).toEqual({ wb_ref: "www.google.com", wb_lp: "/", next: href }); + } + }); +}); diff --git a/web/src/router.tsx b/web/src/router.tsx index 316a5f5f5..0baf53fa7 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -20,6 +20,7 @@ import getToken from "./lib/helper/getToken"; import { bootDashboard, requireDashboardToken } from "./lib/boot"; import { queryClient } from "./lib/queryClient"; import { parseSearch, stringifySearch, type SearchParams } from "./lib/routerSearch"; +import { acquisitionSearch } from "./lib/acquisition"; import * as loaders from "./routeLoaders"; import type { PageLoader } from "./routeLoaders"; @@ -130,16 +131,16 @@ function forward(parent: TParent } // Pages for someone who must be signed in, outside the dashboard chrome. -function requireToken({ location }: { location: { href: string } }) { +function requireToken({ location }: { location: { href: string; searchStr: string } }) { if (getToken()) return; - throw redirect({ to: "/auth/login", search: { next: location.href }, replace: true }); + throw redirect({ to: "/auth/login", search: { ...acquisitionSearch(location.searchStr), next: location.href }, replace: true }); } const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", - beforeLoad: () => { - throw redirect({ to: "/app/emails", replace: true }); + beforeLoad: ({ location }) => { + throw redirect({ to: "/app/emails", search: acquisitionSearch(location.searchStr), replace: true }); }, }); @@ -207,9 +208,7 @@ const onboardingRoute = createRoute({ getParentRoute: () => rootRoute, path: "onboarding", pendingComponent: BlankPending, - beforeLoad: () => { - if (!getToken()) throw redirect({ to: "/auth/login", replace: true }); - }, + beforeLoad: requireToken, component: lazyRouteComponent(() => import("./app/onboarding/layout")), }); const onboardingIndex = createRoute({