Compare commits

...
3 Commits
8 changed files with 234 additions and 68 deletions

No files matched your search

+4 -1
View File
@@ -179,7 +179,10 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
const dir = new URL('../drizzle/', import.meta.url); const dir = new URL('../drizzle/', import.meta.url);
try { try {
await migrate(drizzle(migrationPool), { migrationsFolder: fileURLToPath(dir) }); await migrate(drizzle(migrationPool), {
migrationsFolder: fileURLToPath(dir),
migrationsSchema: migrationSchema,
});
const discovered = await migrationPool.query<{ count: string }>( const discovered = await migrationPool.query<{ count: string }>(
"select count(*)::text as count from dares where id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000150'::uuid", "select count(*)::text as count from dares where id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000150'::uuid",
); );
+15 -36
View File
@@ -21,7 +21,7 @@ import {
handleNotificationResponse, handleNotificationResponse,
} from "../src/lib/notifications"; } from "../src/lib/notifications";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef } from "react";
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native"; import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
// Suppress useLayoutEffect warning on server-side rendering // Suppress useLayoutEffect warning on server-side rendering
@@ -51,7 +51,7 @@ import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store"; import { useAuthStore } from "../src/store";
import { authClient } from "../src/lib/authClient"; import { authClient } from "../src/lib/authClient";
import { colors, spacing, radius, typography } from "../src/theme"; import { colors, spacing, radius, typography } from "../src/theme";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen"; import { useGuestAccountWarning } from "../src/hooks/useGuestAccountWarning";
import { parseInviteLinkCode } from "../src/lib/inviteLink"; import { parseInviteLinkCode } from "../src/lib/inviteLink";
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode"; import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
import { needsOnboarding } from "../src/constants/onboarding"; import { needsOnboarding } from "../src/constants/onboarding";
@@ -59,18 +59,17 @@ import { needsOnboarding } from "../src/constants/onboarding";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
export default function RootLayout() { export default function RootLayout() {
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore(); const { fetchUser, setUser, user, isAuthenticated, isAnonymous } = useAuthStore();
const pathname = usePathname(); const pathname = usePathname();
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
const analyticsInitialized = useRef(false); const analyticsInitialized = useRef(false);
const [showGuestWarning, setShowGuestWarning] = useState(false); const { visible: showGuestWarning, dismiss: dismissGuestWarning } = useGuestAccountWarning({
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false); isAuthenticated,
isAnonymous,
const dismissGuestWarning = async () => { userId: user?.id,
await markGuestAccountWarningSeen(); pathname,
setShowGuestWarning(false); });
};
// Initialize analytics after native modules are ready // Initialize analytics after native modules are ready
@@ -90,22 +89,6 @@ export default function RootLayout() {
} }
}, [pathname]); }, [pathname]);
// Warn guest users (first time only) that accounts are not recoverable
useEffect(() => {
if (!isAuthenticated || !isAnonymous) return;
if (checkingGuestWarning) return;
if (pathname?.startsWith("/(auth)")) return;
setCheckingGuestWarning(true);
hasSeenGuestAccountWarning()
.then((seen) => {
if (!seen) {
setShowGuestWarning(true);
}
})
.finally(() => setCheckingGuestWarning(false));
}, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]);
// Track warm starts when app comes back from background // Track warm starts when app comes back from background
useEffect(() => { useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => { const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
@@ -313,22 +296,18 @@ export default function RootLayout() {
<View style={styles.guestWarningActions}> <View style={styles.guestWarningActions}>
<TouchableOpacity <TouchableOpacity
style={styles.guestWarningPrimary} style={styles.guestWarningPrimary}
onPress={async () => { onPress={async () => {
await markGuestAccountWarningSeen(); await dismissGuestWarning();
setShowGuestWarning(false); router.push("/(app)/settings/save-account" as any);
router.push("/(app)/settings/save-account" as any);
}} }}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={styles.guestWarningPrimaryText}>Save account</Text> <Text style={styles.guestWarningPrimaryText}>Save account</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.guestWarningSecondary} style={styles.guestWarningSecondary}
onPress={async () => { onPress={dismissGuestWarning}
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
}}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={styles.guestWarningSecondaryText}>Not now</Text> <Text style={styles.guestWarningSecondaryText}>Not now</Text>
@@ -0,0 +1,68 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen";
import { useGuestAccountWarning } from "@/hooks/useGuestAccountWarning";
jest.mock("@/lib/guestAccountWarningSeen", () => ({
hasSeenGuestAccountWarning: jest.fn(),
markGuestAccountWarningSeen: jest.fn(),
}));
const options = {
isAuthenticated: true,
isAnonymous: true,
userId: "guest-1",
pathname: "/(app)/pairing",
};
describe("useGuestAccountWarning", () => {
beforeEach(() => {
jest.mocked(hasSeenGuestAccountWarning).mockReset();
jest.mocked(markGuestAccountWarningSeen).mockReset();
});
it("does not reopen after a dismissal while the durable write is pending", async () => {
let finishMarking: (() => void) | undefined;
jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false);
jest.mocked(markGuestAccountWarningSeen).mockImplementation(() => new Promise<void>((resolve) => {
finishMarking = resolve;
}));
const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options });
await waitFor(() => expect(result.current.visible).toBe(true));
let dismissal: Promise<void> | undefined;
act(() => {
dismissal = result.current.dismiss();
});
expect(result.current.visible).toBe(false);
rerender({ ...options, pathname: "/(app)/settings" });
expect(result.current.visible).toBe(false);
expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1);
finishMarking?.();
await act(async () => {
await dismissal;
});
});
it("checks once per anonymous account and can warn a later guest account", async () => {
jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false);
const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options });
await waitFor(() => expect(result.current.visible).toBe(true));
rerender({ ...options, pathname: "/(app)/settings" });
expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.dismiss();
});
rerender({ ...options, userId: "guest-2" });
await waitFor(() => expect(hasSeenGuestAccountWarning).toHaveBeenCalledWith("guest-2"));
await waitFor(() => expect(result.current.visible).toBe(true));
});
});
@@ -0,0 +1,20 @@
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen";
import AsyncStorage from "@react-native-async-storage/async-storage";
describe("guestAccountWarningSeen", () => {
beforeEach(() => {
return AsyncStorage.clear();
});
it("persists a dismissal for that anonymous account", async () => {
await markGuestAccountWarningSeen("guest-1");
await expect(hasSeenGuestAccountWarning("guest-1")).resolves.toBe(true);
});
it("does not suppress the recovery warning for a later guest account", async () => {
await markGuestAccountWarningSeen("guest-1");
await expect(hasSeenGuestAccountWarning("guest-2")).resolves.toBe(false);
});
});
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../lib/guestAccountWarningSeen";
type GuestAccountWarningOptions = {
isAuthenticated: boolean;
isAnonymous: boolean;
userId: string | null | undefined;
pathname: string | null;
};
/**
* Shows the account recovery warning at most once for each anonymous account
* during a mounted app session. A dismissal is applied in memory before the
* durable write completes, so a slow storage operation cannot reopen it.
*/
export function useGuestAccountWarning({
isAuthenticated,
isAnonymous,
userId,
pathname,
}: GuestAccountWarningOptions) {
const [visible, setVisible] = useState(false);
const checkedUserIds = useRef(new Set<string>());
const dismissedUserIds = useRef(new Set<string>());
const isEligible = Boolean(
isAuthenticated
&& isAnonymous
&& userId
&& !pathname?.startsWith("/(auth)"),
);
useEffect(() => {
if (!isEligible || !userId) {
return;
}
if (checkedUserIds.current.has(userId) || dismissedUserIds.current.has(userId)) {
return;
}
checkedUserIds.current.add(userId);
let isCurrent = true;
void hasSeenGuestAccountWarning(userId).then((seen) => {
if (isCurrent && !seen && !dismissedUserIds.current.has(userId)) {
setVisible(true);
}
});
return () => {
isCurrent = false;
};
}, [isEligible, userId]);
const dismiss = useCallback(async () => {
if (!userId) {
setVisible(false);
return;
}
dismissedUserIds.current.add(userId);
setVisible(false);
await markGuestAccountWarningSeen(userId);
}, [userId]);
return { visible: isEligible && visible, dismiss };
}
+13 -29
View File
@@ -1,47 +1,31 @@
import * as SecureStore from "expo-secure-store"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
const STORAGE_KEY = "guest_account_recovery_warning_seen"; const STORAGE_KEY_PREFIX = "guest_account_recovery_warning_seen";
async function getStorage(): Promise<boolean> { const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(key: string): Promise<boolean> {
try { try {
let data: string | null = null; return (await AsyncStorage.getItem(key)) === "true";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
}
return data === "true";
} catch (error) { } catch (error) {
console.error("Error reading guest warning state:", error); console.error("Error reading guest warning state:", error);
} }
return false; return false;
} }
async function setStorage(seen: boolean): Promise<void> { async function setStorage(key: string, seen: boolean): Promise<void> {
try { try {
const data = seen ? "true" : "false"; await AsyncStorage.setItem(key, seen ? "true" : "false");
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data);
}
} else {
await SecureStore.setItemAsync(STORAGE_KEY, data);
}
} catch (error) { } catch (error) {
console.error("Error saving guest warning state:", error); console.error("Error saving guest warning state:", error);
} }
} }
export async function hasSeenGuestAccountWarning(): Promise<boolean> { /** The recovery warning is shown at most once for each anonymous account. */
return getStorage(); export async function hasSeenGuestAccountWarning(userId: string): Promise<boolean> {
return getStorage(buildKey(userId));
} }
export async function markGuestAccountWarningSeen(): Promise<void> { export async function markGuestAccountWarningSeen(userId: string): Promise<void> {
await setStorage(true); await setStorage(buildKey(userId), true);
} }
+31 -1
View File
@@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { redactInvitePaths } from './providers' import type { CapturedNetworkRequest } from 'posthog-js'
import { redactInviteNetworkRequest, redactInvitePaths } from './providers'
const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url) const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url)
const providerPath = new URL('./providers.tsx', import.meta.url) const providerPath = new URL('./providers.tsx', import.meta.url)
@@ -20,6 +21,7 @@ describe('PostHog invite privacy boundary', () => {
expect(source).toContain('capture_pageleave: false') expect(source).toContain('capture_pageleave: false')
expect(source).toContain('get_current_url: redactInviteUrl') expect(source).toContain('get_current_url: redactInviteUrl')
expect(source).toContain('before_send: redactInvitePathsBeforeSend') expect(source).toContain('before_send: redactInvitePathsBeforeSend')
expect(source).toContain('maskCapturedNetworkRequestFn: redactInviteNetworkRequest')
expect(source).toContain("url.pathname.startsWith('/join/')") expect(source).toContain("url.pathname.startsWith('/join/')")
expect(source).toContain('/join/[invite]') expect(source).toContain('/join/[invite]')
}) })
@@ -43,4 +45,32 @@ describe('PostHog invite privacy boundary', () => {
expect(serialized).toContain('/join/[invite]') expect(serialized).toContain('/join/[invite]')
expect(serialized).toContain('join_page_viewed') expect(serialized).toContain('join_page_viewed')
}) })
it('redacts invite codes from replay network request URLs, headers, and bodies without mutating the capture', () => {
const inviteCode = 'A4K9BT2Q'
const request = {
name: `https://sauci.app/join/${inviteCode}`,
entryType: 'resource',
startTime: 1,
duration: 2,
requestHeaders: {
referer: `https://sauci.app/join/${inviteCode}`,
},
requestBody: JSON.stringify({ invite_path: `/join/${inviteCode}` }),
responseHeaders: {
location: `https://sauci.app/join/${inviteCode}`,
},
responseBody: JSON.stringify({ referrer: `https://sauci.app/join/${inviteCode}` }),
} as CapturedNetworkRequest
const redacted = redactInviteNetworkRequest(request)
const serialized = JSON.stringify(redacted)
expect(redacted).not.toBe(request)
expect(redacted.requestHeaders).not.toBe(request.requestHeaders)
expect(redacted.responseHeaders).not.toBe(request.responseHeaders)
expect(serialized).not.toContain(inviteCode)
expect(serialized).toContain('/join/[invite]')
expect(JSON.stringify(request)).toContain(inviteCode)
})
}) })
+15 -1
View File
@@ -2,7 +2,7 @@
import posthog from 'posthog-js' import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react' import { PostHogProvider as PHProvider } from 'posthog-js/react'
import type { CaptureResult } from 'posthog-js' import type { CapturedNetworkRequest, CaptureResult } from 'posthog-js'
import { useEffect } from 'react' import { useEffect } from 'react'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
@@ -38,6 +38,17 @@ export function redactInvitePathsBeforeSend(event: CaptureResult | null): Captur
return event ? redactInvitePaths(event) : null return event ? redactInvitePaths(event) : null
} }
export function redactInviteNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest {
return {
...request,
name: redactInvitePaths(request.name),
requestHeaders: request.requestHeaders ? redactInvitePaths({ ...request.requestHeaders }) : request.requestHeaders,
requestBody: redactInvitePaths(request.requestBody),
responseHeaders: request.responseHeaders ? redactInvitePaths({ ...request.responseHeaders }) : request.responseHeaders,
responseBody: redactInvitePaths(request.responseBody),
}
}
export function PostHogProvider({ children }: { children: React.ReactNode }) { export function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname() const pathname = usePathname()
@@ -51,6 +62,9 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
capture_pageleave: false, capture_pageleave: false,
get_current_url: redactInviteUrl, get_current_url: redactInviteUrl,
before_send: redactInvitePathsBeforeSend, before_send: redactInvitePathsBeforeSend,
session_recording: {
maskCapturedNetworkRequestFn: redactInviteNetworkRequest,
},
// This must be part of initialization, not only the navigation effect // This must be part of initialization, not only the navigation effect
// below, so an initially loaded invite page is protected before its DOM // below, so an initially loaded invite page is protected before its DOM
// can be considered for capture or recording. // can be considered for capture or recording.