Merge pull request #28 from jjbrunton/staging

Promote staging to main: Apple review onboarding fixes and v1.0.8 build 49
This commit is contained in:
jjbrunton authored and GitHub committed 2026-09-01 16:16:37 +01:00
commit c776859183
24 files changed
+643 -244

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@
"group.com.sauci.app"
]
},
"buildNumber": "47",
"buildNumber": "49",
"usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": {
+26 -184
View File
@@ -9,14 +9,12 @@ import {
Platform,
Keyboard,
ScrollView,
Image,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
FadeIn,
FadeInUp,
FadeOut,
SlideInRight,
SlideOutLeft,
@@ -32,8 +30,8 @@ import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics';
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage, type OnboardingStage } from '../../src/lib/onboardingStages';
import type { Gender, UsageReason } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
@@ -51,15 +49,17 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
];
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications';
type Stage = OnboardingStage;
export default function OnboardingScreen() {
const router = useRouter();
// Only subscribe to stable values to prevent re-renders from background fetchUser calls
const userId = useAuthStore((s) => s.user?.id);
const persistedUserName = useAuthStore((s) => s.user?.name);
const pendingAppleDisplayName = useAuthStore((s) => s.pendingAppleDisplayName);
const fetchUser = useAuthStore((s) => s.fetchUser);
const initialUser = useRef(useAuthStore.getState().user);
const [stage, setStage] = useState<Stage>('avatar');
const [stage, setStage] = useState<Stage>(() => initialOnboardingStage(initialUser.current?.name));
const [name, setName] = useState(initialUser.current?.name || '');
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
@@ -69,36 +69,16 @@ export default function OnboardingScreen() {
const inputRef = useRef<TextInput>(null);
const paywallNavigationRef = useRef(false);
const {
avatarUri,
isUploading: isUploadingAvatar,
showPicker: handleAvatarPress,
uploadAvatar,
} = useAvatarPicker({
userId,
onSelect: () => setError(null),
});
const pendingDisplayName = pendingAppleDisplayName?.userId === userId ? pendingAppleDisplayName?.name : undefined;
const displayName = persistedUserName?.trim() || pendingDisplayName || name;
const hasStoredDisplayName = Boolean(persistedUserName?.trim() || pendingDisplayName);
const activeStage = activeOnboardingStage(stage, persistedUserName, pendingDisplayName);
const previousStage = previousOnboardingStage(activeStage, hasStoredDisplayName);
useEffect(() => {
Events.onboardingStart();
}, []);
const handleAvatarContinue = () => {
if (!avatarUri) {
setError('Please add a profile photo to continue');
return;
}
setStage('name');
Events.onboardingStageComplete('avatar');
};
const handleAvatarSkip = () => {
setError(null);
setStage('name');
Events.avatarSkipped();
Events.onboardingStageComplete('avatar');
};
const handleNameSubmit = () => {
if (!name.trim()) {
setError('Please enter your name');
@@ -154,7 +134,7 @@ export default function OnboardingScreen() {
};
const handleComplete = async () => {
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', name);
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', displayName);
if (!userId) {
console.error('[Onboarding] No user ID available');
setError('Not logged in. Please restart the app.');
@@ -165,19 +145,10 @@ export default function OnboardingScreen() {
setError(null);
try {
// Upload avatar if one was selected
let avatarUrl: string | null = null;
if (avatarUri) {
avatarUrl = await uploadAvatar();
if (avatarUrl) {
Events.avatarUploaded();
}
}
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = {
name: name.trim(),
name: displayName.trim(),
gender,
usage_reason: usageReason,
// Preserve the safest legacy profile values. Catalogue
@@ -191,10 +162,6 @@ export default function OnboardingScreen() {
await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) {
updatedFields.push("avatar_url");
}
console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser();
console.log('[Onboarding] User fetched, navigating to home...');
@@ -231,71 +198,7 @@ export default function OnboardingScreen() {
};
const renderStage = () => {
switch (stage) {
case 'avatar':
return (
<Animated.View
key="avatar"
entering={FadeInUp.duration(500)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Welcome to Sauci</Text>
<Text style={styles.subtitle}>
Add a profile photo so your partner knows it's you
</Text>
</View>
<GlassCard style={styles.card}>
<View style={styles.avatarSection}>
<Pressable onPress={handleAvatarPress} style={styles.avatarTouchable}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : (
<View
style={styles.avatarPlaceholder}
>
<Ionicons name="camera" size={40} color={colors.textSecondary} />
</View>
)}
<View style={styles.avatarBadge}>
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
</View>
</Pressable>
<Text style={styles.avatarHint}>
{avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
</Text>
</View>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
Continue
</GlassButton>
<Pressable
style={styles.skipButton}
onPress={handleAvatarSkip}
testID="avatar-skip-button"
>
<Text style={styles.skipText}>Skip for now</Text>
</Pressable>
</View>
</Animated.View>
);
switch (activeStage) {
case 'name':
return (
<Animated.View
@@ -305,16 +208,12 @@ export default function OnboardingScreen() {
style={styles.stageContainer}
>
<View style={styles.header}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} />
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="person" size={40} color={colors.text} />
</LinearGradient>
)}
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="person" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>What can we call you?</Text>
<Text style={styles.subtitle}>
Nicknames and pet names are welcome too!
@@ -368,7 +267,7 @@ export default function OnboardingScreen() {
style={styles.stageContainer}
>
<View style={styles.header}>
<Text style={styles.greeting}>Hey, {name.trim()}!</Text>
<Text style={styles.greeting}>Hey, {displayName.trim()}!</Text>
<Text style={styles.subtitle}>
This helps us show you the right questions
</Text>
@@ -576,15 +475,12 @@ export default function OnboardingScreen() {
>
{/* Header with back button and progress dots */}
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
{stage !== 'avatar' ? (
{previousStage ? (
<Pressable
style={styles.backButton}
onPress={() => {
setError(null);
if (stage === 'notifications') setStage('purpose');
else if (stage === 'purpose') setStage('gender');
else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar');
setStage(previousStage);
}}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
@@ -593,11 +489,10 @@ export default function OnboardingScreen() {
<View style={styles.backButtonPlaceholder} />
)}
<View style={styles.progressContainer}>
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
<View style={[styles.progressDot, activeStage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, activeStage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, activeStage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, activeStage === 'notifications' && styles.progressDotActive]} />
</View>
<View style={styles.backButtonPlaceholder} />
</Animated.View>
@@ -653,18 +548,6 @@ const styles = StyleSheet.create({
backgroundColor: colors.border,
},
// ...
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.border,
borderStyle: 'dashed',
backgroundColor: colors.backgroundLight,
},
// ...
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
@@ -757,47 +640,6 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg,
...shadows.lg,
},
headerAvatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: spacing.lg,
borderWidth: 2,
borderColor: colors.primary,
},
avatarSection: {
alignItems: 'center',
paddingVertical: spacing.lg,
},
avatarTouchable: {
position: 'relative',
},
avatarImage: {
width: 120,
height: 120,
borderRadius: 60,
borderWidth: 3,
borderColor: colors.primary,
},
avatarBadge: {
position: 'absolute',
bottom: 4,
right: 4,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.background,
},
avatarHint: {
...typography.caption1,
color: colors.textSecondary,
marginTop: spacing.md,
},
title: {
...typography.largeTitle,
color: colors.text,
+36 -1
View File
@@ -46,6 +46,11 @@ if (Platform.OS === "android") {
import { authClient } from "../../src/lib/authClient";
import { getAuthError } from "../../src/lib/errors";
import { Events } from "../../src/lib/analytics";
import { apiRequestWithAccessToken } from "../../src/lib/apiClient";
import { formatAppleProfileName } from "../../src/lib/appleProfileName";
import { persistAppleProfileName } from "../../src/lib/appleProfileNamePersistence";
import { profileSettingsApi } from "../../src/lib/profileSettingsApi";
import type { Profile } from "../../src/types";
import { useAuthStore } from "../../src/store";
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
import { colors, spacing, radius, typography } from "../../src/theme";
@@ -290,7 +295,7 @@ export default function LoginScreen() {
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
if (credential.identityToken) {
const { error } = await authClient.auth.signInWithIdToken({
const { data, error } = await authClient.auth.signInWithIdToken({
provider: "apple",
token: credential.identityToken,
});
@@ -299,6 +304,36 @@ export default function LoginScreen() {
console.log("[Apple Sign In] Supabase error:", error.message);
Alert.alert("Error", getAuthError(error));
} else {
const appleProfileName = formatAppleProfileName(credential.fullName);
const userId = data.user?.id;
const accessToken = data.session?.access_token;
if (appleProfileName && userId && accessToken) {
const authStore = useAuthStore.getState();
authStore.setPendingAppleDisplayName({ userId, name: appleProfileName });
try {
await persistAppleProfileName(
{ userId, accessToken },
appleProfileName,
{
getProfile: (token) => apiRequestWithAccessToken<{ profile: Profile }>("/v1/me", token),
updateProfileName: (token, name) => profileSettingsApi.updateProfileWithAccessToken(token, { name }),
},
);
await authStore.fetchUser({
userId,
accessToken,
isAnonymous: !!(data.user as { is_anonymous?: boolean } | null)?.is_anonymous,
});
} catch {
// The account session remains valid. Clear the subject-keyed
// temporary name so onboarding safely falls back to its name step.
console.warn("[Apple Sign In] Could not persist supplied name");
} finally {
useAuthStore.getState().clearPendingAppleDisplayName(userId);
}
} else if (appleProfileName) {
Alert.alert("Error", "Apple sign in did not return an authenticated session. Please try again.");
}
Events.signIn("apple");
}
} else {
@@ -844,7 +844,7 @@
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 47;
CURRENT_PROJECT_VERSION = 49;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -885,7 +885,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 47;
CURRENT_PROJECT_VERSION = 49;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11;
+1 -1
View File
@@ -47,7 +47,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>47</string>
<string>49</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/>
<key>ITSAppUsesNonExemptEncryption</key>
@@ -1,14 +1,19 @@
import { act, renderHook } from '@testing-library/react-native';
import { ActionSheetIOS, Alert, Platform } from 'react-native';
import { ActionSheetIOS, Alert, Linking, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system/legacy';
import * as ImagePicker from 'expo-image-picker';
import { useAvatarPicker } from '@/hooks/useAvatarPicker';
jest.mock('@/lib/mediaApi', () => ({
uploadMedia: jest.fn(),
}));
jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn(),
}));
jest.mock('expo-image-picker', () => ({
getCameraPermissionsAsync: jest.fn(),
requestCameraPermissionsAsync: jest.fn(),
requestMediaLibraryPermissionsAsync: jest.fn(),
launchCameraAsync: jest.fn(),
@@ -23,7 +28,10 @@ describe('useAvatarPicker', () => {
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
jest.spyOn(Linking, 'openSettings').mockResolvedValue(undefined);
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] });
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'undetermined', canAskAgain: true });
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValue({ status: 'granted' });
(FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
});
@@ -64,4 +72,41 @@ describe('useAvatarPicker', () => {
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledTimes(1);
});
it('does not show a custom alert immediately after a camera permission denial', async () => {
const { result } = renderHook(() => useAvatarPicker());
(ImagePicker.requestCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
act(() => result.current.showPicker());
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
(option: { text: string }) => option.text === 'Take Photo'
);
await act(async () => takePhoto.onPress());
expect(Alert.alert).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchCameraAsync).not.toHaveBeenCalled();
});
it('offers Settings only for a later camera attempt that iOS cannot prompt again', async () => {
(ImagePicker.getCameraPermissionsAsync as jest.Mock).mockResolvedValueOnce({ status: 'denied', canAskAgain: false });
const { result } = renderHook(() => useAvatarPicker());
act(() => result.current.showPicker());
const takePhoto = (Alert.alert as jest.Mock).mock.calls[0][2].find(
(option: { text: string }) => option.text === 'Take Photo'
);
await act(async () => takePhoto.onPress());
const settingsAlert = (Alert.alert as jest.Mock).mock.calls[1];
expect(settingsAlert[0]).toBe('Camera Access');
expect(settingsAlert[2]).toEqual([
{ text: 'Not Now', style: 'cancel' },
expect.objectContaining({ text: 'Open Settings' }),
]);
await act(async () => settingsAlert[2][1].onPress());
expect(Linking.openSettings).toHaveBeenCalledTimes(1);
expect(ImagePicker.requestCameraPermissionsAsync).not.toHaveBeenCalled();
});
});
@@ -1,4 +1,4 @@
import { ApiError, apiRequest } from "@/lib/apiClient";
import { ApiError, apiRequest, apiRequestWithAccessToken } from "@/lib/apiClient";
import { authClient } from "@/lib/authClient";
function response(status: number, body?: unknown) {
@@ -40,6 +40,17 @@ describe("apiClient", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("uses an explicitly captured bearer without reading a mutable auth session", async () => {
fetchMock.mockResolvedValue(response(200, { profile: { id: "user-1" } }));
(authClient.auth.getSession as jest.Mock).mockClear();
await apiRequestWithAccessToken("/v1/me", "captured-token");
const headers = fetchMock.mock.calls[0][1].headers as Headers;
expect(headers.get("Authorization")).toBe("Bearer captured-token");
expect(authClient.auth.getSession).not.toHaveBeenCalled();
});
it("preserves API status and error details", async () => {
fetchMock.mockResolvedValue(response(403, {
error: { message: "Couple access denied", code: "FORBIDDEN" },
@@ -0,0 +1,21 @@
import { formatAppleProfileName } from '@/lib/appleProfileName';
describe('formatAppleProfileName', () => {
it('combines Apple name parts into the profile display name', () => {
expect(formatAppleProfileName({
givenName: ' Ada ',
middleName: ' Lovelace',
familyName: ' Byron ',
})).toBe('Ada Lovelace Byron');
});
it('returns null when Apple has not supplied a usable name', () => {
expect(formatAppleProfileName({ givenName: ' \n\t ' })).toBeNull();
expect(formatAppleProfileName(null)).toBeNull();
});
it('removes control characters and stays within the profile contract limit', () => {
expect(formatAppleProfileName({ givenName: 'Ada\u0000 Lovelace' })).toBe('Ada Lovelace');
expect(formatAppleProfileName({ givenName: 'a'.repeat(101) })).toHaveLength(100);
});
});
@@ -0,0 +1,45 @@
import { persistAppleProfileName } from '@/lib/appleProfileNamePersistence';
describe('persistAppleProfileName', () => {
const session = { userId: 'apple-user', accessToken: 'apple-token' };
const getProfile = jest.fn();
const updateProfileName = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('writes a supplied name only after a matching blank profile is read with the captured bearer', async () => {
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: null } });
updateProfileName.mockResolvedValue({ updated: true });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.resolves.toBe('updated');
expect(getProfile).toHaveBeenCalledWith('apple-token');
expect(updateProfileName).toHaveBeenCalledWith('apple-token', 'Ada Lovelace');
});
it('preserves a returning user nickname', async () => {
getProfile.mockResolvedValue({ profile: { id: 'apple-user', name: 'Ada B' } });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.resolves.toBe('existing');
expect(updateProfileName).not.toHaveBeenCalled();
});
it('fails closed on a subject mismatch before any profile write', async () => {
getProfile.mockResolvedValue({ profile: { id: 'different-user', name: null } });
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.rejects.toThrow('subject did not match');
expect(updateProfileName).not.toHaveBeenCalled();
});
it('propagates API failure so the caller can clear its pending onboarding name', async () => {
getProfile.mockRejectedValue(new Error('offline'));
await expect(persistAppleProfileName(session, 'Ada Lovelace', { getProfile, updateProfileName }))
.rejects.toThrow('offline');
expect(updateProfileName).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
import { activeOnboardingStage, initialOnboardingStage, previousOnboardingStage } from '@/lib/onboardingStages';
describe('initialOnboardingStage', () => {
it('starts at gender when a profile already has an Apple-supplied name', () => {
expect(initialOnboardingStage('Ada Lovelace')).toBe('gender');
});
it('starts at name as a fallback when no name is available', () => {
expect(initialOnboardingStage(' ')).toBe('name');
expect(initialOnboardingStage(null)).toBe('name');
});
it('does not provide a back destination before the first reachable stage', () => {
expect(previousOnboardingStage('name', false)).toBeNull();
expect(previousOnboardingStage('gender', true)).toBeNull();
});
it('allows backwards navigation only through reachable stages', () => {
expect(previousOnboardingStage('gender', false)).toBe('name');
expect(previousOnboardingStage('purpose', true)).toBe('gender');
expect(previousOnboardingStage('notifications', false)).toBe('purpose');
});
it('keeps first-auth onboarding out of the name screen only while its subject-keyed name is pending', () => {
expect(activeOnboardingStage('name', null, 'Ada Lovelace')).toBe('gender');
expect(activeOnboardingStage('name', null, undefined)).toBe('name');
});
});
@@ -1,4 +1,4 @@
import { apiClient } from '../../lib/apiClient';
import { apiClient, apiRequestWithAccessToken } from '../../lib/apiClient';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
jest.mock('../../lib/apiClient', () => ({
@@ -7,6 +7,7 @@ jest.mock('../../lib/apiClient', () => ({
post: jest.fn(),
patch: jest.fn(),
},
apiRequestWithAccessToken: jest.fn(),
}));
describe('profileSettingsApi', () => {
@@ -27,4 +28,14 @@ describe('profileSettingsApi', () => {
type: 'bug', title: 'Title', description: 'Details',
});
});
it('can bind a profile update to a captured authentication bearer', async () => {
await profileSettingsApi.updateProfileWithAccessToken('captured-token', { name: 'Ada' });
expect(apiRequestWithAccessToken).toHaveBeenCalledWith(
'/v1/me/profile',
'captured-token',
{ method: 'PATCH', body: { name: 'Ada' } },
);
});
});
@@ -30,10 +30,14 @@ const profile = {
describe("authStore", () => {
beforeEach(() => {
jest.clearAllMocks();
(authClient.auth.getSession as jest.Mock).mockResolvedValue({
data: { session: { user: { id: "me" }, access_token: "token-me" } },
});
// fetchUser always fetches couple state now, since it also carries the
// sealed answer count for a user who has no couple yet. Individual tests
// override this when they care about the couple/partner shape.
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
jest.spyOn(coupleApi, "getStateWithAccessToken").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
useAuthStore.setState({
user: null,
couple: null,
@@ -42,6 +46,7 @@ describe("authStore", () => {
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
pendingAppleDisplayName: null,
} as any);
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
usePacksStore.setState({ enabledPackIds: ["p1"] } as any);
@@ -64,13 +69,13 @@ describe("authStore", () => {
it("bootstraps and reads the profile through GET /v1/me", async () => {
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me", is_anonymous: true } } },
data: { session: { user: { id: "me", is_anonymous: true }, access_token: "token-me" } },
});
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ profile });
const getSpy = jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({ profile });
await useAuthStore.getState().fetchUser();
expect(getSpy).toHaveBeenCalledWith("/v1/me");
expect(getSpy).toHaveBeenCalledWith("/v1/me", "token-me");
expect(useAuthStore.getState()).toMatchObject({
user: profile,
isAuthenticated: true,
@@ -114,9 +119,9 @@ describe("authStore", () => {
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
useAuthStore.setState({ sealedCount: 3 } as any);
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me" } } },
data: { session: { user: { id: "me" }, access_token: "token-me" } },
});
jest.spyOn(apiClient, "get").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
jest.spyOn(apiClient, "getWithAccessToken").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
(authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
await useAuthStore.getState().fetchUser();
@@ -135,6 +140,123 @@ describe("authStore", () => {
expect(authClient.auth.signOut).toHaveBeenCalled();
});
it("does not let an older session response overwrite a newer profile", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
let resolveA!: (value: { profile: Profile }) => void;
let resolveB!: (value: { profile: Profile }) => void;
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
.mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve; }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveB = resolve; }));
const firstFetch = useAuthStore.getState().fetchUser();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await Promise.resolve();
await Promise.resolve();
resolveB({ profile: { ...profile, id: "user-b", name: "Newer Name" } as Profile });
await secondFetch;
resolveA({ profile: { ...profile, id: "user-a", name: "Older Name" } as Profile });
await firstFetch;
expect(getSpy).toHaveBeenNthCalledWith(1, "/v1/me", "token-a");
expect(getSpy).toHaveBeenNthCalledWith(2, "/v1/me", "token-b");
expect(useAuthStore.getState()).toMatchObject({
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
isAuthenticated: true,
isLoading: false,
});
});
it("does not let an older couple response overwrite a newer session", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
jest.spyOn(apiClient, "getWithAccessToken")
.mockResolvedValueOnce({ profile: { ...profile, id: "user-a", name: "Older Name" } })
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
let resolveCoupleA!: (value: { couple: null; partner: null; sealed_count: number }) => void;
jest.spyOn(coupleApi, "getStateWithAccessToken")
.mockImplementationOnce(() => new Promise((resolve) => { resolveCoupleA = resolve; }))
.mockResolvedValueOnce({ couple: null, partner: null, sealed_count: 7 });
const firstFetch = useAuthStore.getState().fetchUser();
for (let attempt = 0; attempt < 10 && !resolveCoupleA; attempt += 1) {
await Promise.resolve();
}
expect(resolveCoupleA).toBeDefined();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await secondFetch;
resolveCoupleA({ couple: null, partner: null, sealed_count: 1 });
await firstFetch;
expect(useAuthStore.getState()).toMatchObject({
user: expect.objectContaining({ id: "user-b", name: "Newer Name" }),
sealedCount: 7,
isLoading: false,
});
});
it("rejects a mismatched API profile without signing out the active session", async () => {
useAuthStore.setState({
user: profile,
couple: { id: "prior-couple", invite_code: "PRIOR123", created_at: profile.created_at },
partner: { ...profile, id: "prior-partner", name: "Prior Partner" },
sealedCount: 4,
isAuthenticated: true,
isAnonymous: true,
pendingAppleDisplayName: { userId: "me", name: "Prior Name" },
} as any);
jest.spyOn(apiClient, "getWithAccessToken").mockResolvedValueOnce({
profile: { ...profile, id: "different-user", name: "Wrong Subject" },
});
await useAuthStore.getState().fetchUser();
expect(useAuthStore.getState()).toMatchObject({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
pendingAppleDisplayName: null,
});
expect(useMatchStore.getState().matches).toEqual([]);
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
expect(useMessageStore.getState().unreadCount).toBe(0);
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
expect(authClient.auth.signOut).not.toHaveBeenCalled();
expect(coupleApi.getStateWithAccessToken).not.toHaveBeenCalled();
});
it("does not sign out a newer session when an older request is rejected", async () => {
let currentSession = { user: { id: "user-a" }, access_token: "token-a" };
(authClient.auth.getSession as jest.Mock).mockImplementation(async () => ({ data: { session: currentSession } }));
let rejectA!: (error: Error) => void;
const getSpy = jest.spyOn(apiClient, "getWithAccessToken")
.mockImplementationOnce(() => new Promise((_, reject) => { rejectA = reject; }))
.mockResolvedValueOnce({ profile: { ...profile, id: "user-b", name: "Newer Name" } });
const firstFetch = useAuthStore.getState().fetchUser();
currentSession = { user: { id: "user-b" }, access_token: "token-b" };
const secondFetch = useAuthStore.getState().fetchUser();
await Promise.resolve();
await Promise.resolve();
await secondFetch;
rejectA(new ApiError("Unauthorized", 401));
await firstFetch;
expect(getSpy).toHaveBeenCalledTimes(2);
expect(authClient.auth.signOut).not.toHaveBeenCalled();
expect(useAuthStore.getState().user).toMatchObject({ id: "user-b", name: "Newer Name" });
});
it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
@@ -158,4 +280,14 @@ describe("authStore", () => {
isAuthenticated: false,
});
});
it('keeps a pending Apple name scoped to its original subject', () => {
useAuthStore.getState().setPendingAppleDisplayName({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('other-user');
expect(useAuthStore.getState().pendingAppleDisplayName).toEqual({ userId: 'apple-user', name: 'Ada Lovelace' });
useAuthStore.getState().clearPendingAppleDisplayName('apple-user');
expect(useAuthStore.getState().pendingAppleDisplayName).toBeNull();
});
});
@@ -41,9 +41,14 @@ if (Platform.OS === "android") {
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
import { colors, spacing, radius, typography } from "../../../theme";
import { authClient } from "../../../lib/authClient";
import { apiRequestWithAccessToken } from "../../../lib/apiClient";
import { formatAppleProfileName } from "../../../lib/appleProfileName";
import { persistAppleProfileName } from "../../../lib/appleProfileNamePersistence";
import { getAuthError } from "../../../lib/errors";
import { profileSettingsApi } from "../../../lib/profileSettingsApi";
import { useAuthStore } from "../../../store";
import { ScreenHeader } from "../components";
import type { Profile } from "../../../types";
type SaveMode = "magic-link" | "password";
@@ -182,28 +187,40 @@ export function SaveAccountScreen() {
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(" ");
const appleProfileName = formatAppleProfileName(credential.fullName);
if (appleProfileName) {
try {
const { data: { session } } = await authClient.auth.getSession();
if (!session?.user.id || !session.access_token) {
throw new Error("Apple account link did not retain an authenticated session");
}
if (fullName) {
await authClient.auth.updateUser({
data: {
full_name: fullName,
given_name: credential.fullName.givenName,
family_name: credential.fullName.familyName,
await persistAppleProfileName(
{ userId: session.user.id, accessToken: session.access_token },
appleProfileName,
{
getProfile: (token) => apiRequestWithAccessToken<{ profile: Profile }>("/v1/me", token),
updateProfileName: (token, name) => profileSettingsApi.updateProfileWithAccessToken(token, { name }),
},
});
);
} catch {
// Identity linking succeeded. Do not retain the raw Apple name in Auth
// metadata if the separate API profile update is temporarily unavailable.
console.warn("[Apple Link] Could not persist supplied name");
}
}
// Refresh user/profile
await fetchUser();
const { data: { session } } = await authClient.auth.getSession();
if (session?.user.id && session.access_token) {
await fetchUser({
userId: session.user.id,
accessToken: session.access_token,
isAnonymous: !!(session.user as { is_anonymous?: boolean }).is_anonymous,
});
} else {
await fetchUser();
}
showSuccess("Account saved with Apple.");
router.navigate("/(app)/profile" as any);
+14 -2
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef } from 'react';
import { Alert, Platform, ActionSheetIOS } from 'react-native';
import { Alert, Platform, ActionSheetIOS, Linking } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import { uploadMedia } from '../lib/mediaApi';
@@ -88,9 +88,21 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
let result: ImagePicker.ImagePickerResult;
if (source === 'camera') {
const existingPermission = await ImagePicker.getCameraPermissionsAsync();
if (existingPermission.status !== 'granted' && !existingPermission.canAskAgain) {
Alert.alert(
'Camera Access',
'To take a photo, allow camera access in Settings.',
[
{ text: 'Not Now', style: 'cancel' },
{ text: 'Open Settings', onPress: () => Linking.openSettings() },
]
);
return;
}
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
return;
}
result = await ImagePicker.launchCameraAsync({
+17 -8
View File
@@ -37,14 +37,11 @@ async function parseResponse(response: Response): Promise<unknown> {
}
}
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token;
if (error || !accessToken) {
throw new ApiError("An authenticated session is required", 401, error);
}
export async function apiRequestWithAccessToken<T>(
path: string,
accessToken: string,
options: ApiRequestOptions = {},
): Promise<T> {
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
headers.set("Authorization", `Bearer ${accessToken}`);
@@ -83,6 +80,17 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
return responseBody as T;
}
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token;
if (error || !accessToken) {
throw new ApiError("An authenticated session is required", 401, error);
}
return apiRequestWithAccessToken(path, accessToken, options);
}
export async function authenticatedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token;
@@ -94,6 +102,7 @@ export async function authenticatedFetch(path: string, init: RequestInit = {}):
export const apiClient = {
get: <T>(path: string) => apiRequest<T>(path),
getWithAccessToken: <T>(path: string, accessToken: string) => apiRequestWithAccessToken<T>(path, accessToken),
post: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "POST", body }),
put: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PUT", body }),
patch: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PATCH", body }),
+24
View File
@@ -0,0 +1,24 @@
export interface AppleProfileName {
givenName?: string | null;
middleName?: string | null;
familyName?: string | null;
}
const MAX_PROFILE_NAME_LENGTH = 100;
/**
* Apple supplies its name fields only on the first authorization. Keep that
* user-provided value as the Sauci display name without retaining email or
* other Apple credential fields outside the existing profile contract.
*/
export function formatAppleProfileName(fullName: AppleProfileName | null | undefined): string | null {
const name = [fullName?.givenName, fullName?.middleName, fullName?.familyName]
.filter((part): part is string => typeof part === "string")
.join(" ")
.normalize("NFKC")
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
.replace(/\s+/gu, " ")
.trim();
return name.length > 0 ? name.slice(0, MAX_PROFILE_NAME_LENGTH) : null;
}
@@ -0,0 +1,36 @@
import type { Profile } from '../types';
export interface AppleProfileNameSession {
userId: string;
accessToken: string;
}
export interface AppleProfileNamePersistenceDependencies {
getProfile: (accessToken: string) => Promise<{ profile: Pick<Profile, 'id' | 'name'> }>;
updateProfileName: (accessToken: string, name: string) => Promise<unknown>;
}
export type AppleProfileNamePersistenceResult = 'updated' | 'existing';
/**
* Persist a first-authorization Apple display name only for the exact subject
* and bearer that completed the authorization. The profile read protects an
* existing Sauci nickname from being overwritten.
*/
export async function persistAppleProfileName(
session: AppleProfileNameSession,
name: string,
dependencies: AppleProfileNamePersistenceDependencies,
): Promise<AppleProfileNamePersistenceResult> {
const { profile } = await dependencies.getProfile(session.accessToken);
if (profile.id !== session.userId) {
throw new Error('Apple profile subject did not match the authenticated user');
}
if (profile.name?.trim()) {
return 'existing';
}
await dependencies.updateProfileName(session.accessToken, name);
return 'updated';
}
+3 -1
View File
@@ -1,5 +1,5 @@
import type { Couple, Profile } from "@/types";
import { apiRequest } from "./apiClient";
import { apiRequest, apiRequestWithAccessToken } from "./apiClient";
export interface CoupleStateResponse {
couple: Couple | null;
@@ -16,6 +16,8 @@ export interface CoupleMutationResponse {
export const coupleApi = {
getState: () => apiRequest<CoupleStateResponse>("/v1/couple"),
getStateWithAccessToken: (accessToken: string) =>
apiRequestWithAccessToken<CoupleStateResponse>("/v1/couple", accessToken),
create: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "POST", body: {} }),
join: (inviteCode: string) => apiRequest<CoupleMutationResponse>("/v1/couple", {
method: "POST",
+32
View File
@@ -0,0 +1,32 @@
export type OnboardingStage = "name" | "gender" | "purpose" | "notifications";
/** A stored display name lets onboarding begin at the next required question. */
export function initialOnboardingStage(name: string | null | undefined): OnboardingStage {
return name?.trim() ? "gender" : "name";
}
/** A pending first-auth name is valid only for the matching signed-in subject. */
export function activeOnboardingStage(
stage: OnboardingStage,
storedDisplayName: string | null | undefined,
pendingDisplayName: string | null | undefined,
): OnboardingStage {
return stage === "name" && (storedDisplayName?.trim() || pendingDisplayName?.trim()) ? "gender" : stage;
}
/** The first reachable stage has no back action. */
export function previousOnboardingStage(
stage: OnboardingStage,
hasStoredDisplayName: boolean,
): OnboardingStage | null {
switch (stage) {
case "name":
return null;
case "gender":
return hasStoredDisplayName ? null : "name";
case "purpose":
return "gender";
case "notifications":
return "purpose";
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
import { apiClient } from './apiClient';
import { apiClient, apiRequestWithAccessToken } from './apiClient';
import type { Gender } from '../types';
import type { NotificationPreferences } from '../store/notificationPreferencesStore';
@@ -20,6 +20,8 @@ type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at
export const profileSettingsApi = {
updateProfile: (update: ProfileUpdate) => apiClient.patch<{ updated: true }>('/v1/me/profile', update),
updateProfileWithAccessToken: (accessToken: string, update: ProfileUpdate) =>
apiRequestWithAccessToken<{ updated: true }>('/v1/me/profile', accessToken, { method: 'PATCH', body: update }),
updateLastActive: () => apiClient.post<void>('/v1/me/activity'),
getNotificationPreferences: () => apiClient.get<NotificationPreferences>('/v1/me/notification-preferences'),
updateNotificationPreference: (key: PreferenceKey, value: boolean) =>
+96 -18
View File
@@ -7,6 +7,22 @@ import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
export interface AuthSessionSnapshot {
userId: string;
accessToken: string;
isAnonymous: boolean;
}
let fetchUserGeneration = 0;
const snapshotSession = (session: { user?: { id: string; is_anonymous?: boolean }; access_token?: string } | null): AuthSessionSnapshot | null => {
if (!session?.user?.id || !session.access_token) return null;
return {
userId: session.user.id,
accessToken: session.access_token,
isAnonymous: !!session.user.is_anonymous,
};
};
interface AuthState {
user: Profile | null;
@@ -17,14 +33,18 @@ interface AuthState {
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
/** First-authorization Apple name held only until the API profile reflects it. */
pendingAppleDisplayName: { userId: string; name: string } | null;
// Actions
fetchUser: () => Promise<void>;
fetchUser: (expectedSession?: AuthSessionSnapshot) => Promise<void>;
fetchCouple: () => Promise<void>;
refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>;
setUser: (user: Profile | null) => void;
setPendingAppleDisplayName: (pending: { userId: string; name: string } | null) => void;
clearPendingAppleDisplayName: (userId: string) => void;
updateLastActive: () => Promise<void>;
}
@@ -50,45 +70,94 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
pendingAppleDisplayName: null,
fetchUser: async (expectedSession) => {
const generation = ++fetchUserGeneration;
let snapshot: AuthSessionSnapshot | null = expectedSession ?? null;
const isCurrentSession = async (snapshot: AuthSessionSnapshot): Promise<boolean> => {
if (generation !== fetchUserGeneration) return false;
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await authClient.auth.getSession();
return generation === fetchUserGeneration
&& session?.user?.id === snapshot.userId
&& session.access_token === snapshot.accessToken;
};
if (!session?.user) {
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
try {
// Capture the session that owns this refresh. All following API calls
// and state writes remain bound to it, never the ambient auth session.
snapshot = expectedSession ?? snapshotSession((await authClient.auth.getSession()).data.session);
if (!snapshot) {
if (generation !== fetchUserGeneration) return;
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
return;
}
// This endpoint verifies the Supabase access token and creates the
// product profile on first use, keeping profile data out of Supabase.
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
const { profile } = await apiClient.getWithAccessToken<{ profile: Profile }>("/v1/me", snapshot.accessToken);
const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous;
if (!(await isCurrentSession(snapshot))) return;
if (profile.id !== snapshot.userId) {
console.error("[Auth] API profile subject did not match the authenticated session");
// Fail closed rather than leaving the prior account visible when
// an API response cannot be proven to belong to this session.
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
return;
}
const isAnonymous = snapshot.isAnonymous;
// Only update user if data actually changed to avoid unnecessary re-renders
const currentUser = get().user;
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile);
// Always fetch couple state: it reports the sealed answer count for a
// solo user even before any couple exists, not just once paired.
const { couple, partner, sealed_count } = await coupleApi.getStateWithAccessToken(snapshot.accessToken);
if (!(await isCurrentSession(snapshot))) return;
if (userChanged) {
set({
user: profile,
couple,
partner,
sealedCount: sealed_count,
isAuthenticated: true,
isAnonymous,
isLoading: false,
});
} else {
set({
couple,
partner,
sealedCount: sealed_count,
isAuthenticated: true,
isAnonymous,
isLoading: false,
});
} else if (!get().isAuthenticated) {
set({ isAuthenticated: true, isAnonymous });
}
// Always fetch couple state: it reports the sealed answer count for a
// solo user even before any couple exists, not just once paired.
await get().fetchCouple();
set({ isLoading: false });
} catch (error) {
if (generation !== fetchUserGeneration) return;
const currentSnapshot = snapshot;
if (currentSnapshot && !(await isCurrentSession(currentSnapshot))) return;
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false });
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false, pendingAppleDisplayName: null });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
@@ -141,6 +210,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
pendingAppleDisplayName: null,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
@@ -169,7 +239,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({
user,
isAuthenticated: !!user,
...(user === null && { isAnonymous: false }),
...(user === null && { isAnonymous: false, pendingAppleDisplayName: null }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null, sealedCount: 0 })
@@ -191,6 +261,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
setPendingAppleDisplayName: (pending) => set({ pendingAppleDisplayName: pending }),
clearPendingAppleDisplayName: (userId) => {
if (get().pendingAppleDisplayName?.userId === userId) {
set({ pendingAppleDisplayName: null });
}
},
/**
* Update the user's last_active_at timestamp.
* Called when the app comes to the foreground to track user activity.
+1
View File
@@ -92,6 +92,7 @@ jest.mock('@react-native-async-storage/async-storage', () =>
);
jest.mock('expo-image-picker', () => ({
getCameraPermissionsAsync: jest.fn(async () => ({ status: 'undetermined', canAskAgain: true })),
requestCameraPermissionsAsync: jest.fn(async () => ({ status: 'granted' })),
launchImageLibraryAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
launchCameraAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
+1 -1
View File
@@ -8,7 +8,7 @@
<string>com.apple.widgetkit-extension</string>
</dict>
<key>CFBundleVersion</key>
<string>47</string>
<string>49</string>
<key>CFBundleShortVersionString</key>
<string>1.0.8</string>
</dict>
+16
View File
@@ -87,6 +87,22 @@ const { error } = await supabase.auth.signInWithIdToken({
});
```
Apple provides the selected full name only on the first native authorization.
The mobile client formats it and reads the API-owned profile with the exact
subject and bearer returned by that authorization. It writes the display name
only when that matching profile name is blank, preserving existing nicknames.
While this first-auth write is pending, the name is held only in subject-keyed
mobile state so onboarding does not briefly require it again; failure clears
that value and safely returns to the normal name step. Apple full/given/family
names and email are not copied into Supabase user metadata.
Profile refreshes bind both `/v1/me` and couple-state reads to the session
subject and bearer captured at their start. A response is applied only while
that exact session remains current, and a returned profile must have the same
subject. This prevents an older request from replacing a newer account's local
profile, relationship state, or loading state. A subject mismatch clears the
in-memory user-scoped state rather than leaving a prior account visible.
**OAuth Fallback (web, Expo Go):**
```typescript
const { error } = await supabase.auth.signInWithOAuth({