feat(analytics): remove Recapt, PostHog is now the only analytics (#1238)

* feat(analytics): remove Recapt, PostHog is now the only analytics

Recapt shuts down in days. Everything it did is covered by the PostHog
integration in the previous commit, so the SDK, its five modules and its
CSP hosts come out.

Deleted: RecaptLoader, RecaptHideWidget, RecaptIdentify, lib/recapt.ts,
types/recapt.d.ts. Unmounted from app/layout.tsx (the <script> in <head>
and the widget-hider) and from app/(dashboard)/layout.tsx. Both logout
handlers already call resetAnalyticsIdentity() and now only that.

The CSP gets strictly narrower: connect-src loses api.recapt.app and
cdn.recapt.app, script-src loses cdn.recapt.app, and nothing is added in
their place, because PostHog runs through the same-origin /rl rewrite.
Verified against the built routes-manifest.

Behaviour change worth calling out: lib/support/submit-feedback.ts is now
single-channel. Recapt used to accept the message through its own SDK, so
a failing /api/support/contact still reported success to the user. Email
is now the only delivery path and its failure is visible. That is the
right outcome, silently "succeeding" while the message reached nobody was
worse, and the Resend path is solid. A non-blocking
posthog.capture('support_feedback_submitted') keeps the useful half of
the old dual-channel behaviour by putting the submission on the user's
timeline next to the session replay; it carries no message body, since
free text is user content and would be PII in an event property. The six
Recapt-specific test cases are replaced with the email-only contract plus
coverage of the breadcrumb, the self-hosted skip, and a throwing SDK not
breaking delivery.

Compliance, which Recapt never had: the privacy page sub-processor row is
replaced (not just deleted) with an accurate PostHog row, and .compliance/
ropa.yaml gains a product.analytics activity. The old row also claimed
Recapt loaded "endast for inloggade anvandare", which was never true,
RecaptLoader sat in the root <head> on every page including logged-out
ones. The new row describes what actually happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analytics): purge Recapt storage left on users' devices

Removing the Recapt <script> stops it writing anything new, but every
browser that already loaded the app keeps what it persisted. Observed on
production after #1237: localStorage still holds
`__recapt_record_engine`, and after this PR nothing would ever remove it,
because the helper that used to sweep on logout (lib/recapt.ts
clearRecaptIdentity) is deleted along with the SDK.

Inert data, but it is third-party storage from a processor the privacy
page now says we no longer use, and the whole point of the PostHog
config is that nothing is stored on the device. So clear it.

Matching is by substring rather than prefix on purpose: the old sweep
tested key.startsWith('recapt'), which never actually matched the real
key, since `__recapt_record_engine` starts with underscores. A test pins
that. The app's own keys (Accounted:chat-sidebar-collapsed,
gnubok.inbox.onboarding.dismissed) contain neither marker.

