Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import AnalyticsSettings from "@/components/AnalyticsSettings";

export const metadata: Metadata = {
title: "Privacy Policy — Reframe",
Expand Down Expand Up @@ -135,13 +136,29 @@ export default function PrivacyPage() {
Session replay is explicitly disabled — Datadog never
records or reconstructs what the editor looked like during
your session. Telemetry is limited to metrics and error
events; it does not and cannot include your video or audio
files, filenames, export settings, or any content you
create or edit in the app, since that data never leaves
your browser in the first place (see Data Collection
above).
events. It never includes your video or audio files, file
names, captions, folder paths or anything you create in the
app. Export events carry only coarse details: the output
format, a resolution range (such as 1080p), size and length
ranges, whether text, music or an image overlay was added,
and whether the
export succeeded.
</p>

<h2
id="settings"
className="text-2xl font-semibold mt-12 mb-6 text-[var(--text)] scroll-mt-24"
>
Settings
</h2>
<p className="text-[var(--muted)] mb-4">
Turn anonymous analytics off here. When it&apos;s off, the
Datadog script isn&apos;t loaded at all. Reframe also leaves
it off if your browser sends Do Not Track or Global Privacy
Control.
</p>
<AnalyticsSettings />

<h2 className="text-2xl font-semibold mt-12 mb-6 text-[var(--text)]">
4. Third-Party Dependencies
</h2>
Expand Down
88 changes: 88 additions & 0 deletions src/components/AnalyticsSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use client";

import { useEffect, useId, useState } from "react";
import { cn } from "@/lib/utils";
import {
analyticsAllowed,
browserDeclinesTracking,
onAnalyticsChoiceChange,
setAnalyticsChoice,
} from "@/lib/analyticsConsent";

/** The "Anonymous analytics" switch. Saved in this browser only. */
export default function AnalyticsSettings() {
const labelId = useId();
const descriptionId = useId();
// Unknown until mounted: the page is prerendered without a browser.
const [enabled, setEnabled] = useState<boolean | null>(null);
const [declined, setDeclined] = useState(false);
const [notice, setNotice] = useState<string | null>(null);

useEffect(() => {
const read = () => {
setDeclined(browserDeclinesTracking());
setEnabled(analyticsAllowed());
};
read();
return onAnalyticsChoiceChange(read);
}, []);

const toggle = () => {
if (enabled === null || declined) return;
const next = !enabled;
const saved = setAnalyticsChoice(next);
setEnabled(next);
setNotice(
!saved
? "Your browser blocked saving this choice, so it applies to this page only."
: next
? "Anonymous analytics is on. It starts on your next visit."
: "Anonymous analytics is off. Nothing more is sent from this browser."
);
};

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<p id={labelId} className="font-semibold text-[var(--text)]">
Anonymous analytics
</p>
<p id={descriptionId} className="mt-1 text-sm text-[var(--muted)]">
{declined
? "Off, because your browser sends Do Not Track or Global Privacy Control. Reframe respects that."
: "Export success or failure, load times and errors, with no file names, captions or video content. Saved in this browser."}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled === true}
aria-labelledby={labelId}
aria-describedby={descriptionId}
disabled={enabled === null || declined}
onClick={toggle}
className={cn(
"relative inline-flex h-7 w-12 shrink-0 items-center rounded-full border transition-colors",
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent)]",
"disabled:cursor-not-allowed disabled:opacity-50",
enabled ? "border-[var(--accent)] bg-[var(--accent)]" : "border-[var(--border)] bg-[var(--bg)]"
)}
>
<span
aria-hidden="true"
className={cn(
"inline-block h-5 w-5 rounded-full bg-white shadow transition-transform",
enabled ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
</div>
{notice && (
<p role="status" className="mt-3 text-sm text-[var(--text)]">
{notice}
</p>
)}
</div>
);
}
10 changes: 9 additions & 1 deletion src/components/DatadogInit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useEffect } from "react";
import { capabilityContext, currentTelemetryEnv, registerRum, sanitizeRumEvent } from "@/lib/telemetry";
import { analyticsAllowed } from "@/lib/analyticsConsent";

