Add guest auth and save-account flow

Introduce anonymous sign-in with in-app saving, guest recovery warnings, paywall prompts, and admin guest labeling.
This commit is contained in:
jjbrunton committed 2026-01-17 17:55:06 +00:00
1 parent ce68d57a49
commit 349f460e15
14 files changed
+1236 -176

No files matched your search

@@ -0,0 +1,3 @@
import { SaveAccountScreen } from "../../../src/features/profile/screens";
export default SaveAccountScreen;
+42
View File
@@ -61,6 +61,7 @@ export default function LoginScreen() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isGuestLoading, setIsGuestLoading] = useState(false);
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
@@ -364,6 +365,20 @@ export default function LoginScreen() {
}
};
const handleGuestSignIn = async () => {
clearError();
setIsGuestLoading(true);
const { error: authError } = await supabase.auth.signInAnonymously();
setIsGuestLoading(false);
if (authError) {
showError(getAuthError(authError));
return;
}
Events.signIn("guest");
};
if (isMagicLinkSent) {
return (
<GradientBackground showAccent>
@@ -663,6 +678,23 @@ export default function LoginScreen() {
</View>
</Animated.View>
)}
<Animated.View
entering={FadeInDown.delay(400).duration(600).springify()}
style={styles.guestContainer}
>
<GlassButton
variant="ghost"
onPress={handleGuestSignIn}
loading={isGuestLoading}
disabled={isGuestLoading}
icon={<Ionicons name="person-outline" size={20} color={colors.primary} />}
fullWidth
>
Continue without Login
</GlassButton>
<Text style={styles.guestHint}>Save your account anytime in Settings.</Text>
</Animated.View>
</View>
</ScrollView>
</KeyboardAvoidingView>
@@ -821,4 +853,14 @@ const styles = StyleSheet.create({
flex: 1,
height: 48,
},
guestContainer: {
marginTop: spacing.lg,
alignItems: "center",
gap: spacing.sm,
},
guestHint: {
...typography.caption1,
color: colors.textTertiary,
textAlign: "center",
},
});
+169 -4
View File
@@ -16,8 +16,8 @@ import {
handleNotificationResponse,
} from "../src/lib/notifications";
import React, { useEffect, useRef } from "react";
import { Platform, View, AppState, AppStateStatus } from "react-native";
import React, { useEffect, useRef, useState } from "react";
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
// Suppress useLayoutEffect warning on server-side rendering
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
@@ -34,6 +34,7 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
import { Stack, router, usePathname } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";
// GestureHandlerRootView is only needed on native platforms
// On web, we use a simple View to avoid findNodeHandle errors
const GestureHandlerRootView = Platform.OS === "web"
@@ -43,15 +44,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store";
import { supabase } from "../src/lib/supabase";
import { colors, spacing, radius, typography } from "../src/theme";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
const queryClient = new QueryClient();
export default function RootLayout() {
const { fetchUser, setUser } = useAuthStore();
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
const pathname = usePathname();
const appState = useRef(AppState.currentState);
const analyticsInitialized = useRef(false);
const [showGuestWarning, setShowGuestWarning] = useState(false);
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
const dismissGuestWarning = async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
};
// Initialize analytics after native modules are ready
useEffect(() => {
if (!analyticsInitialized.current) {
@@ -68,6 +80,22 @@ export default function RootLayout() {
}
}, [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
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
@@ -181,7 +209,7 @@ export default function RootLayout() {
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
console.log('[Auth] Calling fetchUser...');
fetchUser();
// Set Sentry user context for error tracking
@@ -223,7 +251,144 @@ export default function RootLayout() {
contentStyle: { backgroundColor: "#1a1a2e" },
}}
/>
<Modal
visible={showGuestWarning}
transparent
animationType="fade"
onRequestClose={dismissGuestWarning}
>
<Pressable
style={styles.guestWarningOverlay}
onPress={dismissGuestWarning}
>
<Pressable style={styles.guestWarningCard} onPress={() => { }}>
<View style={styles.guestWarningHeader}>
<View style={styles.guestWarningIcon}>
<Ionicons name="warning" size={22} color={colors.primary} />
</View>
<TouchableOpacity
onPress={dismissGuestWarning}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={styles.guestWarningClose}
>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<Text style={styles.guestWarningTitle}>Your account isn't saved</Text>
<Text style={styles.guestWarningBody}>
If you delete the app or switch devices, you won't be able to recover this account.
Save your account now to protect your couple, matches, and purchases.
</Text>
<View style={styles.guestWarningActions}>
<TouchableOpacity
style={styles.guestWarningPrimary}
onPress={async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
router.push("/(app)/settings/save-account" as any);
}}
activeOpacity={0.8}
>
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.guestWarningSecondary}
onPress={async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
}}
activeOpacity={0.8}
>
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
</TouchableOpacity>
</View>
</Pressable>
</Pressable>
</Modal>
</GestureHandlerRootView>
</QueryClientProvider>
);
}
const styles = StyleSheet.create({
guestWarningOverlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
backgroundColor: "rgba(0, 0, 0, 0.75)",
},
guestWarningCard: {
width: "100%",
maxWidth: 420,
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.lg,
borderWidth: 1,
borderColor: colors.glass.border,
},
guestWarningHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: spacing.md,
},
guestWarningIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
},
guestWarningClose: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
},
guestWarningTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.sm,
},
guestWarningBody: {
...typography.body,
color: colors.textSecondary,
lineHeight: 22,
marginBottom: spacing.lg,
},
guestWarningActions: {
gap: spacing.sm,
},
guestWarningPrimary: {
backgroundColor: colors.primary,
paddingVertical: 14,
borderRadius: radius.md,
alignItems: "center",
},
guestWarningPrimaryText: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
},
guestWarningSecondary: {
backgroundColor: colors.glass.background,
paddingVertical: 14,
borderRadius: radius.md,
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.border,
},
guestWarningSecondaryText: {
...typography.subhead,
color: colors.textSecondary,
fontWeight: "600",
},
});
@@ -15,6 +15,7 @@ describe('authStore', () => {
partner: null,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
} as any);
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
@@ -31,9 +32,35 @@ describe('authStore', () => {
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isAnonymous).toBe(false);
expect(state.isLoading).toBe(false);
});
it('sets isAnonymous from auth user', async () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: 'me' } } },
});
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
data: { user: { id: 'me', is_anonymous: true } },
error: null,
});
const profileQuery: any = {
select: jest.fn(() => profileQuery),
eq: jest.fn(() => profileQuery),
maybeSingle: jest.fn(async () => ({ data: { id: 'me', couple_id: null } })),
};
(supabase.from as jest.Mock).mockReturnValue(profileQuery);
await useAuthStore.getState().fetchUser();
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(true);
expect(state.isAnonymous).toBe(true);
});
it('clears stores and signs out when session invalid', async () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: 'me' } } },
@@ -50,51 +77,9 @@ describe('authStore', () => {
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().isAnonymous).toBe(false);
expect(useMatchStore.getState().matches).toEqual([]);
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
expect(useMessageStore.getState().unreadCount).toBe(0);
expect(useMessageStore.getState().activeMatchId).toBeNull();
expect(useSubscriptionStore.getState().subscription.isProUser).toBe(false);
expect(supabase.auth.signOut).toHaveBeenCalled();
});
it('fetches couple and partner when couple_id exists', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1' } } as any);
const couplesQuery: any = {
select: jest.fn(() => couplesQuery),
eq: jest.fn(() => couplesQuery),
maybeSingle: jest.fn(async () => ({ data: { id: 'c1' } })),
};
const partnerQuery: any = {
select: jest.fn(() => partnerQuery),
eq: jest.fn(() => partnerQuery),
neq: jest.fn(() => partnerQuery),
maybeSingle: jest.fn(async () => ({ data: { id: 'partner' } })),
};
(supabase.from as jest.Mock).mockImplementation((table: string) => {
if (table === 'couples') return couplesQuery;
if (table === 'profiles') return partnerQuery;
return couplesQuery;
});
await useAuthStore.getState().fetchCouple();
expect(useAuthStore.getState().couple).toEqual({ id: 'c1' });
expect(useAuthStore.getState().partner).toEqual({ id: 'partner' });
});
it('signOut clears local state even if Supabase fails', async () => {
(supabase.auth.signOut as jest.Mock).mockRejectedValueOnce(new Error('network'));
await useAuthStore.getState().signOut();
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().isAnonymous).toBe(false);
expect(useAuthStore.getState().isLoading).toBe(false);
});
});
+196 -45
View File
@@ -13,7 +13,8 @@ import {
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { useSubscriptionStore } from "../../store";
import { router } from "expo-router";
import { useAuthStore, useSubscriptionStore } from "../../store";
import { colors, gradients, spacing, radius, typography } from "../../theme";
import type { PurchasesPackage } from "../../lib/revenuecat";
import { Events } from "../../lib/analytics";
@@ -42,10 +43,12 @@ const FEATURES = [
},
];
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
Events.paywallClosed("paywall", reason);
onClose();
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const { isAnonymous } = useAuthStore();
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
Events.paywallClosed("paywall", reason);
onClose();
};
const {
offerings,
@@ -60,6 +63,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
null
);
const [showGuestConfirm, setShowGuestConfirm] = useState(false);
const [guestConfirmed, setGuestConfirmed] = useState(false);
useEffect(() => {
if (visible && !offerings) {
@@ -67,6 +72,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
}
if (visible) {
Events.paywallShown("paywall");
setGuestConfirmed(false);
setShowGuestConfirm(false);
}
}, [visible, offerings, fetchOfferings]);
@@ -80,36 +87,47 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
}
}, [offerings]);
const handlePurchase = async () => {
if (!selectedPackage) return;
const packageType = selectedPackage.packageType || "unknown";
Events.purchaseInitiated(packageType);
const result = await purchasePackage(selectedPackage);
if (result.success) {
Events.purchaseCompleted(packageType);
onSuccess?.();
handleClose("success");
return;
}
if (result.cancelled) {
Events.purchaseCancelled(packageType, result.errorCode);
return;
}
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
};
const doPurchase = async () => {
if (!selectedPackage) return;
const handleRestore = async () => {
const restored = await restorePurchases();
if (restored) {
Events.purchaseRestored();
onSuccess?.();
handleClose("restore");
}
};
const packageType = selectedPackage.packageType || "unknown";
Events.purchaseInitiated(packageType);
const result = await purchasePackage(selectedPackage);
if (result.success) {
Events.purchaseCompleted(packageType);
onSuccess?.();
handleClose("success");
return;
}
if (result.cancelled) {
Events.purchaseCancelled(packageType, result.errorCode);
return;
}
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
};
const handlePurchase = async () => {
if (!selectedPackage) return;
if (isAnonymous && !guestConfirmed) {
setShowGuestConfirm(true);
return;
}
await doPurchase();
};
const handleRestore = async () => {
const restored = await restorePurchases();
if (restored) {
Events.purchaseRestored();
onSuccess?.();
handleClose("restore");
}
};
const formatPrice = (pkg: PurchasesPackage) => {
const price = pkg.product.priceString;
@@ -143,12 +161,12 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={() => handleClose("system")}
>
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={() => handleClose("system")}
>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Premium gradient background */}
@@ -169,11 +187,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
contentContainerStyle={styles.scrollContent}
>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={() => handleClose("dismiss")}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<TouchableOpacity
style={styles.closeButton}
onPress={() => handleClose("dismiss")}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
@@ -398,6 +416,59 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
</View>
</View>
</ScrollView>
{showGuestConfirm && (
<View style={styles.guestConfirmOverlay}>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => setShowGuestConfirm(false)}
/>
<View style={styles.guestConfirmCard}>
<View style={styles.guestConfirmHeader}>
<View style={styles.guestConfirmIcon}>
<Ionicons name="shield-checkmark" size={22} color={colors.premium.gold} />
</View>
<TouchableOpacity
onPress={() => setShowGuestConfirm(false)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={styles.guestConfirmClose}
>
<Ionicons name="close" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<Text style={styles.guestConfirmTitle}>Save your account first?</Text>
<Text style={styles.guestConfirmBody}>
Unsaved accounts can't be recovered if you delete the app or switch devices.
Saving your account protects your access to Pro.
</Text>
<View style={styles.guestConfirmActions}>
<Pressable
style={styles.guestConfirmPrimary}
onPress={async () => {
setShowGuestConfirm(false);
handleClose("dismiss");
router.push("/(app)/settings/save-account" as any);
}}
>
<Text style={styles.guestConfirmPrimaryText}>Save account</Text>
</Pressable>
<Pressable
style={styles.guestConfirmSecondary}
onPress={async () => {
setGuestConfirmed(true);
setShowGuestConfirm(false);
await doPurchase();
}}
>
<Text style={styles.guestConfirmSecondaryText}>Continue purchase</Text>
</Pressable>
</View>
</View>
</View>
)}
</View>
</View>
</Modal>
@@ -760,6 +831,86 @@ const styles = StyleSheet.create({
...typography.caption1,
color: colors.textTertiary,
},
guestConfirmOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.7)",
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
guestConfirmCard: {
width: "100%",
maxWidth: 420,
backgroundColor: "rgba(22, 33, 62, 0.98)",
borderRadius: radius.xl,
padding: spacing.lg,
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.2)",
},
guestConfirmHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: spacing.md,
},
guestConfirmIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(212, 175, 55, 0.12)",
justifyContent: "center",
alignItems: "center",
},
guestConfirmClose: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
},
guestConfirmTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.sm,
},
guestConfirmBody: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
marginBottom: spacing.lg,
textAlign: "left",
},
guestConfirmActions: {
gap: spacing.sm,
},
guestConfirmPrimary: {
backgroundColor: colors.premium.gold,
paddingVertical: spacing.md,
borderRadius: radius.lg,
alignItems: "center",
},
guestConfirmPrimaryText: {
...typography.headline,
color: colors.background,
fontWeight: "700",
},
guestConfirmSecondary: {
backgroundColor: "rgba(255, 255, 255, 0.06)",
paddingVertical: spacing.md,
borderRadius: radius.lg,
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
},
guestConfirmSecondaryText: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
},
});
export default Paywall;
@@ -29,7 +29,7 @@ export function ProfileScreen() {
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const { user, partner, fetchUser } = useAuthStore();
const { user, partner, fetchUser, isAnonymous } = useAuthStore();
const { subscription } = useSubscriptionStore();
const settings = useProfileSettings();
@@ -116,7 +116,9 @@ export function ProfileScreen() {
</LinearGradient>
<View style={styles.profileInfo}>
<Text style={styles.userName}>{user?.name || "User"}</Text>
<Text style={styles.userEmail}>{user?.email}</Text>
<Text style={styles.userEmail}>
{isAnonymous ? "Unsaved account" : (user?.email || "")}
</Text>
</View>
<View style={styles.profileChevron}>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
@@ -172,6 +174,17 @@ export function ProfileScreen() {
{/* Other Section */}
<SettingsSection title="Other" delay={350}>
{isAnonymous && (
<>
<MenuItem
icon="shield-checkmark-outline"
label="Save Account"
description="Protect access across devices"
onPress={() => router.push('/(app)/settings/save-account' as any)}
/>
<View style={styles.divider} />
</>
)}
<MenuItem
icon="help-circle-outline"
label="Help & Support"
@@ -1,5 +1,6 @@
import React from 'react';
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
import React from 'react';
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { GradientBackground } from '../../../components/ui';
import { colors, spacing } from '../../../theme';
@@ -10,9 +11,10 @@ import { ScreenHeader, SettingsSection, MenuItem, DangerZone } from '../componen
/**
* Account settings sub-screen (sign out, delete account).
*/
export function AccountScreen() {
const { couple } = useAuthStore();
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
export function AccountScreen() {
const router = useRouter();
const { couple, isAnonymous } = useAuthStore();
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
return (
<GradientBackground>
@@ -22,15 +24,25 @@ export function AccountScreen() {
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<SettingsSection title="Account" delay={0}>
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={false}
/>
</SettingsSection>
<SettingsSection title="Account" delay={0}>
{isAnonymous ? (
<MenuItem
icon="shield-checkmark-outline"
label="Save Account"
description="Protect access across devices"
onPress={() => router.push("/(app)/settings/save-account" as any)}
showChevron={false}
/>
) : (
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={false}
/>
)}
</SettingsSection>
<View style={styles.spacer} />
@@ -0,0 +1,590 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
View,
Text,
ScrollView,
StyleSheet,
Platform,
Alert,
TouchableOpacity,
} from "react-native";
import { useRouter } from "expo-router";
import * as Linking from "expo-linking";
import { Ionicons } from "@expo/vector-icons";
// Safely import platform auth modules
let AppleAuthentication: typeof import("expo-apple-authentication") | null = null;
let GoogleSignin: typeof import("@react-native-google-signin/google-signin").GoogleSignin | null = null;
if (Platform.OS === "ios") {
try {
AppleAuthentication = require("expo-apple-authentication");
} catch {
AppleAuthentication = null;
}
}
if (Platform.OS === "android") {
try {
const googleModule = require("@react-native-google-signin/google-signin");
GoogleSignin = googleModule.GoogleSignin;
if (GoogleSignin) {
GoogleSignin.configure({
webClientId: "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
});
}
} catch {
GoogleSignin = null;
}
}
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
import { colors, spacing, radius, typography } from "../../../theme";
import { supabase } from "../../../lib/supabase";
import { getAuthError } from "../../../lib/errors";
import { useAuthStore } from "../../../store";
import { ScreenHeader } from "../components";
type SaveMode = "magic-link" | "password";
export function SaveAccountScreen() {
const router = useRouter();
const { isAnonymous, fetchUser } = useAuthStore();
const [mode, setMode] = useState<SaveMode>("magic-link");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [isChecking, setIsChecking] = useState(false);
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
const [pendingPassword, setPendingPassword] = useState<string | null>(null);
const [isAppleAvailable, setIsAppleAvailable] = useState(false);
const [isGoogleAvailable, setIsGoogleAvailable] = useState(false);
useEffect(() => {
if (Platform.OS === "ios" && AppleAuthentication) {
AppleAuthentication.isAvailableAsync()
.then(setIsAppleAvailable)
.catch(() => setIsAppleAvailable(false));
}
if (Platform.OS === "android" && GoogleSignin) {
setIsGoogleAvailable(true);
}
}, []);
const emailRedirectTo = useMemo(() => Linking.createURL("/(auth)/login"), []);
const showError = useCallback((message: string) => {
Alert.alert("Error", message);
}, []);
const showSuccess = useCallback((message: string) => {
Alert.alert("Success", message);
}, []);
const syncProfileEmail = useCallback(async (userId: string, nextEmail: string | null | undefined) => {
if (!nextEmail) return;
try {
await supabase
.from("profiles")
.update({ email: nextEmail })
.eq("id", userId);
} catch {
// Non-blocking (some envs may restrict this by RLS)
}
}, []);
const handleSaveWithEmail = useCallback(async () => {
if (!email.trim()) {
showError("Please enter your email");
return;
}
if (mode === "password" && password.length < 8) {
showError("Password must be at least 8 characters");
return;
}
setIsSubmitting(true);
const { error } = await supabase.auth.updateUser(
{ email: email.trim() },
{ emailRedirectTo } as any
);
setIsSubmitting(false);
if (error) {
const friendly = getAuthError(error);
if (__DEV__ && friendly === "Something went wrong. Please try again.") {
showError(`${friendly}\n\n${error.message}`);
} else {
showError(friendly);
}
return;
}
setPendingEmail(email.trim());
setPendingPassword(mode === "password" ? password : null);
Alert.alert(
"Check your email",
"We sent you a link to confirm your email. Once you confirm, come back and tap “I’ve verified my email”."
);
}, [email, password, mode, emailRedirectTo, showError]);
const handleCheckVerification = useCallback(async () => {
setIsChecking(true);
const { data: { user }, error } = await supabase.auth.getUser();
setIsChecking(false);
if (error || !user) {
showError(getAuthError(error) || "Unable to check account status");
return;
}
const isStillAnonymous = !!(user as any).is_anonymous;
if (isStillAnonymous) {
Alert.alert("Not yet verified", "Please click the link in your email to finish saving your account.");
return;
}
// If user chose password mode, set password after email is verified
if (pendingPassword) {
setIsSubmitting(true);
const { error: passwordError } = await supabase.auth.updateUser({ password: pendingPassword });
setIsSubmitting(false);
if (passwordError) {
showError(getAuthError(passwordError));
return;
}
}
await syncProfileEmail(user.id, user.email);
await fetchUser();
showSuccess("Account saved. You can now recover your account on any device.");
router.navigate("/(app)/profile" as any);
}, [pendingPassword, fetchUser, showError, showSuccess, router, syncProfileEmail]);
const handleLinkApple = useCallback(async () => {
if (!AppleAuthentication) return;
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
if (!credential.identityToken) {
showError("No identity token received from Apple");
return;
}
setIsSubmitting(true);
const { error } = await (supabase.auth.linkIdentity as any)({
provider: "apple",
token: credential.identityToken,
});
setIsSubmitting(false);
if (error) {
showError(getAuthError(error));
return;
}
// Apple only provides name/email on first auth grant
if (credential.fullName) {
const nameParts = [
credential.fullName.givenName,
credential.fullName.middleName,
credential.fullName.familyName,
].filter(Boolean);
const fullName = nameParts.join(" ");
if (fullName) {
await supabase.auth.updateUser({
data: {
full_name: fullName,
given_name: credential.fullName.givenName,
family_name: credential.fullName.familyName,
},
});
}
}
// Refresh user/profile
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await syncProfileEmail(user.id, user.email);
}
await fetchUser();
showSuccess("Account saved with Apple.");
router.navigate("/(app)/profile" as any);
} catch (e: any) {
if (e?.code === "ERR_REQUEST_CANCELED") return;
showError("Apple sign in failed. Please try again.");
}
}, [fetchUser, router, showError, showSuccess, syncProfileEmail]);
const handleLinkGoogle = useCallback(async () => {
if (!GoogleSignin) return;
try {
await GoogleSignin.hasPlayServices();
const response = await GoogleSignin.signIn();
if (response.type !== "success" || !response.data.idToken) {
return;
}
setIsSubmitting(true);
const { error } = await (supabase.auth.linkIdentity as any)({
provider: "google",
token: response.data.idToken,
});
setIsSubmitting(false);
if (error) {
showError(getAuthError(error));
return;
}
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await syncProfileEmail(user.id, user.email);
}
await fetchUser();
showSuccess("Account saved with Google.");
router.navigate("/(app)/profile" as any);
} catch {
showError("Google sign in failed. Please try again.");
}
}, [fetchUser, router, showError, showSuccess, syncProfileEmail]);
const showSocial = Platform.select({ ios: true, android: true, default: false });
if (!isAnonymous) {
return (
<GradientBackground>
<ScreenHeader title="Save Account" />
<View style={styles.centered}>
<GlassCard variant="elevated" style={styles.readyCard}>
<Ionicons name="checkmark-circle" size={36} color={colors.success} />
<Text style={styles.readyTitle}>Your account is already saved</Text>
<Text style={styles.readyBody}>
You're signed in with a recoverable account.
</Text>
<GlassButton onPress={() => router.navigate("/(app)/profile" as any)}>
Back to Settings
</GlassButton>
</GlassCard>
</View>
</GradientBackground>
);
}
if (pendingEmail) {
return (
<GradientBackground>
<ScreenHeader title="Save Account" />
<View style={styles.centered}>
<GlassCard variant="elevated" style={styles.verifyCard}>
<View style={styles.verifyIcon}>
<Ionicons name="mail-unread" size={36} color={colors.primary} />
</View>
<Text style={styles.verifyTitle}>Confirm your email</Text>
<Text style={styles.verifyBody}>
We sent a confirmation link to {pendingEmail}. Tap it, then come back here.
</Text>
<GlassButton
onPress={handleCheckVerification}
loading={isChecking || isSubmitting}
disabled={isChecking || isSubmitting}
fullWidth
>
I've verified my email
</GlassButton>
<GlassButton
variant="secondary"
onPress={handleSaveWithEmail}
loading={isSubmitting}
disabled={isSubmitting}
fullWidth
style={{ marginTop: spacing.sm }}
>
Resend email
</GlassButton>
<GlassButton
variant="ghost"
onPress={() => {
setPendingEmail(null);
setPendingPassword(null);
}}
fullWidth
style={{ marginTop: spacing.sm }}
>
Use a different email
</GlassButton>
</GlassCard>
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
<ScreenHeader title="Save Account" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<GlassCard variant="elevated" style={styles.heroCard}>
<Text style={styles.heroTitle}>Protect your account</Text>
<Text style={styles.heroBody}>
Unsaved accounts can't be recovered if you delete the app or switch devices.
Save your account to keep your couple, matches, and purchases.
</Text>
</GlassCard>
{showSocial && (
<GlassCard style={styles.sectionCard}>
<Text style={styles.sectionTitle}>Quick save</Text>
{Platform.OS === "ios" && (
<View style={styles.socialRow}>
{isAppleAvailable && AppleAuthentication ? (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE}
cornerRadius={12}
style={styles.appleButton}
onPress={handleLinkApple}
/>
) : (
<GlassButton
variant="secondary"
onPress={handleLinkApple}
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
fullWidth
disabled={isSubmitting}
>
Save with Apple
</GlassButton>
)}
</View>
)}
{Platform.OS === "android" && isGoogleAvailable && (
<GlassButton
variant="secondary"
onPress={handleLinkGoogle}
icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
fullWidth
disabled={isSubmitting}
>
Save with Google
</GlassButton>
)}
<Text style={styles.sectionHint}>
Use an account that isn't already linked to Sauci.
</Text>
</GlassCard>
)}
<GlassCard style={styles.sectionCard}>
<Text style={styles.sectionTitle}>Save with email</Text>
<View style={styles.modeToggleContainer}>
<TouchableOpacity
style={[styles.modeToggle, mode === "magic-link" && styles.modeToggleActive]}
onPress={() => setMode("magic-link")}
activeOpacity={0.7}
>
<Text style={[styles.modeToggleText, mode === "magic-link" && styles.modeToggleTextActive]}>
Magic Link
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modeToggle, mode === "password" && styles.modeToggleActive]}
onPress={() => setMode("password")}
activeOpacity={0.7}
>
<Text style={[styles.modeToggleText, mode === "password" && styles.modeToggleTextActive]}>
Password
</Text>
</TouchableOpacity>
</View>
<View style={styles.form}>
<GlassInput
placeholder="Enter your email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
icon={<Ionicons name="mail-outline" size={20} color={colors.textTertiary} />}
/>
{mode === "password" && (
<GlassInput
placeholder="Create a password"
value={password}
onChangeText={setPassword}
secureTextEntry
autoCapitalize="none"
icon={<Ionicons name="lock-closed-outline" size={20} color={colors.textTertiary} />}
/>
)}
<GlassButton
onPress={handleSaveWithEmail}
loading={isSubmitting}
disabled={isSubmitting}
fullWidth
>
Save with Email
</GlassButton>
</View>
<Text style={styles.sectionHint}>
We'll send a confirmation link to your email.
</Text>
</GlassCard>
<View style={{ height: spacing.xl }} />
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === "ios" ? 40 : 20,
gap: spacing.lg,
},
heroCard: {
marginHorizontal: spacing.lg,
},
heroTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.sm,
},
heroBody: {
...typography.body,
color: colors.textSecondary,
lineHeight: 22,
},
sectionCard: {
marginHorizontal: spacing.lg,
},
sectionTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.md,
},
sectionHint: {
...typography.caption1,
color: colors.textTertiary,
marginTop: spacing.md,
},
socialRow: {
marginBottom: spacing.sm,
},
appleButton: {
width: "100%",
height: 48,
},
modeToggleContainer: {
flexDirection: "row",
backgroundColor: colors.glass.background,
borderRadius: radius.md,
padding: 4,
marginBottom: spacing.md,
},
modeToggle: {
flex: 1,
paddingVertical: 12,
borderRadius: radius.sm,
alignItems: "center",
},
modeToggleActive: {
backgroundColor: colors.primary,
},
modeToggleText: {
...typography.subhead,
color: colors.textTertiary,
fontWeight: "600",
},
modeToggleTextActive: {
color: colors.text,
},
form: {
gap: spacing.sm,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
readyCard: {
width: "100%",
maxWidth: 420,
alignItems: "center",
gap: spacing.sm,
},
readyTitle: {
...typography.title3,
color: colors.text,
marginTop: spacing.sm,
},
readyBody: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.md,
},
verifyCard: {
width: "100%",
maxWidth: 420,
alignItems: "center",
padding: spacing.lg,
},
verifyIcon: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
verifyTitle: {
...typography.title3,
color: colors.text,
marginBottom: spacing.sm,
},
verifyBody: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 22,
marginBottom: spacing.lg,
},
});
export default SaveAccountScreen;
@@ -5,4 +5,5 @@ export { PreferencesScreen } from './PreferencesScreen';
export { SubscriptionScreen } from './SubscriptionScreen';
export { PartnerScreen } from './PartnerScreen';
export { SupportScreen } from './SupportScreen';
export { AccountScreen } from './AccountScreen';
export { AccountScreen } from './AccountScreen';
export { SaveAccountScreen } from './SaveAccountScreen';
+21 -17
View File
@@ -10,23 +10,27 @@ interface ErrorMapping {
message: string;
}
const AUTH_ERROR_MAPPINGS: ErrorMapping[] = [
{ pattern: /invalid login credentials/i, message: "Incorrect email or password. Please try again." },
{ pattern: /email not confirmed/i, message: "Please verify your email before signing in." },
{ pattern: /user already registered/i, message: "An account with this email already exists." },
{ pattern: /invalid email/i, message: "Please enter a valid email address." },
{ pattern: /password.*too short/i, message: "Password must be at least 8 characters." },
{ pattern: /password.*too weak/i, message: "Please choose a stronger password." },
{ pattern: /rate limit/i, message: "Too many attempts. Please wait a moment and try again." },
{ pattern: /email rate limit/i, message: "Too many emails sent. Please wait before requesting another." },
{ pattern: /same as/i, message: "New password must be different from your current password." },
{ pattern: /session.*expired/i, message: "Your session has expired. Please log in again." },
{ pattern: /invalid.*token/i, message: "This link has expired. Please request a new one." },
{ pattern: /otp.*expired/i, message: "This code has expired. Please request a new one." },
{ pattern: /signups.*disabled/i, message: "Sign ups are currently disabled. Please try again later." },
{ pattern: /user not found/i, message: "No account found with this email." },
{ pattern: /provider.*disabled/i, message: "This sign-in method is currently unavailable." },
];
const AUTH_ERROR_MAPPINGS: ErrorMapping[] = [
{ pattern: /invalid login credentials/i, message: "Incorrect email or password. Please try again." },
{ pattern: /email not confirmed/i, message: "Please verify your email before signing in." },
{ pattern: /user already registered/i, message: "An account with this email already exists." },
{ pattern: /email.*already.*(registered|exists|in use|used)/i, message: "This email is already in use. Please use a different one." },
{ pattern: /duplicate key value violates unique constraint.*users_email_key/i, message: "This email is already in use. Please use a different one." },
{ pattern: /invalid email/i, message: "Please enter a valid email address." },
{ pattern: /password.*too short/i, message: "Password must be at least 8 characters." },
{ pattern: /password.*too weak/i, message: "Please choose a stronger password." },
{ pattern: /rate limit/i, message: "Too many attempts. Please wait a moment and try again." },
{ pattern: /email rate limit/i, message: "Too many emails sent. Please wait before requesting another." },
{ pattern: /same as/i, message: "New password must be different from your current password." },
{ pattern: /session.*expired/i, message: "Your session has expired. Please log in again." },
{ pattern: /invalid.*token/i, message: "This link has expired. Please request a new one." },
{ pattern: /otp.*expired/i, message: "This code has expired. Please request a new one." },
{ pattern: /signups.*disabled/i, message: "Sign ups are currently disabled. Please try again later." },
{ pattern: /user not found/i, message: "No account found with this email." },
{ pattern: /provider.*disabled/i, message: "This sign-in method is currently unavailable." },
{ pattern: /manual.*link/i, message: "Saving accounts is temporarily unavailable. Please try again later." },
{ pattern: /identity.*link/i, message: "Saving accounts is temporarily unavailable. Please try again later." },
];
const PAIRING_ERROR_MAPPINGS: ErrorMapping[] = [
{ pattern: /invalid.*invite.*code/i, message: "This invite code is invalid. Please check and try again." },
@@ -0,0 +1,47 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY = "guest_account_recovery_warning_seen";
async function getStorage(): Promise<boolean> {
try {
let data: string | null = null;
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) {
console.error("Error reading guest warning state:", error);
}
return false;
}
async function setStorage(seen: boolean): Promise<void> {
try {
const data = 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) {
console.error("Error saving guest warning state:", error);
}
}
export async function hasSeenGuestAccountWarning(): Promise<boolean> {
return getStorage();
}
export async function markGuestAccountWarningSeen(): Promise<void> {
await setStorage(true);
}
+40 -32
View File
@@ -4,12 +4,14 @@ import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
interface AuthState {
user: Profile | null;
couple: Couple | null;
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
// Actions
fetchUser: () => Promise<void>;
@@ -36,18 +38,19 @@ export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
isLoading: true,
isAuthenticated: false,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
set({ user: null, isAuthenticated: false, isLoading: false });
return;
}
if (!session?.user) {
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
return;
}
// Validate the session by fetching the user from the server
// This will fail if the session was deleted server-side
@@ -56,8 +59,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (authError || !authUser) {
console.log("[Auth] Session invalid, signing out:", authError?.message);
// Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
// Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
@@ -77,10 +80,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
.eq("id", authUser.id)
.maybeSingle();
set({
user: profile,
isAuthenticated: true,
});
const isAnonymous = !!(authUser as any).is_anonymous;
set({
user: profile,
isAuthenticated: true,
isAnonymous,
});
// If user has a couple, fetch couple data; otherwise clear couple/partner
if (profile?.couple_id) {
@@ -142,13 +148,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false,
isLoading: false,
});
set({
user: null,
couple: null,
partner: null,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
@@ -171,13 +178,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
},
setUser: (user) => {
set({
user,
isAuthenticated: !!user,
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
});
set({
user,
isAuthenticated: !!user,
...(user === null && { isAnonymous: false }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();