diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx index 2879254..d214882 100644 --- a/apps/mobile/app/(app)/onboarding.tsx +++ b/apps/mobile/app/(app)/onboarding.tsx @@ -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,9 +30,9 @@ 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 type { Gender, UsageReason } from '../../src/types'; +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 }[] = [ { value: 'male', label: 'Male', icon: 'male' }, @@ -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 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('avatar'); + const initialUser = useRef(useAuthStore.getState().user); + const [stage, setStage] = useState(() => initialOnboardingStage(initialUser.current?.name)); const [name, setName] = useState(initialUser.current?.name || ''); const [gender, setGender] = useState(initialUser.current?.gender || null); const [usageReason, setUsageReason] = useState(null); @@ -67,38 +67,18 @@ export default function OnboardingScreen() { const [error, setError] = useState(null); const [showPaywall, setShowPaywall] = useState(false); const inputRef = useRef(null); - const paywallNavigationRef = useRef(false); - - const { - avatarUri, - isUploading: isUploadingAvatar, - showPicker: handleAvatarPress, - uploadAvatar, - } = useAvatarPicker({ - userId, - onSelect: () => setError(null), - }); + const paywallNavigationRef = useRef(false); + + 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,72 +198,8 @@ export default function OnboardingScreen() { }; const renderStage = () => { - switch (stage) { - case 'avatar': - return ( - - - - - - Welcome to Sauci - - Add a profile photo so your partner knows it's you - - - - - - - {avatarUri ? ( - - ) : ( - - - - )} - - - - - - {avatarUri ? 'Tap to change photo' : 'Tap to add a photo'} - - - {error && ( - - - {error} - - )} - - - - - Continue - - - Skip for now - - - - ); - - case 'name': + switch (activeStage) { + case 'name': return ( - {avatarUri ? ( - - ) : ( - - - - )} + + + What can we call you? Nicknames and pet names are welcome too! @@ -368,7 +267,7 @@ export default function OnboardingScreen() { style={styles.stageContainer} > - Hey, {name.trim()}! + Hey, {displayName.trim()}! This helps us show you the right questions @@ -576,28 +475,24 @@ export default function OnboardingScreen() { > {/* Header with back button and progress dots */} - {stage !== 'avatar' ? ( - { - 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'); - }} + {previousStage ? ( + { + setError(null); + setStage(previousStage); + }} > ) : ( - )} - - - - - - + )} + + + + + @@ -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, diff --git a/apps/mobile/app/(auth)/login.tsx b/apps/mobile/app/(auth)/login.tsx index 7025119..5df6d60 100755 --- a/apps/mobile/app/(auth)/login.tsx +++ b/apps/mobile/app/(auth)/login.tsx @@ -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 { diff --git a/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts b/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts index ad82e4e..323499d 100644 --- a/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts +++ b/apps/mobile/src/__tests__/hooks/useAvatarPicker.test.ts @@ -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(); + }); }); diff --git a/apps/mobile/src/__tests__/lib/apiClient.test.ts b/apps/mobile/src/__tests__/lib/apiClient.test.ts index 687d14b..3d93a7b 100644 --- a/apps/mobile/src/__tests__/lib/apiClient.test.ts +++ b/apps/mobile/src/__tests__/lib/apiClient.test.ts @@ -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" }, diff --git a/apps/mobile/src/__tests__/lib/appleProfileName.test.ts b/apps/mobile/src/__tests__/lib/appleProfileName.test.ts new file mode 100644 index 0000000..9843da5 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/appleProfileName.test.ts @@ -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); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/appleProfileNamePersistence.test.ts b/apps/mobile/src/__tests__/lib/appleProfileNamePersistence.test.ts new file mode 100644 index 0000000..a0fbdba --- /dev/null +++ b/apps/mobile/src/__tests__/lib/appleProfileNamePersistence.test.ts @@ -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(); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/onboardingStages.test.ts b/apps/mobile/src/__tests__/lib/onboardingStages.test.ts new file mode 100644 index 0000000..e117dd4 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/onboardingStages.test.ts @@ -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'); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/profileSettingsApi.test.ts b/apps/mobile/src/__tests__/lib/profileSettingsApi.test.ts index 313e188..050ca6a 100644 --- a/apps/mobile/src/__tests__/lib/profileSettingsApi.test.ts +++ b/apps/mobile/src/__tests__/lib/profileSettingsApi.test.ts @@ -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' } }, + ); + }); }); diff --git a/apps/mobile/src/__tests__/stores/authStore.test.ts b/apps/mobile/src/__tests__/stores/authStore.test.ts index bf3a242..a50caa8 100644 --- a/apps/mobile/src/__tests__/stores/authStore.test.ts +++ b/apps/mobile/src/__tests__/stores/authStore.test.ts @@ -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(); + }); }); diff --git a/apps/mobile/src/features/profile/screens/SaveAccountScreen.tsx b/apps/mobile/src/features/profile/screens/SaveAccountScreen.tsx index c823d4e..5eb6576 100644 --- a/apps/mobile/src/features/profile/screens/SaveAccountScreen.tsx +++ b/apps/mobile/src/features/profile/screens/SaveAccountScreen.tsx @@ -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); diff --git a/apps/mobile/src/hooks/useAvatarPicker.ts b/apps/mobile/src/hooks/useAvatarPicker.ts index 5df7b96..5f77d50 100644 --- a/apps/mobile/src/hooks/useAvatarPicker.ts +++ b/apps/mobile/src/hooks/useAvatarPicker.ts @@ -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'; @@ -85,14 +85,26 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick const currentOptions = optionsRef.current; try { - let result: ImagePicker.ImagePickerResult; - - if (source === 'camera') { - const { status } = await ImagePicker.requestCameraPermissionsAsync(); - if (status !== 'granted') { - Alert.alert('Permission Denied', 'Camera access is required to take a photo.'); - return; - } + 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') { + return; + } result = await ImagePicker.launchCameraAsync({ mediaTypes: ['images'], allowsEditing: true, diff --git a/apps/mobile/src/lib/apiClient.ts b/apps/mobile/src/lib/apiClient.ts index 44e5158..c52e2f9 100644 --- a/apps/mobile/src/lib/apiClient.ts +++ b/apps/mobile/src/lib/apiClient.ts @@ -37,14 +37,11 @@ async function parseResponse(response: Response): Promise { } } -export async function apiRequest(path: string, options: ApiRequestOptions = {}): Promise { - 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( + path: string, + accessToken: string, + options: ApiRequestOptions = {}, +): Promise { const headers = new Headers(options.headers); headers.set("Accept", "application/json"); headers.set("Authorization", `Bearer ${accessToken}`); @@ -83,6 +80,17 @@ export async function apiRequest(path: string, options: ApiRequestOptions = { return responseBody as T; } +export async function apiRequest(path: string, options: ApiRequestOptions = {}): Promise { + 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 { 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: (path: string) => apiRequest(path), + getWithAccessToken: (path: string, accessToken: string) => apiRequestWithAccessToken(path, accessToken), post: (path: string, body?: unknown) => apiRequest(path, { method: "POST", body }), put: (path: string, body?: unknown) => apiRequest(path, { method: "PUT", body }), patch: (path: string, body?: unknown) => apiRequest(path, { method: "PATCH", body }), diff --git a/apps/mobile/src/lib/appleProfileName.ts b/apps/mobile/src/lib/appleProfileName.ts new file mode 100644 index 0000000..1447e4a --- /dev/null +++ b/apps/mobile/src/lib/appleProfileName.ts @@ -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; +} diff --git a/apps/mobile/src/lib/appleProfileNamePersistence.ts b/apps/mobile/src/lib/appleProfileNamePersistence.ts new file mode 100644 index 0000000..6b556f3 --- /dev/null +++ b/apps/mobile/src/lib/appleProfileNamePersistence.ts @@ -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 }>; + updateProfileName: (accessToken: string, name: string) => Promise; +} + +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 { + 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'; +} diff --git a/apps/mobile/src/lib/coupleApi.ts b/apps/mobile/src/lib/coupleApi.ts index 744b634..05044ac 100644 --- a/apps/mobile/src/lib/coupleApi.ts +++ b/apps/mobile/src/lib/coupleApi.ts @@ -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("/v1/couple"), + getStateWithAccessToken: (accessToken: string) => + apiRequestWithAccessToken("/v1/couple", accessToken), create: () => apiRequest("/v1/couple", { method: "POST", body: {} }), join: (inviteCode: string) => apiRequest("/v1/couple", { method: "POST", diff --git a/apps/mobile/src/lib/onboardingStages.ts b/apps/mobile/src/lib/onboardingStages.ts new file mode 100644 index 0000000..c836a73 --- /dev/null +++ b/apps/mobile/src/lib/onboardingStages.ts @@ -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"; + } +} diff --git a/apps/mobile/src/lib/profileSettingsApi.ts b/apps/mobile/src/lib/profileSettingsApi.ts index 4d7e5b4..9850ced 100644 --- a/apps/mobile/src/lib/profileSettingsApi.ts +++ b/apps/mobile/src/lib/profileSettingsApi.ts @@ -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 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('/v1/me/activity'), getNotificationPreferences: () => apiClient.get('/v1/me/notification-preferences'), updateNotificationPreference: (key: PreferenceKey, value: boolean) => diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index 1805e74..ce50485 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -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; + fetchUser: (expectedSession?: AuthSessionSnapshot) => Promise; fetchCouple: () => Promise; refreshPartner: () => Promise; signOut: () => Promise; setUser: (user: Profile | null) => void; + setPendingAppleDisplayName: (pending: { userId: string; name: string } | null) => void; + clearPendingAppleDisplayName: (userId: string) => void; updateLastActive: () => Promise; } @@ -50,45 +70,94 @@ export const useAuthStore = create((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 => { + 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((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((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((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. diff --git a/apps/mobile/src/test/setup.ts b/apps/mobile/src/test/setup.ts index 24d861b..ff7a48e 100644 --- a/apps/mobile/src/test/setup.ts +++ b/apps/mobile/src/test/setup.ts @@ -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: [] })), diff --git a/docs/authentication.md b/docs/authentication.md index f6df836..294d216 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -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({