diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml index ba83ff8c..7beff8f9 100644 --- a/.compliance/ropa.yaml +++ b/.compliance/ropa.yaml @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index 8bc15a47..fc56bf41 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -590,3 +590,12 @@ One line per decision: `[YYYY-MM-DD] : `. 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_ 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. diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 05b96028..f2f1fc29 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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({ {settingsModal} - {!isSandbox && ( - - )} {!isSandbox && ( SCCs (standardavtalsklausuler) - Recapt + PostHog - 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 på din + enhet används för analysen; enkäter sparar enbart en + lokal markering om att du redan sett dem. + + EU (Frankfurt) + + EU-baserad: ingen tredjelandsöverföring. DPA, SCCs vid + eventuella underbiträden utanför EES. - EU - SCCs vid eventuella underbiträden utanför EES diff --git a/app/layout.tsx b/app/layout.tsx index 8d491376..7b192695 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ - - diff --git a/components/RecaptHideWidget.tsx b/components/RecaptHideWidget.tsx deleted file mode 100644 index 9cc17c87..00000000 --- a/components/RecaptHideWidget.tsx +++ /dev/null @@ -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 -} diff --git a/components/RecaptIdentify.tsx b/components/RecaptIdentify.tsx deleted file mode 100644 index 90d98103..00000000 --- a/components/RecaptIdentify.tsx +++ /dev/null @@ -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 -} diff --git a/components/RecaptLoader.tsx b/components/RecaptLoader.tsx deleted file mode 100644 index acc52a09..00000000 --- a/components/RecaptLoader.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Loads the Recapt SDK globally. - * - * Renders a plain