Runs unconditionally from instrumentation-client.ts, before the
analytics gate, so a browser gets cleaned even on a build where PostHog
is switched off. Iterates backwards because removeItem() re-indexes the
store and a forward loop would skip entries; both covered by tests, along
with private-mode throws and the server no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-27 15:08:32 +02:00
committed by GitHub
parent c62d00bcb3
commit 248d98bd7e
18 changed files with 337 additions and 269 deletions
+51
View File
@@ -535,3 +535,54 @@ processing_activities:
- webhook_secret_constant_time_validation
- rls_company_scoped
- immutable_status_machine_trigger
- id: product.analytics
name: Produktanalys, sessionsinspelning, felrapportering och enkäter (PostHog)
purpose: >-
Förstå hur tjänsten faktiskt används (var användare fastnar i
onboarding, vilka flöden som avbryts), fånga klientfel och serverfel så
de kan åtgärdas, och ställa riktade produktfrågor. Endast hostad drift:
self-hosted-installationer laddar aldrig PostHog (isAnalyticsEnabled()
kortsluter på NEXT_PUBLIC_SELF_HOSTED, och ingen token bakas in i
Docker-imagen). Sandbox-/demoföretag identifieras aldrig.
lawful_basis: art_6_1_f # legitimate interest (produktförbättring och felsökning)
special_category_basis: null
controller: gnubok-tenant
processor: posthog-eu
data_subjects:
- business_owner
- company_member
data_categories:
- user.contact.email # person property via identify(), aldrig i event-properties
- user.name # profiles.full_name
- user.behavior # sidvisningar, klick, händelser, sessionsinspelning
- user.device # user agent, skärmstorlek, IP (trunkeras av PostHog)
# EJ organisationsnummer: för enskild firma ÄR orgnr innehavarens
# personnummer. buildGroupProperties() vägrar skicka det och ett
# enhetstest låser fast beteendet.
recipients:
- name: PostHog
country: DE
role: processor
international_transfers:
applicable: false
mechanism: null
note: >-
PostHog Cloud EU (Frankfurt). EU-baserat biträde; ingen
tredjelandsöverföring. DPA tecknat; SCCs gäller för eventuella
underbiträden utanför EES.
retention:
duration: posthog_project_retention
basis: processor_configured # sätts i PostHog-projektet, ej i denna kodbas
stored_in:
- posthog_eu # externt hos biträdet; inget lagras i vår databas
security_measures:
- session_replay_masks_all_text # maskTextSelector '*' utöver maskAllInputs
- session_replay_masks_all_inputs
- org_number_never_transmitted # låst av test i lib/analytics/__tests__
- no_pii_in_event_properties # PII endast som person properties via identify()
- no_device_storage # persistence: 'memory', inga kakor, ingen consent-banner krävs
- same_origin_reverse_proxy # /rl-rewrite; ingen tredjepartsvärd i CSP
- sandbox_companies_never_identified
- disabled_entirely_when_self_hosted
- server_side_payloads_redacted_before_send # lib/observability/redact.ts
+9
View File
@@ -590,3 +590,12 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-27] Compliance-review artifact unpacks to runner.temp instead of the workspace root: extracting fork-influenced content over the trusted checkout, with AWS secrets in scope, was safe only because stage 1 happens to write fixed filenames; moving it makes overwrite unreachable by construction.
[2026-07-27] Supplier-invoice 'overdue' stays a stored status, made symmetric instead of derived (#1206): added approved_at as the durable attest marker and an un-flip branch in update_overdue_supplier_invoices(), rather than computing overdue at read time. Computing it would have touched every list/filter/report query that reads status plus the v1 API contract; the symmetric-cron fix is the same user-visible outcome at a fraction of the blast radius.
[2026-07-27] In-browser preview (#1190) opens a new tab against an inline-disposition URL instead of an in-app viewer surface for invoice PDFs: the browser's native PDF viewer already does the job, and reusing resolveInvoicePdfSource keeps the archived-vs-rerender distinction intact, which a separate preview path would have had to duplicate.
[2026-07-27] Routed PostHog through a same-origin rewrite (/rl -> eu.i.posthog.com, next.config.ts) instead of allowlisting *.posthog.com in the CSP, which is what PostHog's own docs suggest. Same-origin means connect-src 'self' and script-src 'self' already cover ingestion and the lazy-loaded replay/survey bundles, so replacing Recapt removed two CSP hosts and added none; it also leaves tracking blockers no third-party host to match. Cost is one global setting, skipTrailingSlashRedirect: true (PostHog sends trailing-slash API requests), verified not to break trailing-slash URLs on normal routes: /login/ still resolves 200, it just no longer 308s to /login.
[2026-07-27] /rl is excluded from the proxy.ts middleware matcher. Next.js runs middleware BEFORE next.config rewrites, so without the exclusion updateSession() treats a PostHog ingestion POST as an unknown protected path and 307s it to /login. Verified with a control on a production build: /zz/flags/ -> 307 /login, /rl/flags/ -> 200 from PostHog. This is the failure mode worth remembering because it is silent: asset loads and flags keep working through the rewrite while no events arrive, so the integration looks healthy. Any future change to the proxy prefix must touch next.config.ts, proxy.ts and instrumentation-client.ts together.
[2026-07-27] Analytics runs cookieless (persistence: 'memory') rather than shipping a consent banner. Nothing is written to the device, so no ePrivacy consent is required, and everything post-login stays accurate because AnalyticsIdentify re-identifies on every dashboard load. Accepted cost: anonymous identity does not survive a hard reload, so logged-out funnel stitching (/login -> /register) and cross-reload replay continuity are lost. Escape hatch if that bites is persistence: 'sessionStorage', which is device storage and puts consent back on the table.
[2026-07-27] PostHog surveys write seenSurvey_<id> straight to localStorage with a direct setItem that bypasses the persistence config (verified in the shipped survey bundle). Kept anyway: without it a dismissed survey would re-prompt on every page load under memory persistence. Position recorded deliberately so it is not re-litigated in an audit: a flag whose only purpose is "do not show this person this survey again" is functional UI state, not tracking, in the same category as a dismissed-banner flag. It carries no identity, and resetAnalyticsIdentity() deliberately does NOT clear it (clearing would re-prompt the next person on a shared device).
[2026-07-27] Session replay masks ALL text (maskTextSelector '*'), not just inputs. PostHog masks inputs by default but records on-screen text in the clear, and this app renders org numbers, customer names, balances and invoice amounts as ordinary text; for an enskild firma the organisationsnummer IS the owner's personnummer. Replays therefore show layout, clicks and where a user stalls, never what their books say. buildGroupProperties() additionally refuses to send org_number at all, with a unit test pinning it.
[2026-07-27] PostHog error tracking registers as an adapter on the existing lib/observability sink (lib/analytics/posthog-observability.ts, wired in lib/init.ts) rather than capturing directly. That way every error-level createLogger() line is captured already redacted by lib/observability/redact.ts, which is far broader coverage than instrumentation.ts onRequestError alone (that only sees what escapes uncaught, and is kept as a complement). The sink stays a no-op when analytics is off, so core, CI and self-hosted builds still run with zero third-party runtime code.
[2026-07-27] Analytics is hosted-only and explicitly so: isAnalyticsEnabled() short-circuits on NEXT_PUBLIC_SELF_HOSTED and no __NEXT_PUBLIC_POSTHOG_*__ sentinel was added to Dockerfile/docker-entrypoint.sh. Recapt reached the same outcome only by accident (its env var was simply missing from the sentinel list), which meant a self-hosted operator could never have configured it and nobody had decided that on purpose. An AGPL operator's users should not be reported to our project.
[2026-07-27] Removing Recapt made lib/support/submit-feedback.ts single-channel: a failing /api/support/contact now surfaces as a real error instead of being masked by Recapt reporting success on its own channel. That is the correct behaviour (silently "succeeding" while the message reached nobody was worse) and the Resend path is solid. A non-blocking posthog.capture('support_feedback_submitted') replaces the useful half of the Recapt channel by putting the submission on the user's timeline next to the session replay; it deliberately carries no message body, since free text is user content and would be PII in an event property.
[2026-07-27] vitest.config.ts now aliases 'server-only' to tests/stubs/server-only.ts. Its real entry point throws unconditionally (Next.js swaps it out at bundle time; Vitest cannot), so the moment a server-only module entered the test graph it broke 48 test files at import. app/(dashboard)/request-context.ts was already carrying the same latent trap and had simply never been imported by a test.
-8
View File
@@ -3,7 +3,6 @@ import { headers } from 'next/headers'
import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import AnalyticsIdentify from '@/components/AnalyticsIdentify'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
@@ -324,13 +323,6 @@ export default async function DashboardLayout({
<SettingsHotkey />
{settingsModal}
</div>
{!isSandbox && (
<RecaptIdentify
userId={user.id}
email={user.email}
displayName={settings?.company_name || undefined}
/>
)}
{!isSandbox && (
<AnalyticsIdentify
user={{
+16 -6
View File
@@ -151,14 +151,24 @@ export default function PrivacyPolicyPage() {
<td className="py-2">SCCs (standardavtalsklausuler)</td>
</tr>
<tr className="border-b">
<td className="py-2 pr-4 font-medium">Recapt</td>
<td className="py-2 pr-4 font-medium">PostHog</td>
<td className="py-2 pr-4">
Produktanalys och användarfeedback. Laddas endast för
inloggade användare (ej sandbox/demo). Överförda
uppgifter: användar-ID, e-postadress och företagsnamn.
Produktanalys, sessionsinspelning, felrapportering och
enkäter. Överförda uppgifter: användar-ID,
e-postadress, namn och företagsnamn. All text i
sessionsinspelningar maskeras: vi spelar in var i
gränssnittet du klickar, aldrig vad som står i din
bokföring. Organisationsnummer överförs aldrig.
Identifiering sker endast för inloggade användare (ej
sandbox/demo). Inga kakor eller annan lagring din
enhet används för analysen; enkäter sparar enbart en
lokal markering om att du redan sett dem.
</td>
<td className="py-2 pr-4">EU (Frankfurt)</td>
<td className="py-2">
EU-baserad: ingen tredjelandsöverföring. DPA, SCCs vid
eventuella underbiträden utanför EES.
</td>
<td className="py-2 pr-4">EU</td>
<td className="py-2">SCCs vid eventuella underbiträden utanför EES</td>
</tr>
</tbody>
</table>
-4
View File
@@ -9,8 +9,6 @@ import { Toaster } from "@/components/ui/toaster";
import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt";
import { ThemeProvider } from "@/components/theme-provider";
import { SWRProvider } from "@/components/providers/SWRProvider";
import { RecaptLoader } from "@/components/RecaptLoader";
import { RecaptHideWidget } from "@/components/RecaptHideWidget";
import { ScrollbarReveal } from "@/components/ScrollbarReveal";
import { ensureInitialized } from "@/lib/init";
import { getBranding } from "@/lib/branding/service";
@@ -77,7 +75,6 @@ export default async function RootLayout({
<html lang={locale} suppressHydrationWarning className={`${geistSans.variable} ${geistMono.variable} ${hedvigSerif.variable}`}>
<head>
<link rel="apple-touch-icon" href={branding.appleTouchIconPath} />
<RecaptLoader />
</head>
<body
className="antialiased"
@@ -95,7 +92,6 @@ export default async function RootLayout({
{children}
<Toaster />
<DeployReloadPrompt />
<RecaptHideWidget />
<ScrollbarReveal />
</SWRProvider>
</ThemeProvider>
-38
View File
@@ -1,38 +0,0 @@
'use client'
import { useEffect } from 'react'
/**
* Hides Recapt's floating feedback bubble while keeping the SDK active so
* `window.recapt('identify', ...)` and programmatic `window.recapt('feedback',
* { message })` calls continue to work. Mounted globally in the root layout.
*/
export function RecaptHideWidget() {
useEffect(() => {
let attempts = 0
const maxAttempts = 50
const hide = (): boolean => {
if (typeof window.recapt !== 'function') return false
try {
window.recapt('feedback', { widget: 'hide' })
} catch {
// best-effort
}
return true
}
if (hide()) return
const interval = setInterval(() => {
attempts++
if (hide() || attempts >= maxAttempts) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [])
return null
}
-37
View File
@@ -1,37 +0,0 @@
'use client'
import { useEffect } from 'react'
export function RecaptIdentify({
userId,
email,
displayName,
}: {
userId: string
email?: string
displayName?: string
}) {
useEffect(() => {
let attempts = 0
const maxAttempts = 50
const interval = setInterval(() => {
if (typeof window.recapt === 'function') {
window.recapt('identify', {
uid: userId,
email,
nickname: displayName,
})
clearInterval(interval)
return
}
attempts++
if (attempts >= maxAttempts) {
clearInterval(interval)
}
}, 100)
return () => clearInterval(interval)
}, [userId, email, displayName])
return null
}
-23
View File
@@ -1,23 +0,0 @@
/**
* Loads the Recapt SDK globally.
*
* Renders a plain <script> tag (not next/script) so it lands in <head> and
* runs as early as possible: that's what the SDK needs to capture full
* session replays. The public key is sourced from NEXT_PUBLIC_RECAPT_PUBLIC_KEY
* so hosted and self-hosted deployments can each supply their own key (or
* disable Recapt entirely by leaving it unset).
*/
export function RecaptLoader() {
const publicKey = process.env.NEXT_PUBLIC_RECAPT_PUBLIC_KEY
if (!publicKey) return null
return (
<script
src="https://cdn.recapt.app/browser/glimt.js"
async
data-public-key={publicKey}
data-persist=""
data-enable-user-comments=""
/>
)
}
-2
View File
@@ -49,7 +49,6 @@ import {
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import { clearRecaptIdentity } from '@/lib/recapt'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
@@ -357,7 +356,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
}
const handleLogout = async () => {
clearRecaptIdentity()
resetAnalyticsIdentity()
await supabase.auth.signOut()
router.push(isSandbox ? '/sandbox' : '/login')
@@ -22,7 +22,6 @@ import {
} from '@/components/settings/SettingsRows'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useSettings } from '@/components/settings/useSettings'
import { clearRecaptIdentity } from '@/lib/recapt'
import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import { useToast } from '@/components/ui/use-toast'
import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config'
@@ -92,7 +91,6 @@ export function AccountSettingsContent() {
}
async function handleLogout() {
clearRecaptIdentity()
resetAnalyticsIdentity()
await supabase.auth.signOut()
router.push('/login')
+6
View File
@@ -1,5 +1,11 @@
import posthog from 'posthog-js'
import { isAnalyticsEnabled, warnIfAnalyticsMisconfigured } from '@/lib/analytics/enabled'
import { purgeLegacyAnalyticsStorage } from '@/lib/analytics/purge-legacy-storage'
// Clear anything Recapt left on the device. Runs unconditionally, BEFORE the
// analytics gate: a browser carrying `__recapt_record_engine` must get cleaned
// up even on a build where PostHog itself is switched off.
purgeLegacyAnalyticsStorage()
/**
* Hostnames that get the X-POSTHOG-DISTINCT-ID / X-POSTHOG-SESSION-ID headers,
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { purgeLegacyAnalyticsStorage } from '../purge-legacy-storage'
/** Minimal in-memory Storage stand-in: the suite runs in a node env. */
function makeStorage(initial: Record<string, string> = {}): Storage {
const map = new Map(Object.entries(initial))
return {
get length() {
return map.size
},
key: (i: number) => [...map.keys()][i] ?? null,
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => map.clear(),
} as unknown as Storage
}
function keysOf(s: Storage): string[] {
return Array.from({ length: s.length }, (_, i) => s.key(i)!).sort()
}
describe('purgeLegacyAnalyticsStorage', () => {
afterEach(() => vi.unstubAllGlobals())
it('removes the real key observed in production', () => {
const local = makeStorage({ __recapt_record_engine: 'x' })
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
expect(purgeLegacyAnalyticsStorage()).toBe(1)
expect(keysOf(local)).toEqual([])
})
// The helper this replaces used startsWith('recapt'), which never matched
// `__recapt_record_engine`. Pin the substring behaviour so it cannot regress.
it('matches by substring, not prefix', () => {
const local = makeStorage({
__recapt_record_engine: 'a',
'ph_glimt_session': 'b',
'RECAPT_UPPER': 'c',
})
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
expect(purgeLegacyAnalyticsStorage()).toBe(3)
expect(keysOf(local)).toEqual([])
})
it("leaves the app's own keys alone", () => {
const local = makeStorage({
'Accounted:chat-sidebar-collapsed': '1',
'gnubok.inbox.onboarding.dismissed': '1',
__recapt_record_engine: 'x',
})
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
expect(purgeLegacyAnalyticsStorage()).toBe(1)
expect(keysOf(local)).toEqual([
'Accounted:chat-sidebar-collapsed',
'gnubok.inbox.onboarding.dismissed',
])
})
it('sweeps sessionStorage too', () => {
const session = makeStorage({ glimt_buffer: 'x' })
vi.stubGlobal('window', { localStorage: makeStorage(), sessionStorage: session })
expect(purgeLegacyAnalyticsStorage()).toBe(1)
expect(keysOf(session)).toEqual([])
})
// Backwards iteration matters: removeItem() re-indexes, so a forward loop
// skips the entry after each removal.
it('removes every match even when they are adjacent', () => {
const local = makeStorage({ recapt_a: '1', recapt_b: '2', recapt_c: '3', keep: '4' })
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
expect(purgeLegacyAnalyticsStorage()).toBe(3)
expect(keysOf(local)).toEqual(['keep'])
})
it('is a no-op on a second run', () => {
const local = makeStorage({ __recapt_record_engine: 'x' })
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
purgeLegacyAnalyticsStorage()
expect(purgeLegacyAnalyticsStorage()).toBe(0)
})
it('never throws when storage is unavailable (private mode)', () => {
vi.stubGlobal('window', {
get localStorage(): Storage {
throw new Error('SecurityError')
},
get sessionStorage(): Storage {
throw new Error('SecurityError')
},
})
expect(() => purgeLegacyAnalyticsStorage()).not.toThrow()
})
it('returns 0 on the server', () => {
vi.stubGlobal('window', undefined)
expect(purgeLegacyAnalyticsStorage()).toBe(0)
})
})
+55
View File
@@ -0,0 +1,55 @@
/**
* One-time cleanup of storage left behind by Recapt.
*
* Removing the Recapt <script> stops it writing anything NEW, but every
* browser that has already loaded the app keeps whatever Recapt persisted:
* observed in production as `__recapt_record_engine` in localStorage. Nothing
* would ever remove it, because the helper that used to sweep on logout
* (lib/recapt.ts `clearRecaptIdentity`) is deleted along with the SDK.
*
* That leftover is inert, but it is third-party storage from a processor we
* have told users we no longer use (app/(public)/privacy/page.tsx), and this
* app's whole analytics posture is "nothing on the device". So we clear it.
*
* Matching is by SUBSTRING, not prefix, on purpose. The old sweep tested
* `key.startsWith('recapt')`, which never actually matched the real key:
* `__recapt_record_engine` starts with underscores. The app's own keys
* (`Accounted:chat-sidebar-collapsed`, `gnubok.inbox.onboarding.dismissed`)
* contain neither token, so there is nothing to collide with.
*
* Safe to call on every load: once the keys are gone the loop finds nothing
* and the whole thing costs one localStorage.length read.
*/
const LEGACY_MARKERS = ['recapt', 'glimt']
function purgeFrom(store: Storage): number {
let removed = 0
// Iterate backwards: removeItem() re-indexes the store, so a forward loop
// skips the entry after each removal.
for (let i = store.length - 1; i >= 0; i--) {
const key = store.key(i)
if (!key) continue
const lower = key.toLowerCase()
if (LEGACY_MARKERS.some((m) => lower.includes(m))) {
store.removeItem(key)
removed++
}
}
return removed
}
export function purgeLegacyAnalyticsStorage(): number {
if (typeof window === 'undefined') return 0
let removed = 0
try {
removed += purgeFrom(window.localStorage)
} catch {
// Storage can throw in private mode / when disabled: never break boot.
}
try {
removed += purgeFrom(window.sessionStorage)
} catch {
// Same.
}
return removed
}
-31
View File
@@ -1,31 +0,0 @@
// Recapt's identify SDK keeps the last-known uid in memory and in
// localStorage. Passing `uid: undefined` is not a documented logout
// signal: on some SDK versions it's coerced to the previous value.
// We send an explicit empty-string uid (the SDK's "anonymous" marker),
// then clear any persisted Recapt keys from localStorage so the next
// pageload doesn't re-identify the logged-out user from cache.
export function clearRecaptIdentity(): void {
if (typeof window === 'undefined') return
try {
if (typeof window.recapt === 'function') {
window.recapt('identify', {
uid: '',
email: undefined,
nickname: undefined,
})
}
// Defense-in-depth: wipe any Recapt-namespaced storage on logout so
// a shared device cannot resurrect the previous user's identity on
// the next page load.
if (typeof window.localStorage !== 'undefined') {
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i)
if (key && (key.startsWith('recapt') || key.startsWith('glimt'))) {
window.localStorage.removeItem(key)
}
}
}
} catch {
// best-effort: we're already in a logout flow
}
}
+64 -66
View File
@@ -1,40 +1,40 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { submitFeedback } from '@/lib/support/submit-feedback'
// posthog-js is browser-only and irrelevant to delivery: stub it so the
// analytics breadcrumb can be asserted without initialising the real SDK.
const captureMock = vi.fn()
vi.mock('posthog-js', () => ({ default: { capture: (...a: unknown[]) => captureMock(...a) } }))
describe('submitFeedback', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.restoreAllMocks()
captureMock.mockClear()
// Analytics on by default so the breadcrumb path is exercised.
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test')
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
function stubRecapt(impl: (...args: unknown[]) => void) {
vi.stubGlobal('window', { recapt: impl })
}
function stubNoRecapt() {
vi.stubGlobal('window', {})
}
function stubFetchOk() {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
vi.stubGlobal('fetch', fetchSpy)
return fetchSpy
}
it('sends to both Recapt and email when SDK is present', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
it('delivers over email and reports the email channel', async () => {
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
expect(result.ok).toBe(true)
expect(result.channels.sort()).toEqual(['email', 'recapt'])
expect(recapt).toHaveBeenCalledWith('feedback', { message: '[Hjälpsida]\n\nHjälp tack' })
expect(result.channels).toEqual(['email'])
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
@@ -44,57 +44,10 @@ describe('submitFeedback', () => {
)
})
it('omits subject prefix in Recapt payload when subject not provided', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
stubFetchOk()
await submitFeedback({ message: 'plain' })
expect(recapt).toHaveBeenCalledWith('feedback', { message: 'plain' })
})
it('still reports success via email when Recapt throws', async () => {
stubRecapt(() => {
throw new Error('boom')
})
stubFetchOk()
const result = await submitFeedback({ subject: 'X', message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
})
it('uses email only when Recapt SDK is absent', async () => {
stubNoRecapt()
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
expect(fetchSpy).toHaveBeenCalledOnce()
})
it('reports success when Recapt succeeds even if email fails', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) })
)
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['recapt'])
})
it('returns failure with email error when both channels fail', async () => {
stubRecapt(() => {
throw new Error('boom')
})
// Recapt used to mask a failing email endpoint by reporting success on its
// own channel. Email is now the only delivery path, so its failure must
// surface to the user instead of being swallowed.
it('reports failure when the email endpoint rejects', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
@@ -110,8 +63,7 @@ describe('submitFeedback', () => {
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
})
it('returns failure when fetch itself throws and Recapt is absent', async () => {
stubNoRecapt()
it('reports failure when fetch itself throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
const result = await submitFeedback({ message: 'msg' })
@@ -120,4 +72,50 @@ describe('submitFeedback', () => {
expect(result.channels).toEqual([])
expect(result.error).toBe('Network down')
})
it('records a PostHog breadcrumb WITHOUT the message body', async () => {
stubFetchOk()
await submitFeedback({ subject: 'Hjälpsida', message: 'känslig text om mitt bolag' })
expect(captureMock).toHaveBeenCalledWith('support_feedback_submitted', {
subject: 'Hjälpsida',
delivered: true,
})
// Free text is user content: it must never ride along as an event property.
expect(JSON.stringify(captureMock.mock.calls)).not.toContain('känslig text')
})
it('marks the breadcrumb undelivered when email failed', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }))
await submitFeedback({ message: 'msg' })
expect(captureMock).toHaveBeenCalledWith(
'support_feedback_submitted',
expect.objectContaining({ delivered: false })
)
})
it('skips the breadcrumb entirely when analytics is off (self-hosted)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(captureMock).not.toHaveBeenCalled()
})
it('does not let a throwing analytics SDK break delivery', async () => {
captureMock.mockImplementationOnce(() => {
throw new Error('posthog boom')
})
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
})
})
+30 -22
View File
@@ -1,9 +1,19 @@
import posthog from 'posthog-js'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
export interface SubmitFeedbackInput {
message: string
subject?: string
}
export type SupportChannel = 'recapt' | 'email'
/**
* Delivery channels. Recapt used to be a second one: it accepted the message
* through its feedback SDK, so a failing /api/support/contact still reported
* success. With Recapt gone, email is the only delivery channel and its
* failure is now a real, visible failure. That is correct: silently
* "succeeding" while the message reached nobody was the worse behaviour.
*/
export type SupportChannel = 'email'
export interface SubmitFeedbackResult {
ok: boolean
@@ -11,11 +21,6 @@ export interface SubmitFeedbackResult {
error?: string
}
function composeMessage({ message, subject }: SubmitFeedbackInput): string {
if (!subject) return message
return `[${subject}]\n\n${message}`
}
async function submitViaEmail(
{ message, subject }: SubmitFeedbackInput
): Promise<{ ok: true } | { ok: false; error: string }> {
@@ -35,34 +40,37 @@ async function submitViaEmail(
}
}
function submitViaRecapt(
input: SubmitFeedbackInput
): { ok: true } | { ok: false; error: string } | null {
const recapt = typeof window !== 'undefined' ? window.recapt : undefined
if (typeof recapt !== 'function') return null
/**
* Breadcrumb on the user's PostHog timeline so a support message is visible
* next to the session replay that led to it: the genuinely useful half of what
* the Recapt channel provided. NOT a delivery channel, and deliberately
* carries no message body: free text is user content and would be PII in an
* event property. Email remains the only thing that actually delivers.
*/
function noteInAnalytics({ subject }: SubmitFeedbackInput, delivered: boolean): void {
if (!isAnalyticsEnabled()) return
try {
recapt('feedback', { message: composeMessage(input) })
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Recapt-fel' }
posthog.capture('support_feedback_submitted', {
subject: subject ?? null,
delivered,
})
} catch {
// Telemetry must never affect whether the user's message went out.
}
}
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
const recaptResult = submitViaRecapt(input)
const emailResult = await submitViaEmail(input)
const channels: SupportChannel[] = []
if (recaptResult?.ok) channels.push('recapt')
if (emailResult.ok) channels.push('email')
noteInAnalytics(input, emailResult.ok)
if (channels.length > 0) {
return { ok: true, channels }
if (emailResult.ok) {
return { ok: true, channels: ['email'] }
}
return {
ok: false,
channels: [],
error: emailResult.ok ? undefined : emailResult.error,
error: emailResult.error,
}
}
+7 -7
View File
@@ -29,14 +29,14 @@ const supabaseWsUrl =
const cspDirectives = [
"default-src 'self'",
// Recapt: scoped to the two specific hosts the SDK actually contacts:
// `cdn.recapt.app` for the script bundle and `api.recapt.app` for
// ingestion. The previous wildcard (`https://*.recapt.app`) allowed
// exfiltration to any subdomain of recapt.app and is intentionally
// narrowed.
`connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com https://api.recapt.app https://cdn.recapt.app`,
// No analytics hosts here on purpose. PostHog replaced Recapt and is
// routed through the same-origin `/rl` rewrite below, so ingestion is
// covered by `connect-src 'self'` and its lazy-loaded replay/survey
// bundles by `script-src 'self'`. Adding `*.posthog.com` back would
// re-widen the policy for no benefit and undo the ad-blocker resistance.
`connect-src 'self' ${supabaseUrl} ${supabaseWsUrl} https://*.supabase.co wss://*.supabase.co https://*.enablebanking.com`,
`style-src 'self' 'unsafe-inline' https://*.enablebanking.com`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com https://cdn.recapt.app`,
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://*.enablebanking.com`,
"img-src 'self' data: blob: https:",
"font-src 'self'",
"worker-src 'self' blob:",
-23
View File
@@ -1,23 +0,0 @@
type RecaptFeedbackPayload =
| { message: string; rating?: number }
| { widget: 'show' | 'hide' | 'open' | 'close'; position?: string }
type RecaptIdentifyPayload = {
uid: string | undefined
email?: string
nickname?: string
}
interface RecaptFn {
(action: 'feedback', data: RecaptFeedbackPayload): void
(action: 'identify', data: RecaptIdentifyPayload): void
}
declare global {
interface Window {
Recapt?: unknown
recapt?: RecaptFn
}
}
export {}