mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
fix(mobile): address Apple review onboarding flows
This commit is contained in:
1 parent
6883befff3
commit
7c8d91c89c
20 files changed
+661
-262
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: [] })),
|
||||
|
||||
Reference in new issue
Block a user