export default function DatadogInit() {
useEffect(() => {
Expand All @@ -11,7 +12,9 @@ export default function DatadogInit() {
// null on localhost, LAN addresses and automated browsers unless
// NEXT_PUBLIC_DATADOG_ENV names a non-production env (see telemetry.ts).
const env = currentTelemetryEnv();
if (!applicationId || !clientToken || !env) {
// Off in Settings, or the browser sends Do Not Track / Global Privacy
// Control: don't load the SDK at all.
if (!applicationId || !clientToken || !env || !analyticsAllowed()) {
return;
}

Expand All @@ -36,6 +39,11 @@ export default function DatadogInit() {
// session replay would visually record the editor UI, which cuts
// against that claim. Metrics/errors only.
sessionReplaySampleRate: 0,
// Replay is off; if it were ever enabled, text and inputs stay masked.
defaultPrivacyLevel: "mask",
// No persistent anonymous user ID (usr.anonymous_id): sessions
// can't be linked across visits.
trackAnonymousUser: false,
plugins: [reactPlugin()],
trackUserInteractions: true,
// Auto-named click actions use the clicked element's text, which can
Expand Down
29 changes: 27 additions & 2 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Lock,
Mail,
HeartHandshake,
Settings,
} from "lucide-react";

export default function Footer() {
Expand Down Expand Up @@ -87,11 +88,20 @@ export default function Footer() {
Privacy Policy
</Link>

<Link
href="/privacy#settings"
aria-label="Settings: anonymous analytics"
className="flex items-center gap-2 text-[var(--text-secondary)] hover:text-[var(--text)] transition-colors w-fit rounded"
>
<Settings size={14} />
Settings
</Link>

{/* Netlify's open source plan expects a publicly reachable Code of
Conduct. Points at the repo file rather than an in-site route,
since CODE_OF_CONDUCT.md is the canonical copy. */}
<a
href="https://github.com/magic-peach/reframe/blob/main/CODE_OF_CONDUCT.md"
href="https://github.com/reframe-oss/reframe/blob/main/CODE_OF_CONDUCT.md"
target="_blank"
rel="noopener noreferrer"
aria-label="Code of Conduct"
Expand Down Expand Up @@ -181,7 +191,22 @@ export default function Footer() {

{/* Bottom Bar */}
<div className="max-w-[1440px] mx-auto mt-16 pt-8 border-t border-[var(--border)] flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-[var(--muted)]">
<p>© {new Date().getFullYear()} Reframe · MIT License</p>
<nav aria-label="Credits and license" className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
<span>© {new Date().getFullYear()} Reframe</span>
<a href="https://github.com/reframe-oss/reframe/blob/main/LICENSE" target="_blank" rel="noopener noreferrer" className="text-[var(--text-secondary)] hover:text-[var(--text)] underline-offset-4 hover:underline transition-colors rounded">
MIT License
</a>
<a href="https://github.com/reframe-oss/reframe" target="_blank" rel="noopener noreferrer" className="text-[var(--text-secondary)] hover:text-[var(--text)] underline-offset-4 hover:underline transition-colors rounded">
GitHub
</a>
{/* Netlify's open source plan asks for this wording and link. */}
<a href="https://www.netlify.com" target="_blank" rel="noopener noreferrer" className="text-[var(--text-secondary)] hover:text-[var(--text)] underline-offset-4 hover:underline transition-colors rounded">
This site is powered by Netlify
</a>
<a href="https://snyk.io" target="_blank" rel="noopener noreferrer" className="text-[var(--text-secondary)] hover:text-[var(--text)] underline-offset-4 hover:underline transition-colors rounded">
Security by Snyk
</a>
</nav>

<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
Expand Down
37 changes: 37 additions & 0 deletions src/components/__tests__/AnalyticsSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import AnalyticsSettings from "../AnalyticsSettings";
import { ANALYTICS_STORAGE_KEY } from "@/lib/analyticsConsent";

const setDnt = (value: string | null) =>
Object.defineProperty(navigator, "doNotTrack", { configurable: true, get: () => value });

beforeEach(() => {
localStorage.clear();
setDnt(null);
});

describe("AnalyticsSettings", () => {
it("is an accessible switch that saves the choice in this browser", async () => {
render(<AnalyticsSettings />);
const toggle = await screen.findByRole("switch", { name: "Anonymous analytics" });
expect(toggle).toHaveAttribute("aria-checked", "true");

fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-checked", "false");
expect(localStorage.getItem(ANALYTICS_STORAGE_KEY)).toBe("off");
expect(screen.getByRole("status")).toHaveTextContent(/off/i);

fireEvent.click(toggle);
expect(localStorage.getItem(ANALYTICS_STORAGE_KEY)).toBe("on");
});

it("shows off and can't be turned on when the browser sends Do Not Track", async () => {
setDnt("1");
render(<AnalyticsSettings />);
const toggle = await screen.findByRole("switch", { name: "Anonymous analytics" });
expect(toggle).toHaveAttribute("aria-checked", "false");
expect(toggle).toBeDisabled();
expect(screen.getByText(/Do Not Track/)).toBeInTheDocument();
});
});
51 changes: 51 additions & 0 deletions src/components/__tests__/DatadogInit.consent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// @vitest-environment-options {"url": "https://reframe-os.netlify.app/"}
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { rumInit, renderDatadogInit } from "./datadogInitHarness";
import { setAnalyticsChoice } from "@/lib/analyticsConsent";

// A production build on the production host: RUM would start, unless the
// viewer or their browser says no.
beforeEach(() => {
localStorage.clear();
Object.defineProperty(navigator, "doNotTrack", { configurable: true, get: () => null });
Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, get: () => undefined });
vi.stubEnv("NEXT_PUBLIC_DATADOG_APPLICATION_ID", "app");
vi.stubEnv("NEXT_PUBLIC_DATADOG_CLIENT_TOKEN", "pub0000");
vi.stubEnv("NEXT_PUBLIC_DATADOG_ENV", "");
vi.stubEnv("NEXT_PUBLIC_VERCEL_ENV", "");
vi.stubEnv("NEXT_PUBLIC_DEPLOY_CONTEXT", "production");
});
afterEach(() => vi.unstubAllEnvs());

describe("DatadogInit and the analytics choice", () => {
it("starts with session replay off and privacy level mask when allowed", async () => {
await renderDatadogInit();
expect(rumInit).toHaveBeenCalledTimes(1);
expect(rumInit.mock.calls[0]![0]).toMatchObject({
sessionReplaySampleRate: 0,
defaultPrivacyLevel: "mask",
enablePrivacyForActionName: true,
trackAnonymousUser: false,
});
});

it("never initialises, or contacts Datadog, when turned off in Settings", async () => {
setAnalyticsChoice(false);
const requests = await renderDatadogInit();
expect(rumInit).not.toHaveBeenCalled();
expect(requests).toEqual([]);
});

it("never initialises when the browser sends Do Not Track", async () => {
Object.defineProperty(navigator, "doNotTrack", { configurable: true, get: () => "1" });
const requests = await renderDatadogInit();
expect(rumInit).not.toHaveBeenCalled();
expect(requests).toEqual([]);
});

it("never initialises under Global Privacy Control", async () => {
Object.defineProperty(navigator, "globalPrivacyControl", { configurable: true, get: () => true });
await renderDatadogInit();
expect(rumInit).not.toHaveBeenCalled();
});
});
68 changes: 68 additions & 0 deletions src/lib/analyticsConsent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Whether this browser may send anonymous telemetry (Datadog RUM).
//
// - The viewer's own choice, from Settings (the privacy page), is stored in
// localStorage under ANALYTICS_STORAGE_KEY.
// - Do Not Track and Global Privacy Control turn it off, and the toggle can't
// turn it back on: the browser-wide signal wins.
// - With no choice and no signal, it is on (anonymous, see docs/DATADOG.md).
//
// When it is off RUM never initialises; if the viewer turns it off on a page
// where RUM already started, every further event is dropped in beforeSend.

export const ANALYTICS_STORAGE_KEY = "reframe_analytics";
const CHANGE_EVENT = "reframe:analytics-change";

type Nav = Navigator & { globalPrivacyControl?: boolean; msDoNotTrack?: string };

/** True when the browser asks not to be tracked (Do Not Track or GPC). */
export function browserDeclinesTracking(): boolean {
if (typeof navigator === "undefined") return false;
const nav = navigator as Nav;
const win = typeof window !== "undefined" ? (window as Window & { doNotTrack?: string }) : undefined;
const dnt = nav.doNotTrack ?? win?.doNotTrack ?? nav.msDoNotTrack;
return dnt === "1" || dnt === "yes" || nav.globalPrivacyControl === true;
}

/** The viewer's stored choice, or null if they haven't made one. */
export function storedAnalyticsChoice(): boolean | null {
try {
const value = localStorage.getItem(ANALYTICS_STORAGE_KEY);
if (value === "on") return true;
if (value === "off") return false;
} catch {
// storage blocked: no stored choice
}
return null;
}

/** Whether telemetry may be sent from this browser right now. */
export function analyticsAllowed(): boolean {
if (typeof window === "undefined") return false;
if (browserDeclinesTracking()) return false;
return storedAnalyticsChoice() ?? true;
}

/** Saves the viewer's choice. Returns false if it couldn't be stored. */
export function setAnalyticsChoice(enabled: boolean): boolean {
let saved = true;
try {
localStorage.setItem(ANALYTICS_STORAGE_KEY, enabled ? "on" : "off");
} catch {
saved = false;
}
if (typeof window !== "undefined") window.dispatchEvent(new Event(CHANGE_EVENT));
return saved;
}

/** Calls `listener` when the choice changes in this tab or another one. */
export function onAnalyticsChoiceChange(listener: () => void): () => void {
const onStorage = (e: StorageEvent) => {
if (e.key === ANALYTICS_STORAGE_KEY) listener();
};
window.addEventListener(CHANGE_EVENT, listener);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(CHANGE_EVENT, listener);
window.removeEventListener("storage", onStorage);
};
}
Loading
Loading