diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx
index ba1b6ed..f4d8957 100644
--- a/ui/src/components/Layout.tsx
+++ b/ui/src/components/Layout.tsx
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
-import { Shield, Lock, Eye, Trash2 } from "lucide-react";
+import { Shield, Lock, Eye, Trash2, Github } from "lucide-react";
import SecurityModal from "./SecurityModal";
import LanguageSelector from "./LanguageSelector";
@@ -47,6 +47,15 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{t("footer.autoDelete")}
+
+
+ {t("footer.openSource")}
+
);
diff --git a/ui/src/i18n/locales/ar.json b/ui/src/i18n/locales/ar.json
index f8ed7ce..c5d35c9 100644
--- a/ui/src/i18n/locales/ar.json
+++ b/ui/src/i18n/locales/ar.json
@@ -45,7 +45,8 @@
"footer": {
"encryption": "AES-256-GCM",
"zeroKnowledge": "معرفة صفرية",
- "autoDelete": "حذف تلقائي"
+ "autoDelete": "حذف تلقائي",
+ "openSource": "مفتوح المصدر"
},
"security": {
"title": "كيف يعمل",
diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json
index 9eee21d..c701458 100644
--- a/ui/src/i18n/locales/en.json
+++ b/ui/src/i18n/locales/en.json
@@ -45,7 +45,8 @@
"footer": {
"encryption": "AES-256-GCM",
"zeroKnowledge": "Zero Knowledge",
- "autoDelete": "Auto-Delete"
+ "autoDelete": "Auto-Delete",
+ "openSource": "Open Source"
},
"security": {
"title": "How It Works",
diff --git a/ui/src/i18n/locales/es.json b/ui/src/i18n/locales/es.json
index a003240..8d7333e 100644
--- a/ui/src/i18n/locales/es.json
+++ b/ui/src/i18n/locales/es.json
@@ -45,7 +45,8 @@
"footer": {
"encryption": "AES-256-GCM",
"zeroKnowledge": "Conocimiento cero",
- "autoDelete": "Auto-eliminación"
+ "autoDelete": "Auto-eliminación",
+ "openSource": "Código Abierto"
},
"security": {
"title": "Cómo funciona",
diff --git a/ui/src/i18n/locales/hi.json b/ui/src/i18n/locales/hi.json
index 1cb06b7..bb688ff 100644
--- a/ui/src/i18n/locales/hi.json
+++ b/ui/src/i18n/locales/hi.json
@@ -45,7 +45,8 @@
"footer": {
"encryption": "AES-256-GCM",
"zeroKnowledge": "शून्य ज्ञान",
- "autoDelete": "स्वतः-हटाएं"
+ "autoDelete": "स्वतः-हटाएं",
+ "openSource": "ओपन सोर्स"
},
"security": {
"title": "यह कैसे काम करता है",
diff --git a/ui/src/i18n/locales/pt.json b/ui/src/i18n/locales/pt.json
index 6dbcb27..89a7b79 100644
--- a/ui/src/i18n/locales/pt.json
+++ b/ui/src/i18n/locales/pt.json
@@ -45,7 +45,8 @@
"footer": {
"encryption": "AES-256-GCM",
"zeroKnowledge": "Conhecimento zero",
- "autoDelete": "Auto-exclusão"
+ "autoDelete": "Auto-exclusão",
+ "openSource": "Código Aberto"
},
"security": {
"title": "Como funciona",
diff --git a/ui/src/i18n/locales/zh.json b/ui/src/i18n/locales/zh.json
index 8ff82f8..ef793de 100644
--- a/ui/src/i18n/locales/zh.json
+++ b/ui/src/i18n/locales/zh.json
@@ -45,7 +45,8 @@
"footer": {
"encryption": "AES-256-GCM",
"zeroKnowledge": "零知识",
- "autoDelete": "自动删除"
+ "autoDelete": "自动删除",
+ "openSource": "开源项目"
},
"security": {
"title": "工作原理",
diff --git a/ui/src/index.css b/ui/src/index.css
index f6cb325..8d5d826 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -141,6 +141,21 @@ body {
flex-shrink: 0;
}
+.footer-opensource {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ margin-top: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ text-decoration: none;
+ transition: color var(--transition);
+}
+
+.footer-opensource:hover {
+ color: var(--text-secondary);
+}
+
/* ─── Language Selector ─── */
.lang-selector {
position: relative;
diff --git a/ui/src/lib/posthog.test.ts b/ui/src/lib/posthog.test.ts
new file mode 100644
index 0000000..55bf50c
--- /dev/null
+++ b/ui/src/lib/posthog.test.ts
@@ -0,0 +1,133 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// Mock posthog-js before importing our module
+const mockInit = vi.fn();
+vi.mock("posthog-js", () => ({
+ default: {
+ init: mockInit,
+ capture: vi.fn(),
+ },
+}));
+
+describe("posthog", () => {
+ beforeEach(() => {
+ mockInit.mockReset();
+ vi.unstubAllEnvs();
+ });
+
+ it("does not call posthog.init when VITE_POSTHOG_KEY is not set", async () => {
+ vi.stubEnv("VITE_POSTHOG_KEY", "");
+ // Re-import to re-evaluate module
+ vi.resetModules();
+ vi.mock("posthog-js", () => ({
+ default: { init: mockInit, capture: vi.fn() },
+ }));
+ await import("./posthog");
+ expect(mockInit).not.toHaveBeenCalled();
+ });
+
+ it("calls posthog.init when VITE_POSTHOG_KEY is set", async () => {
+ vi.stubEnv("VITE_POSTHOG_KEY", "phc_test123");
+ vi.stubEnv("VITE_POSTHOG_HOST", "https://custom.posthog.com");
+ vi.resetModules();
+ vi.mock("posthog-js", () => ({
+ default: { init: mockInit, capture: vi.fn() },
+ }));
+ await import("./posthog");
+ expect(mockInit).toHaveBeenCalledWith("phc_test123", expect.objectContaining({
+ api_host: "https://custom.posthog.com",
+ capture_pageview: true,
+ capture_pageleave: true,
+ autocapture: true,
+ }));
+ });
+
+ it("uses default host when VITE_POSTHOG_HOST is not set", async () => {
+ vi.stubEnv("VITE_POSTHOG_KEY", "phc_test123");
+ vi.stubEnv("VITE_POSTHOG_HOST", "");
+ vi.resetModules();
+ vi.mock("posthog-js", () => ({
+ default: { init: mockInit, capture: vi.fn() },
+ }));
+ await import("./posthog");
+ expect(mockInit).toHaveBeenCalledWith("phc_test123", expect.objectContaining({
+ api_host: "https://us.i.posthog.com",
+ }));
+ });
+
+ describe("sanitize_properties", () => {
+ function getSanitizer(): (props: Record, event: string) => Record {
+ const call = mockInit.mock.calls[0];
+ return call[1].sanitize_properties;
+ }
+
+ async function initWithKey() {
+ vi.stubEnv("VITE_POSTHOG_KEY", "phc_test");
+ vi.resetModules();
+ vi.mock("posthog-js", () => ({
+ default: { init: mockInit, capture: vi.fn() },
+ }));
+ await import("./posthog");
+ }
+
+ it("strips URL fragments from known URL properties", async () => {
+ await initWithKey();
+ const sanitize = getSanitizer();
+ const props = {
+ "$current_url": "https://example.com/s/abc#secretkey",
+ "$pathname": "/s/abc#secretkey",
+ "$referrer": "https://example.com/page#frag",
+ other: "untouched",
+ };
+ const result = sanitize(props, "$pageview");
+ expect(result["$current_url"]).toBe("https://example.com/s/abc");
+ expect(result["$pathname"]).toBe("/s/abc");
+ expect(result["$referrer"]).toBe("https://example.com/page");
+ expect(result.other).toBe("untouched");
+ });
+
+ it("strips fragments from arbitrary properties containing /s/ URLs", async () => {
+ await initWithKey();
+ const sanitize = getSanitizer();
+ const props = {
+ custom_field: "https://example.com/s/abc#key123",
+ };
+ const result = sanitize(props, "$autocapture");
+ expect(result.custom_field).toBe("https://example.com/s/abc");
+ });
+
+ it("does not strip fragments from non-/s/ URLs in arbitrary properties", async () => {
+ await initWithKey();
+ const sanitize = getSanitizer();
+ const props = {
+ custom_field: "https://example.com/page#section",
+ };
+ const result = sanitize(props, "$autocapture");
+ expect(result.custom_field).toBe("https://example.com/page#section");
+ });
+
+ it("handles non-string values without error", async () => {
+ await initWithKey();
+ const sanitize = getSanitizer();
+ const props = {
+ "$current_url": 12345,
+ numeric: 42,
+ bool: true,
+ obj: { nested: "value" },
+ };
+ const result = sanitize(props, "$pageview");
+ expect(result["$current_url"]).toBe(12345);
+ expect(result.numeric).toBe(42);
+ });
+
+ it("handles URL properties without fragments", async () => {
+ await initWithKey();
+ const sanitize = getSanitizer();
+ const props = {
+ "$current_url": "https://example.com/s/abc",
+ };
+ const result = sanitize(props, "$pageview");
+ expect(result["$current_url"]).toBe("https://example.com/s/abc");
+ });
+ });
+});