mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
fix(acquisition): close invite privacy gaps
This commit is contained in:
1 parent
1c7084c033
commit
4a57f31615
15 files changed
+213
-70
No files matched your search
@@ -14,24 +14,31 @@ const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" :
|
|||||||
|
|
||||||
export default function PairedScreen() {
|
export default function PairedScreen() {
|
||||||
const { user, couple, partner } = useAuthStore();
|
const { user, couple, partner } = useAuthStore();
|
||||||
const { newMatchesCount, fetchMatches, error } = useMatchStore();
|
const { newMatchesCount, fetchMatches } = useMatchStore();
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [loadedSuccessfully, setLoadedSuccessfully] = useState(false);
|
||||||
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
|
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
|
||||||
const heading = useRef<Text>(null);
|
const heading = useRef<Text>(null);
|
||||||
const role = rawRole === "inviter" ? "inviter" : "joiner";
|
const role = rawRole === "inviter" ? "inviter" : "joiner";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchMatches(true).finally(() => setReady(true));
|
let cancelled = false;
|
||||||
|
void fetchMatches(true).then((success) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadedSuccessfully(success);
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
}, [fetchMatches]);
|
}, [fetchMatches]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ready || error || !user || !couple) return;
|
if (!ready || !loadedSuccessfully || !user || !couple) return;
|
||||||
void markPairedUnlockSeen(user.id, couple.id);
|
void markPairedUnlockSeen(user.id, couple.id);
|
||||||
Events.pairingUnlockViewed(role, bucket(newMatchesCount));
|
Events.pairingUnlockViewed(role, bucket(newMatchesCount));
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never);
|
if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never);
|
||||||
}, 250);
|
}, 250);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [ready, error, user, couple, newMatchesCount]);
|
}, [ready, loadedSuccessfully, user, couple, newMatchesCount]);
|
||||||
|
|
||||||
const partnerName = partner?.name?.split(" ")[0] || "your partner";
|
const partnerName = partner?.name?.split(" ")[0] || "your partner";
|
||||||
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
|
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
|
||||||
@@ -40,8 +47,8 @@ export default function PairedScreen() {
|
|||||||
if (!ready) {
|
if (!ready) {
|
||||||
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
|
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
|
||||||
}
|
}
|
||||||
if (error) {
|
if (!loadedSuccessfully) {
|
||||||
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).finally(() => setReady(true)); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
|
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
|
||||||
}
|
}
|
||||||
return <GradientBackground><View style={styles.container}>
|
return <GradientBackground><View style={styles.container}>
|
||||||
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
|
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function PairingScreen() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (typeof params.incomingCode === "string") {
|
if (typeof params.incomingCode === "string") {
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode(user.id);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]);
|
Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]);
|
||||||
}
|
}
|
||||||
@@ -86,15 +86,15 @@ export default function PairingScreen() {
|
|||||||
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
|
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
|
||||||
if (isValidInviteCode(routeCode)) {
|
if (isValidInviteCode(routeCode)) {
|
||||||
await applyPrefill(routeCode);
|
await applyPrefill(routeCode);
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode(user?.id);
|
||||||
if (!cancelled) setPrefillAttempted(true);
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stashedCode = await getPendingInviteCode();
|
const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null;
|
||||||
if (stashedCode) {
|
if (stashedCode) {
|
||||||
await applyPrefill(stashedCode);
|
await applyPrefill(stashedCode);
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode(user?.id);
|
||||||
if (!cancelled) setPrefillAttempted(true);
|
if (!cancelled) setPrefillAttempted(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ export default function PairingScreen() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [couple, params.code, prefillAttempted]);
|
}, [couple, params.code, prefillAttempted, user?.id]);
|
||||||
|
|
||||||
const acceptClipboardOffer = () => {
|
const acceptClipboardOffer = () => {
|
||||||
if (!clipboardOfferCode) return;
|
if (!clipboardOfferCode) return;
|
||||||
@@ -312,7 +312,7 @@ export default function PairingScreen() {
|
|||||||
"Cancel this invite?",
|
"Cancel this invite?",
|
||||||
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
|
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
|
||||||
[
|
[
|
||||||
{ text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode() },
|
{ text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode(user?.id) },
|
||||||
{
|
{
|
||||||
text: "Cancel mine and join", style: "destructive", onPress: async () => {
|
text: "Cancel mine and join", style: "destructive", onPress: async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
@@ -320,7 +320,7 @@ export default function PairingScreen() {
|
|||||||
await cancelInvite(async () => {
|
await cancelInvite(async () => {
|
||||||
setInviteCode(incomingCode);
|
setInviteCode(incomingCode);
|
||||||
setWasPrefilled(true);
|
setWasPrefilled(true);
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode(user?.id);
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
@@ -369,7 +369,7 @@ export default function PairingScreen() {
|
|||||||
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
|
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
|
||||||
Cancel mine and join
|
Cancel mine and join
|
||||||
</GlassButton>
|
</GlassButton>
|
||||||
<TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
|
<TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
|
||||||
<Text style={styles.cancelButtonText}>Keep my invite</Text>
|
<Text style={styles.cancelButtonText}>Keep my invite</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
@@ -400,7 +400,7 @@ export default function PairingScreen() {
|
|||||||
>
|
>
|
||||||
<GlassCard variant="elevated">
|
<GlassCard variant="elevated">
|
||||||
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
|
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
|
||||||
You've answered at least {sealedCount} question{sealedCount === 1 ? "" : "s"} about you two. When you both answer, shared discoveries are visible to you both.
|
You have saved at least {sealedCount} private question{sealedCount === 1 ? "" : "s"}. Vote-style questions show shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you each answer.
|
||||||
</Text>
|
</Text>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -134,12 +134,12 @@ export default function RootLayout() {
|
|||||||
const { isAuthenticated, user } = useAuthStore.getState();
|
const { isAuthenticated, user } = useAuthStore.getState();
|
||||||
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
|
||||||
if (user?.couple_id) {
|
if (user?.couple_id) {
|
||||||
await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode());
|
await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode(user.id));
|
||||||
if (user.couple_id) {
|
if (user.couple_id) {
|
||||||
router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } });
|
router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await stashPendingInviteCode(inviteLink.code);
|
await stashPendingInviteCode(inviteLink.code, user?.id ?? null);
|
||||||
if (isAuthenticated && !onboardingPending) {
|
if (isAuthenticated && !onboardingPending) {
|
||||||
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
|
|||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||||
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
|
const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null);
|
||||||
const [hasPendingInvite, setHasPendingInvite] = useState(false);
|
|
||||||
|
|
||||||
// Check once, after auth resolves, whether an invite code is waiting to be
|
// Check once, after auth resolves, whether an invite code is waiting to be
|
||||||
// applied (e.g. the user tapped a join link before signing in).
|
// applied (e.g. the user tapped a join link before signing in).
|
||||||
@@ -18,16 +17,17 @@ export default function Index() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getPendingInviteCode().then((code) => {
|
if (!user?.id) return;
|
||||||
|
setPendingInviteCheck(null);
|
||||||
|
getPendingInviteCode(user.id).then((code) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setHasPendingInvite(!!code);
|
setPendingInviteCheck({ userId: user.id, hasInvite: !!code });
|
||||||
setPendingCodeChecked(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isLoading, isAuthenticated, user?.couple_id]);
|
}, [isLoading, isAuthenticated, user?.couple_id, user?.id]);
|
||||||
|
|
||||||
// Show loading state while auth is being determined
|
// Show loading state while auth is being determined
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -56,7 +56,7 @@ export default function Index() {
|
|||||||
// Wait for the one-time pending invite code check before deciding the
|
// Wait for the one-time pending invite code check before deciding the
|
||||||
// route, so a stashed code from a join link is applied without an extra
|
// route, so a stashed code from a join link is applied without an extra
|
||||||
// trip through the home screen.
|
// trip through the home screen.
|
||||||
if (!pendingCodeChecked) {
|
if (pendingInviteCheck?.userId !== user?.id) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ActivityIndicator size="large" color="#e94560" />
|
<ActivityIndicator size="large" color="#e94560" />
|
||||||
@@ -64,7 +64,7 @@ export default function Index() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPendingInvite) {
|
if (pendingInviteCheck?.hasInvite) {
|
||||||
return <Redirect href="/(app)/pairing" />;
|
return <Redirect href="/(app)/pairing" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ describe("buildInviteShareMessage", () => {
|
|||||||
const message = buildInviteShareMessage("AB12CD34", 3);
|
const message = buildInviteShareMessage("AB12CD34", 3);
|
||||||
expect(message).toContain("https://sauci.app/join/AB12CD34");
|
expect(message).toContain("https://sauci.app/join/AB12CD34");
|
||||||
expect(message).toContain("enter code AB12CD34 in Sauci");
|
expect(message).toContain("enter code AB12CD34 in Sauci");
|
||||||
expect(message).toContain("compare our answers together");
|
expect(message).toContain("Vote-style questions show shared outcomes");
|
||||||
|
expect(message).toContain("open answers can be visible to us both after we each answer");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not claim a sealed outcome before answers exist", () => {
|
it("does not claim a sealed outcome before answers exist", () => {
|
||||||
|
|||||||
@@ -10,20 +10,40 @@ describe("pendingInviteCode", () => {
|
|||||||
await AsyncStorage.clear();
|
await AsyncStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stashes, retrieves, and clears a valid code", async () => {
|
it("stashes, retrieves, and clears a valid code for its owner", async () => {
|
||||||
await stashPendingInviteCode("abcd1234");
|
await stashPendingInviteCode("abcd1234", "user-a");
|
||||||
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode("user-a");
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when nothing has been stashed", async () => {
|
it("returns null when nothing has been stashed", async () => {
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores a stashed value that no longer matches the invite code shape", async () => {
|
it("ignores a stashed value that no longer matches the invite code shape", async () => {
|
||||||
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
|
await AsyncStorage.setItem("pending_invite_code", "not-a-code");
|
||||||
await expect(getPendingInviteCode()).resolves.toBeNull();
|
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not leak a claimed invite to a later account", async () => {
|
||||||
|
await stashPendingInviteCode("abcd1234");
|
||||||
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
await expect(getPendingInviteCode("user-b")).resolves.toBeNull();
|
||||||
|
|
||||||
|
await clearPendingInviteCode("user-b");
|
||||||
|
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets only one concurrently arriving account claim a pre-auth invite", async () => {
|
||||||
|
await stashPendingInviteCode("abcd1234");
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
getPendingInviteCode("user-a"),
|
||||||
|
getPendingInviteCode("user-b"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect([first, second].filter(Boolean)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -50,10 +50,11 @@ describe('matchStore API behavior', () => {
|
|||||||
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
||||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||||
const inFlight = useMatchStore.getState().fetchMatches(true);
|
const inFlight = useMatchStore.getState().fetchMatches(true);
|
||||||
await useMatchStore.getState().fetchMatches(true, { silent: true });
|
const overlapping = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
release({ matches: [], totalCount: 0 });
|
release({ matches: [], totalCount: 0 });
|
||||||
await inFlight;
|
await inFlight;
|
||||||
|
await overlapping;
|
||||||
|
|
||||||
(apiClient.get as jest.Mock).mockClear();
|
(apiClient.get as jest.Mock).mockClear();
|
||||||
useMatchStore.setState({ hasMore: false });
|
useMatchStore.setState({ hasMore: false });
|
||||||
@@ -61,6 +62,21 @@ describe('matchStore API behavior', () => {
|
|||||||
expect(apiClient.get).not.toHaveBeenCalled();
|
expect(apiClient.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('makes overlapping callers await the same failed refresh', async () => {
|
||||||
|
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
(apiClient.get as jest.Mock).mockImplementationOnce(() => new Promise((_, rejectRequest) => { reject = rejectRequest; }));
|
||||||
|
|
||||||
|
const first = useMatchStore.getState().fetchMatches(true);
|
||||||
|
const second = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||||
|
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
reject(new Error('network'));
|
||||||
|
await expect(first).resolves.toBe(false);
|
||||||
|
await expect(second).resolves.toBe(false);
|
||||||
|
expect(useMatchStore.getState().error).toBe('Failed to load matches');
|
||||||
|
});
|
||||||
|
|
||||||
it('appends and deduplicates later pages', async () => {
|
it('appends and deduplicates later pages', async () => {
|
||||||
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
|
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
|
||||||
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
|
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function buildInviteShareMessage(inviteCode: string, sealedCount: number)
|
|||||||
const link = `https://sauci.app/join/${inviteCode}`;
|
const link = `https://sauci.app/join/${inviteCode}`;
|
||||||
if (sealedCount > 0) {
|
if (sealedCount > 0) {
|
||||||
const noun = sealedCount === 1 ? "question" : "questions";
|
const noun = sealedCount === 1 ? "question" : "questions";
|
||||||
return `Join me on Sauci! I've answered ${sealedCount} ${noun} about us. Join to compare our answers together. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
|
return `Join me on Sauci! I've saved ${sealedCount} ${noun} for us to compare. Vote-style questions show shared outcomes; open answers can be visible to us both after we each answer. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
|
||||||
}
|
}
|
||||||
return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
|
return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
|
||||||
}
|
}
|
||||||
@@ -6,31 +6,73 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
|||||||
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
|
||||||
|
|
||||||
const STORAGE_KEY = "pending_invite_code";
|
const STORAGE_KEY = "pending_invite_code";
|
||||||
|
let storageOperation = Promise.resolve();
|
||||||
|
|
||||||
|
function withStorageLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const next = storageOperation.then(operation, operation);
|
||||||
|
storageOperation = next.then(() => undefined, () => undefined);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingInviteCode {
|
||||||
|
code: string;
|
||||||
|
/** The account that received or first claimed this deferred invite. */
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePendingInviteCode(value: string | null): PendingInviteCode | null {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
// Treat legacy, unscoped values as a pre-auth hand-off. The first account
|
||||||
|
// that intentionally enters the pairing flow claims it below.
|
||||||
|
if (isValidInviteCode(value)) return { code: normalizeInviteCode(value), userId: null };
|
||||||
|
|
||||||
export async function stashPendingInviteCode(code: string): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code));
|
const parsed = JSON.parse(value) as Partial<PendingInviteCode>;
|
||||||
|
if (typeof parsed.code !== "string" || !isValidInviteCode(parsed.code)) return null;
|
||||||
|
if (parsed.userId !== null && typeof parsed.userId !== "string") return null;
|
||||||
|
return { code: normalizeInviteCode(parsed.code), userId: parsed.userId ?? null };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stashPendingInviteCode(code: string, userId: string | null = null): Promise<void> {
|
||||||
|
try {
|
||||||
|
await withStorageLock(() => AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ code: normalizeInviteCode(code), userId } satisfies PendingInviteCode)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error stashing pending invite code:", error);
|
console.error("Error stashing pending invite code:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPendingInviteCode(): Promise<string | null> {
|
export async function getPendingInviteCode(userId: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const value = await AsyncStorage.getItem(STORAGE_KEY);
|
return await withStorageLock(async () => {
|
||||||
if (value && isValidInviteCode(value)) {
|
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
|
||||||
return normalizeInviteCode(value);
|
if (!pending || (pending.userId && pending.userId !== userId)) return null;
|
||||||
}
|
|
||||||
return null;
|
// A hand-off made before sign-in becomes owned by the account that
|
||||||
|
// reaches pairing. Later account switches cannot consume it.
|
||||||
|
if (!pending.userId) {
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...pending, userId }));
|
||||||
|
}
|
||||||
|
return pending.code;
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reading pending invite code:", error);
|
console.error("Error reading pending invite code:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearPendingInviteCode(): Promise<void> {
|
export async function clearPendingInviteCode(userId?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
await withStorageLock(async () => {
|
||||||
|
if (userId) {
|
||||||
|
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
|
||||||
|
if (pending?.userId && pending.userId !== userId) return;
|
||||||
|
}
|
||||||
|
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error clearing pending invite code:", error);
|
console.error("Error clearing pending invite code:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
if (unlockUserId) {
|
if (unlockUserId) {
|
||||||
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
|
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
|
||||||
}
|
}
|
||||||
await clearPendingInviteCode();
|
await clearPendingInviteCode(unlockUserId);
|
||||||
|
|
||||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||||
set({
|
set({
|
||||||
@@ -259,7 +259,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
// Clear other stores when user signs out
|
// Clear other stores when user signs out
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
void clearPendingInviteCode();
|
void clearPendingInviteCode(priorUserId);
|
||||||
if (priorUserId) {
|
if (priorUserId) {
|
||||||
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
|
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface FetchOptions { silent?: boolean }
|
|||||||
// account's request for the same key, and a stale generation's `finally` must not
|
// account's request for the same key, and a stale generation's `finally` must not
|
||||||
// clear an entry a newer generation just claimed.
|
// clear an entry a newer generation just claimed.
|
||||||
const inFlight = new Map<string, number>();
|
const inFlight = new Map<string, number>();
|
||||||
|
const activeInFlight = new Map<string, { generation: number; promise: Promise<boolean> }>();
|
||||||
|
|
||||||
// Rate limit: 12 hours in milliseconds
|
// Rate limit: 12 hours in milliseconds
|
||||||
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||||
@@ -80,7 +81,7 @@ interface MatchState {
|
|||||||
*/
|
*/
|
||||||
generation: number;
|
generation: number;
|
||||||
// Methods
|
// Methods
|
||||||
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
|
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<boolean>;
|
||||||
markAsSeen: (matchId: string) => Promise<void>;
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
markAllAsSeen: () => Promise<void>;
|
markAllAsSeen: () => Promise<void>;
|
||||||
addMatch: (match: Match) => void;
|
addMatch: (match: Match) => void;
|
||||||
@@ -130,18 +131,20 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
isNudging: false,
|
isNudging: false,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
|
|
||||||
fetchMatches: async (refresh = false, options = {}) => {
|
fetchMatches: (refresh = false, options = {}) => {
|
||||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
const myGeneration = get().generation;
|
const myGeneration = get().generation;
|
||||||
|
|
||||||
// Early return if no couple - user isn't paired yet
|
// Early return if no couple - user isn't paired yet
|
||||||
if (!coupleId) {
|
if (!coupleId) {
|
||||||
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
||||||
return;
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
|
const activeRequest = activeInFlight.get('active');
|
||||||
|
if (activeRequest?.generation === myGeneration) return activeRequest.promise;
|
||||||
|
if (state.isLoadingMore && !refresh) return Promise.resolve(false);
|
||||||
|
|
||||||
// Nothing on screen yet is the only case that warrants blanking the list
|
// Nothing on screen yet is the only case that warrants blanking the list
|
||||||
// for a spinner; every other refresh leaves the cached rows in place.
|
// for a spinner; every other refresh leaves the cached rows in place.
|
||||||
@@ -149,17 +152,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
if (refresh) {
|
if (refresh) {
|
||||||
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
||||||
} else {
|
} else {
|
||||||
if (!state.hasMore) return;
|
if (!state.hasMore) return Promise.resolve(false);
|
||||||
set({ isLoadingMore: true });
|
set({ isLoadingMore: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
inFlight.set('active', myGeneration);
|
const request = (async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const currentPage = refresh ? 0 : state.page;
|
const currentPage = refresh ? 0 : state.page;
|
||||||
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||||
// A sign-out/account switch that happened while this request was in
|
// A sign-out/account switch that happened while this request was in
|
||||||
// flight must not let its response populate the next account's store.
|
// flight must not let its response populate the next account's store.
|
||||||
if (get().generation !== myGeneration) return;
|
if (get().generation !== myGeneration) return false;
|
||||||
const nonArchivedMatches = result.matches;
|
const nonArchivedMatches = result.matches;
|
||||||
const totalCount = result.totalCount;
|
const totalCount = result.totalCount;
|
||||||
const archivedMatchIds = state.archivedMatchIds;
|
const archivedMatchIds = state.archivedMatchIds;
|
||||||
@@ -174,7 +177,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
} else {
|
} else {
|
||||||
set({ isLoadingMore: false, hasMore: false });
|
set({ isLoadingMore: false, hasMore: false });
|
||||||
}
|
}
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortedData = nonArchivedMatches;
|
const sortedData = nonArchivedMatches;
|
||||||
@@ -208,14 +211,19 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
loadedAt,
|
loadedAt,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (get().generation !== myGeneration) return;
|
if (get().generation !== myGeneration) return false;
|
||||||
console.error("Error fetching matches:", err);
|
console.error("Error fetching matches:", err);
|
||||||
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
// An old generation's cleanup must not delete a newer generation's guard.
|
// An old generation's cleanup must not delete a newer generation's guard.
|
||||||
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
|
if (activeInFlight.get('active')?.generation === myGeneration) activeInFlight.delete('active');
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
activeInFlight.set('active', { generation: myGeneration, promise: request });
|
||||||
|
return request;
|
||||||
},
|
},
|
||||||
|
|
||||||
markAsSeen: async (matchId) => {
|
markAsSeen: async (matchId) => {
|
||||||
@@ -296,6 +304,7 @@ clearMatches: () => {
|
|||||||
// A request belonging to the signed-out user must not suppress the next
|
// A request belonging to the signed-out user must not suppress the next
|
||||||
// one: the guard is a duplicate-request check, not a lock on the data.
|
// one: the guard is a duplicate-request check, not a lock on the data.
|
||||||
inFlight.clear();
|
inFlight.clear();
|
||||||
|
activeInFlight.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
// Archive methods
|
// Archive methods
|
||||||
|
|||||||
@@ -65,13 +65,21 @@ describe('/join/[code] JoinClient', () => {
|
|||||||
expect(source).not.toContain('glass-light')
|
expect(source).not.toContain('glass-light')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('describes open-answer visibility truthfully', async () => {
|
it('describes comparison and open-answer visibility truthfully', async () => {
|
||||||
const source = await readFile(componentPath, 'utf8')
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
expect(source).toContain('For open-answer questions, each response can be visible after you have both answered.')
|
expect(source).toContain('Vote-style questions reveal shared outcomes')
|
||||||
|
expect(source).toContain('open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.')
|
||||||
expect(source).not.toContain('Neither of you sees the other’s individual answers.')
|
expect(source).not.toContain('Neither of you sees the other’s individual answers.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks the invite-code surface out of PostHog DOM capture and replay', async () => {
|
||||||
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain('data-ph-no-capture')
|
||||||
|
expect(source).toContain('data-ph-mask')
|
||||||
|
})
|
||||||
|
|
||||||
it('shows a clear fallback message for a malformed invite code', async () => {
|
it('shows a clear fallback message for a malformed invite code', async () => {
|
||||||
const source = await readFile(componentPath, 'utf8')
|
const source = await readFile(componentPath, 'utf8')
|
||||||
|
|
||||||
|
|||||||
@@ -124,10 +124,10 @@ export function JoinClient({ code }: { code: string }) {
|
|||||||
const storeUnavailableTitle = isOnline ? undefined : 'Reconnect to open the app store.'
|
const storeUnavailableTitle = isOnline ? undefined : 'Reconnect to open the app store.'
|
||||||
const desktopCopy = platform === 'desktop'
|
const desktopCopy = platform === 'desktop'
|
||||||
? 'Sauci is a phone app. Copy your code, then open this link on your phone or install from the store there.'
|
? 'Sauci is a phone app. Copy your code, then open this link on your phone or install from the store there.'
|
||||||
: 'They have already answered questions about you two. Join them to discover what you have in common. For open-answer questions, each response can be visible after you have both answered.'
|
: 'Join to compare answers together. Vote-style questions reveal shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-gradient-to-b from-background-light to-background">
|
<main className="min-h-screen bg-gradient-to-b from-background-light to-background" data-ph-no-capture data-ph-mask>
|
||||||
<header className="py-6 px-6 border-b border-white/5">
|
<header className="py-6 px-6 border-b border-white/5">
|
||||||
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
<div className="max-w-4xl mx-auto flex items-center gap-4">
|
||||||
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||||
@@ -150,7 +150,7 @@ export function JoinClient({ code }: { code: string }) {
|
|||||||
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to compare answers</h1>
|
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You've been invited to compare answers</h1>
|
||||||
<p className="text-white/70 mb-10">{desktopCopy}</p>
|
<p className="text-white/70 mb-10">{desktopCopy}</p>
|
||||||
|
|
||||||
<section className="surface-solid p-6 sm:p-8 mb-6" aria-labelledby="invite-code-heading">
|
<section className="surface-solid p-6 sm:p-8 mb-6" aria-labelledby="invite-code-heading" data-ph-no-capture data-ph-mask>
|
||||||
<h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2>
|
<h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2>
|
||||||
<p
|
<p
|
||||||
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
const providerPath = new URL('./providers.tsx', import.meta.url)
|
||||||
|
|
||||||
|
describe('PostHogProvider invite privacy', () => {
|
||||||
|
it('disables DOM autocapture and session recording on invite routes', async () => {
|
||||||
|
const source = await readFile(providerPath, 'utf8')
|
||||||
|
|
||||||
|
expect(source).toContain("pathname?.startsWith('/join/')")
|
||||||
|
expect(source).toContain("window.location.pathname.startsWith('/join/')")
|
||||||
|
expect(source).toContain('autocapture: !isInvitePage')
|
||||||
|
expect(source).toContain('disable_session_recording: isInvitePage')
|
||||||
|
expect(source).toContain('mask_all_text: isInvitePage')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ 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 { CaptureResult } from 'posthog-js'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g
|
const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g
|
||||||
|
|
||||||
@@ -38,18 +39,41 @@ export function redactInvitePathsBeforeSend(event: CaptureResult | null): Captur
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||||
useEffect(() => {
|
const pathname = usePathname()
|
||||||
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
|
||||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||||
|
const isInvitePage = window.location.pathname.startsWith('/join/')
|
||||||
|
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||||
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
|
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
|
||||||
person_profiles: 'identified_only',
|
person_profiles: 'identified_only',
|
||||||
capture_pageview: false,
|
capture_pageview: false,
|
||||||
capture_pageleave: false,
|
capture_pageleave: false,
|
||||||
get_current_url: redactInviteUrl,
|
get_current_url: redactInviteUrl,
|
||||||
before_send: redactInvitePathsBeforeSend,
|
before_send: redactInvitePathsBeforeSend,
|
||||||
})
|
// This must be part of initialization, not only the navigation effect
|
||||||
|
// below, so an initially loaded invite page is protected before its DOM
|
||||||
|
// can be considered for capture or recording.
|
||||||
|
autocapture: !isInvitePage,
|
||||||
|
disable_session_recording: isInvitePage,
|
||||||
|
mask_all_text: isInvitePage,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return
|
||||||
|
|
||||||
|
// Invite codes are credentials. Do not let their page enter PostHog's DOM
|
||||||
|
// autocapture or session-recording pipeline, even though event properties
|
||||||
|
// and URLs are separately redacted below.
|
||||||
|
const isInvitePage = pathname?.startsWith('/join/')
|
||||||
|
posthog.set_config({
|
||||||
|
autocapture: !isInvitePage,
|
||||||
|
disable_session_recording: isInvitePage,
|
||||||
|
mask_all_text: isInvitePage,
|
||||||
|
})
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
|
|||||||
Reference in new issue
Block a user