mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
ce68d57a49
commit
349f460e15
14 files changed
+1236
-176
No files matched your search
@@ -539,10 +539,19 @@ export function UserDetailPage() {
|
||||
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
||||
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
|
||||
<div className="text-muted-foreground mb-4 flex items-center gap-2">
|
||||
{profile.email ? (
|
||||
<span>{profile.email}</span>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">Guest</Badge>
|
||||
)}
|
||||
{!profile.email && (
|
||||
<span className="text-xs">Not recoverable unless saved in-app</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
||||
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : <div className="text-muted-foreground">Not paired</div>}
|
||||
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : profile.couple_id ? <div className="text-amber-600 dark:text-amber-400 flex items-center gap-1"><Users className="h-4 w-4" />Waiting for partner</div> : <div className="text-muted-foreground">Not paired</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div></CardContent></Card>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning } from 'lucide-react';
|
||||
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning, Hourglass } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Profile {
|
||||
@@ -32,6 +32,7 @@ interface Profile {
|
||||
email_confirmed_at: string | null;
|
||||
storage_bytes?: number;
|
||||
partner_is_premium?: boolean;
|
||||
partner_count?: number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -87,11 +88,13 @@ export function UsersPage() {
|
||||
}
|
||||
});
|
||||
|
||||
// Merge storage data and partner premium status with profiles
|
||||
// Merge storage data, partner premium status, and partner count with profiles
|
||||
const profilesWithExtras = profilesData.map(profile => {
|
||||
// Check if partner has premium
|
||||
// Check if partner has premium and count partners in couple
|
||||
let partnerIsPremium = false;
|
||||
let partnerCount = 0;
|
||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||
partnerCount = couplePartners[profile.couple_id].length;
|
||||
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
||||
partnerIsPremium = partner?.is_premium || false;
|
||||
}
|
||||
@@ -99,6 +102,7 @@ export function UsersPage() {
|
||||
...profile,
|
||||
storage_bytes: storageMap[profile.id] || 0,
|
||||
partner_is_premium: partnerIsPremium,
|
||||
partner_count: partnerCount,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -126,9 +130,12 @@ export function UsersPage() {
|
||||
setProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
||||
setFilteredProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
||||
}, []),
|
||||
insertToast: {
|
||||
insertToast: {
|
||||
enabled: true,
|
||||
message: (payload) => `New user registered: ${payload.name || payload.email || 'Unknown'}`,
|
||||
message: (payload) => {
|
||||
const label = payload.name || payload.email || 'Guest';
|
||||
return payload.email ? `New user registered: ${label}` : `New guest user: ${label}`;
|
||||
},
|
||||
type: 'success',
|
||||
},
|
||||
debounceMs: 1000, // Debounce to avoid excessive refetches
|
||||
@@ -191,7 +198,15 @@ export function UsersPage() {
|
||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
|
||||
{profiles.length} account{profiles.length !== 1 ? 's' : ''}
|
||||
{(() => {
|
||||
const guestCount = profiles.filter(p => !p.email).length;
|
||||
const savedCount = profiles.length - guestCount;
|
||||
if (guestCount === 0) return null;
|
||||
return (
|
||||
<span> • {savedCount} saved • {guestCount} guest</span>
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
@@ -245,11 +260,19 @@ export function UsersPage() {
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{profile.email || '—'}
|
||||
{profile.email && !profile.email_confirmed_at && (
|
||||
<span title="Email not verified">
|
||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
||||
</span>
|
||||
{profile.email ? (
|
||||
<>
|
||||
{profile.email}
|
||||
{!profile.email_confirmed_at && (
|
||||
<span title="Email not verified">
|
||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||
Guest
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -272,10 +295,17 @@ export function UsersPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{profile.couple_id ? (
|
||||
<Badge variant="secondary">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Paired
|
||||
</Badge>
|
||||
profile.partner_count && profile.partner_count >= 2 ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||
<Users className="h-3 w-3 mr-1" />
|
||||
Paired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||
<Hourglass className="h-3 w-3 mr-1" />
|
||||
Pairing
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { SaveAccountScreen } from "../../../src/features/profile/screens";
|
||||
|
||||
export default SaveAccountScreen;
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user