hosted: add posthog opt out + session replay
This commit is contained in:
parent
3e39c65265
commit
2130dccc81
1
drizzle/0010_high_liz_osborn.sql
Normal file
1
drizzle/0010_high_liz_osborn.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `analytics_opted_out` integer;
|
||||
2038
drizzle/meta/0010_snapshot.json
Normal file
2038
drizzle/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -71,6 +71,13 @@
|
||||
"when": 1776347182067,
|
||||
"tag": "0009_smart_kitty_pryde",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1777422740449,
|
||||
"tag": "0010_high_liz_osborn",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
CircleHelp,
|
||||
CreditCard,
|
||||
Menu,
|
||||
Settings,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@ -13,7 +14,6 @@ import {
|
||||
MissingSeoSetupModal,
|
||||
SeoApiStatusBanners,
|
||||
} from "@/client/layout/AppShellParts";
|
||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||
import { getProjectNavGroups } from "@/client/navigation/items";
|
||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
@ -322,7 +322,9 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
||||
>
|
||||
{email ? (
|
||||
<li className="menu-title max-w-full">
|
||||
<span className="truncate text-base-content">{email}</span>
|
||||
<span className="truncate text-base-content" data-ph-mask>
|
||||
{email}
|
||||
</span>
|
||||
</li>
|
||||
) : null}
|
||||
{mobileOnly ? (
|
||||
@ -341,6 +343,12 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
||||
</a>
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
<Link to="/settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</li>
|
||||
{isHostedMode && email ? (
|
||||
<li>
|
||||
<button
|
||||
@ -352,7 +360,6 @@ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
<ThemePreferenceMenuItems />
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,6 +7,7 @@ type BrowserPostHogClient = typeof import("posthog-js").default;
|
||||
let browserPostHogClientPromise: Promise<BrowserPostHogClient | null> | null =
|
||||
null;
|
||||
let browserPostHogInitialized = false;
|
||||
let analyticsCaptureEnabled = true;
|
||||
|
||||
function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
||||
if (typeof window === "undefined" || !isHostedClientAuthMode()) {
|
||||
@ -34,6 +35,11 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
||||
defaults: "2026-01-30",
|
||||
capture_exceptions: true,
|
||||
capture_pageview: "history_change",
|
||||
respect_dnt: true,
|
||||
session_recording: {
|
||||
maskAllInputs: true,
|
||||
maskTextSelector: "[data-ph-mask], .ph-mask",
|
||||
},
|
||||
sanitize_properties(properties, event) {
|
||||
if (event === "$pageview" || event === "$pageleave") {
|
||||
const url: unknown = properties["$current_url"];
|
||||
@ -63,10 +69,6 @@ function getBrowserPostHogClient(): Promise<BrowserPostHogClient | null> {
|
||||
return browserPostHogClientPromise;
|
||||
}
|
||||
|
||||
export function initPostHog() {
|
||||
void getBrowserPostHogClient();
|
||||
}
|
||||
|
||||
function withPostHogClient(fn: (client: BrowserPostHogClient) => void) {
|
||||
void getBrowserPostHogClient().then((client) => {
|
||||
if (!client) return;
|
||||
@ -78,10 +80,23 @@ function withPostHogClient(fn: (client: BrowserPostHogClient) => void) {
|
||||
});
|
||||
}
|
||||
|
||||
function withExistingPostHogClient(fn: (client: BrowserPostHogClient) => void) {
|
||||
if (!browserPostHogClientPromise) return;
|
||||
void browserPostHogClientPromise.then((client) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
fn(client);
|
||||
} catch (e) {
|
||||
console.error("posthog operation failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function captureClientEvent(
|
||||
event: string,
|
||||
properties?: Record<string, unknown>,
|
||||
) {
|
||||
if (!analyticsCaptureEnabled) return;
|
||||
withPostHogClient((client) => client.capture(event, properties));
|
||||
}
|
||||
|
||||
@ -89,6 +104,7 @@ export function identifyAnalyticsUser(args: {
|
||||
userId: string;
|
||||
organizationId: string | null;
|
||||
}) {
|
||||
if (!analyticsCaptureEnabled) return;
|
||||
withPostHogClient((client) => {
|
||||
client.identify(args.userId);
|
||||
if (args.organizationId) {
|
||||
@ -98,13 +114,39 @@ export function identifyAnalyticsUser(args: {
|
||||
}
|
||||
|
||||
export function resetAnalyticsUser() {
|
||||
withPostHogClient((client) => client.reset());
|
||||
withExistingPostHogClient((client) => {
|
||||
client.stopSessionRecording();
|
||||
client.reset();
|
||||
});
|
||||
}
|
||||
|
||||
export function stopAnalyticsCapture() {
|
||||
analyticsCaptureEnabled = false;
|
||||
if (!browserPostHogInitialized || !browserPostHogClientPromise) return;
|
||||
void browserPostHogClientPromise.then((client) => {
|
||||
if (!client) return;
|
||||
try {
|
||||
client.stopSessionRecording();
|
||||
client.opt_out_capturing();
|
||||
} catch (e) {
|
||||
console.error("posthog opt-out failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function startAnalyticsCapture() {
|
||||
analyticsCaptureEnabled = true;
|
||||
withPostHogClient((client) => {
|
||||
client.opt_in_capturing();
|
||||
client.startSessionRecording();
|
||||
});
|
||||
}
|
||||
|
||||
export function captureClientError(
|
||||
error: unknown,
|
||||
properties: Record<string, string | null | undefined> = {},
|
||||
) {
|
||||
if (!analyticsCaptureEnabled) return;
|
||||
withPostHogClient((client) =>
|
||||
client.captureException(error, {
|
||||
source: "client",
|
||||
|
||||
@ -22,6 +22,7 @@ export const user = sqliteTable("user", {
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
.notNull(),
|
||||
analyticsOptedOut: integer("analytics_opted_out", { mode: "boolean" }),
|
||||
});
|
||||
|
||||
export const session = sqliteTable(
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { organizationClient } from "better-auth/client/plugins";
|
||||
import {
|
||||
inferAdditionalFields,
|
||||
organizationClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
||||
import { userAdditionalFields } from "@/lib/auth-options";
|
||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||
plugins: [organizationClient()],
|
||||
plugins: [
|
||||
organizationClient(),
|
||||
inferAdditionalFields({ user: userAdditionalFields }),
|
||||
],
|
||||
});
|
||||
|
||||
export const { useSession } = authClient;
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
export const HOSTED_PASSWORD_MIN_LENGTH = 8;
|
||||
export const HOSTED_PASSWORD_MAX_LENGTH = 128;
|
||||
|
||||
export const userAdditionalFields = {
|
||||
analyticsOptedOut: {
|
||||
type: "boolean" as const,
|
||||
defaultValue: () => false,
|
||||
required: false as const,
|
||||
input: true as const,
|
||||
},
|
||||
};
|
||||
|
||||
export const baseAuthOptions = {
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
@ -8,4 +17,7 @@ export const baseAuthOptions = {
|
||||
minPasswordLength: HOSTED_PASSWORD_MIN_LENGTH,
|
||||
maxPasswordLength: HOSTED_PASSWORD_MAX_LENGTH,
|
||||
},
|
||||
user: {
|
||||
additionalFields: userAdditionalFields,
|
||||
},
|
||||
};
|
||||
|
||||
@ -94,6 +94,8 @@ function getTrustedOrigins(baseUrl: string) {
|
||||
trustedOrigins.push(
|
||||
"http://open-seo.localhost:1355",
|
||||
"http://*.open-seo.localhost:1355",
|
||||
"https://open-seo.localhost:1355",
|
||||
"https://*.open-seo.localhost:1355",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authentica
|
||||
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
@ -97,6 +98,11 @@ const AppSupportRoute = AppSupportRouteImport.update({
|
||||
path: '/support',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const AppSettingsRoute = AppSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const AppBillingRoute = AppBillingRouteImport.update({
|
||||
id: '/billing',
|
||||
path: '/billing',
|
||||
@ -208,6 +214,7 @@ export interface FileRoutesByFullPath {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/billing': typeof AppBillingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/support': typeof AppSupportRoute
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
@ -237,6 +244,7 @@ export interface FileRoutesByTo {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/billing': typeof AppBillingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/support': typeof AppSupportRoute
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
@ -267,6 +275,7 @@ export interface FileRoutesById {
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/_app/billing': typeof AppBillingRoute
|
||||
'/_app/settings': typeof AppSettingsRoute
|
||||
'/_app/support': typeof AppSupportRoute
|
||||
'/_auth/sign-in': typeof AuthSignInRoute
|
||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||
@ -299,6 +308,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/billing'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
@ -328,6 +338,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/billing'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
@ -357,6 +368,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/_app/billing'
|
||||
| '/_app/settings'
|
||||
| '/_app/support'
|
||||
| '/_auth/sign-in'
|
||||
| '/_auth/sign-up'
|
||||
@ -480,6 +492,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppSupportRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/_app/settings': {
|
||||
id: '/_app/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof AppSettingsRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/_app/billing': {
|
||||
id: '/_app/billing'
|
||||
path: '/billing'
|
||||
@ -618,6 +637,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
interface AppRouteRouteChildren {
|
||||
AppBillingRoute: typeof AppBillingRoute
|
||||
AppSettingsRoute: typeof AppSettingsRoute
|
||||
AppSupportRoute: typeof AppSupportRoute
|
||||
AppIndexRoute: typeof AppIndexRoute
|
||||
AppHelpDataforseoApiKeyRoute: typeof AppHelpDataforseoApiKeyRoute
|
||||
@ -625,6 +645,7 @@ interface AppRouteRouteChildren {
|
||||
|
||||
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
||||
AppBillingRoute: AppBillingRoute,
|
||||
AppSettingsRoute: AppSettingsRoute,
|
||||
AppSupportRoute: AppSupportRoute,
|
||||
AppIndexRoute: AppIndexRoute,
|
||||
AppHelpDataforseoApiKeyRoute: AppHelpDataforseoApiKeyRoute,
|
||||
|
||||
@ -14,8 +14,9 @@ import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
|
||||
import { themePreferenceInitScript } from "@/client/lib/theme";
|
||||
import {
|
||||
identifyAnalyticsUser,
|
||||
initPostHog,
|
||||
resetAnalyticsUser,
|
||||
startAnalyticsCapture,
|
||||
stopAnalyticsCapture,
|
||||
} from "@/client/lib/posthog";
|
||||
import { NotFound } from "@/client/components/NotFound";
|
||||
import appCss from "@/client/styles/app.css?url";
|
||||
@ -85,6 +86,7 @@ function PostHogBootstrap() {
|
||||
const isHostedMode = isHostedClientAuthMode();
|
||||
const { data: session, isPending: isSessionPending } = useSession();
|
||||
const userId = session?.user?.id ?? null;
|
||||
const optedOut = session?.user?.analyticsOptedOut === true;
|
||||
const organizationId = getActiveOrganizationId(session);
|
||||
const previousUserIdRef = React.useRef<string | null>(null);
|
||||
|
||||
@ -93,16 +95,17 @@ function PostHogBootstrap() {
|
||||
return;
|
||||
}
|
||||
|
||||
initPostHog();
|
||||
|
||||
if (userId) {
|
||||
if (userId && !optedOut) {
|
||||
startAnalyticsCapture();
|
||||
identifyAnalyticsUser({ userId, organizationId });
|
||||
previousUserIdRef.current = userId;
|
||||
} else if (userId && optedOut) {
|
||||
stopAnalyticsCapture();
|
||||
} else if (previousUserIdRef.current) {
|
||||
previousUserIdRef.current = null;
|
||||
resetAnalyticsUser();
|
||||
}
|
||||
}, [isHostedMode, isSessionPending, organizationId, userId]);
|
||||
}, [isHostedMode, isSessionPending, optedOut, organizationId, userId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
119
src/routes/_app/settings.tsx
Normal file
119
src/routes/_app/settings.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
export const Route = createFileRoute("/_app/settings")({
|
||||
component: SettingsPage,
|
||||
});
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
value: ThemePreference;
|
||||
label: string;
|
||||
icon: typeof Sun;
|
||||
}[] = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
];
|
||||
|
||||
function SettingsPage() {
|
||||
const isHosted = isHostedClientAuthMode();
|
||||
const { themePreference, setThemePreference } = useThemePreference();
|
||||
const { data: session, isPending: isSessionPending } = useSession();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const analyticsEnabled = session?.user?.analyticsOptedOut !== true;
|
||||
|
||||
async function updateAnalyticsPreference(enabled: boolean) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await authClient.updateUser({
|
||||
analyticsOptedOut: !enabled,
|
||||
});
|
||||
if (result.error) {
|
||||
toast.error("We couldn't update your analytics setting.");
|
||||
} else {
|
||||
toast.success(enabled ? "Analytics enabled" : "Analytics disabled");
|
||||
}
|
||||
} catch {
|
||||
toast.error("We couldn't update your analytics setting.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
|
||||
<div className="mx-auto max-w-xl space-y-10">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-base-content/50">
|
||||
Appearance
|
||||
</h2>
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<span className="text-sm">Theme</span>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Theme preference"
|
||||
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const isActive = option.value === themePreference;
|
||||
const Icon = option.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={option.label}
|
||||
className={`flex cursor-pointer items-center justify-center rounded-md px-3 py-1.5 transition-colors ${
|
||||
isActive
|
||||
? "bg-base-100 text-base-content shadow-sm"
|
||||
: "text-base-content/50 hover:text-base-content/80"
|
||||
}`}
|
||||
onClick={() => setThemePreference(option.value)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isHosted ? (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-base-content/50">
|
||||
Analytics
|
||||
</h2>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<p className="text-sm">Help improve OpenSEO</p>
|
||||
<p className="mt-1 text-sm text-base-content/60">
|
||||
Share analytics and usage data.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary"
|
||||
checked={analyticsEnabled}
|
||||
disabled={isSessionPending || isSaving || !session?.user}
|
||||
onChange={(event) => {
|
||||
void updateAnalyticsPreference(event.currentTarget.checked);
|
||||
}}
|
||||
aria-label="Enable product analytics"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowRight, User } from "lucide-react";
|
||||
import { ArrowRight, Settings, User } from "lucide-react";
|
||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||
@ -235,7 +235,15 @@ function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
|
||||
className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
||||
>
|
||||
<li className="menu-title max-w-full">
|
||||
<span className="truncate text-base-content">{email}</span>
|
||||
<span className="truncate text-base-content" data-ph-mask>
|
||||
{email}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/settings" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
</li>
|
||||
<ThemePreferenceMenuItems />
|
||||
<li>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user