From 026f1419ae946f445b9e9b71aab1d278200d2513 Mon Sep 17 00:00:00 2001 From: diogohudson Date: Wed, 11 Mar 2026 08:25:52 -0300 Subject: [PATCH 1/2] feat: add open-source badge with GitHub link to footer Adds a GitHub icon link in the footer indicating the platform is open source, pointing to https://github.com/dhdtech/oos. Includes translations for all 6 supported languages (en, pt, es, zh, hi, ar). Co-Authored-By: Claude Opus 4.6 --- ui/src/components/Layout.tsx | 11 ++++++++++- ui/src/i18n/locales/ar.json | 3 ++- ui/src/i18n/locales/en.json | 3 ++- ui/src/i18n/locales/es.json | 3 ++- ui/src/i18n/locales/hi.json | 3 ++- ui/src/i18n/locales/pt.json | 3 ++- ui/src/i18n/locales/zh.json | 3 ++- ui/src/index.css | 15 +++++++++++++++ 8 files changed, 37 insertions(+), 7 deletions(-) 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; From 62c33169835885f9c928b89deb048beccd1234d1 Mon Sep 17 00:00:00 2001 From: diogohudson Date: Wed, 11 Mar 2026 08:31:03 -0300 Subject: [PATCH 2/2] test: add posthog.ts tests to meet 99% coverage threshold Tests cover init with/without API key, custom/default host, and sanitize_properties stripping URL fragments from known properties and arbitrary /s/ URLs. Co-Authored-By: Claude Opus 4.6 --- ui/src/lib/posthog.test.ts | 133 +++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 ui/src/lib/posthog.test.ts 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"); + }); + }); +});