fix(mobile): address Apple review onboarding flows

This commit is contained in:
jjbrunton committed 2026-09-01 09:42:48 +01:00
1 parent 6883befff3
commit 7c8d91c89c
20 files changed
+661 -262

No files matched your search

+17 -8
View File
@@ -37,14 +37,11 @@ async function parseResponse(response: Response): Promise<unknown> {
}
}
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token;
if (error || !accessToken) {
throw new ApiError("An authenticated session is required", 401, error);
}
export async function apiRequestWithAccessToken<T>(
path: string,
accessToken: string,
options: ApiRequestOptions = {},
): Promise<T> {
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
headers.set("Authorization", `Bearer ${accessToken}`);
@@ -83,6 +80,17 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
return responseBody as T;
}
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token;
if (error || !accessToken) {
throw new ApiError("An authenticated session is required", 401, error);
}
return apiRequestWithAccessToken(path, accessToken, options);
}
export async function authenticatedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token;
@@ -94,6 +102,7 @@ export async function authenticatedFetch(path: string, init: RequestInit = {}):
export const apiClient = {
get: <T>(path: string) => apiRequest<T>(path),
getWithAccessToken: <T>(path: string, accessToken: string) => apiRequestWithAccessToken<T>(path, accessToken),
post: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "POST", body }),
put: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PUT", body }),
patch: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PATCH", body }),
+24
View File
@@ -0,0 +1,24 @@
export interface AppleProfileName {
givenName?: string | null;
middleName?: string | null;
familyName?: string | null;
}
const MAX_PROFILE_NAME_LENGTH = 100;
/**
* Apple supplies its name fields only on the first authorization. Keep that
* user-provided value as the Sauci display name without retaining email or
* other Apple credential fields outside the existing profile contract.
*/
export function formatAppleProfileName(fullName: AppleProfileName | null | undefined): string | null {
const name = [fullName?.givenName, fullName?.middleName, fullName?.familyName]
.filter((part): part is string => typeof part === "string")
.join(" ")
.normalize("NFKC")
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "")
.replace(/\s+/gu, " ")
.trim();
return name.length > 0 ? name.slice(0, MAX_PROFILE_NAME_LENGTH) : null;
}
@@ -0,0 +1,36 @@
import type { Profile } from '../types';
export interface AppleProfileNameSession {
userId: string;
accessToken: string;
}
export interface AppleProfileNamePersistenceDependencies {
getProfile: (accessToken: string) => Promise<{ profile: Pick<Profile, 'id' | 'name'> }>;
updateProfileName: (accessToken: string, name: string) => Promise<unknown>;
}
export type AppleProfileNamePersistenceResult = 'updated' | 'existing';
/**
* Persist a first-authorization Apple display name only for the exact subject
* and bearer that completed the authorization. The profile read protects an
* existing Sauci nickname from being overwritten.
*/
export async function persistAppleProfileName(
session: AppleProfileNameSession,
name: string,
dependencies: AppleProfileNamePersistenceDependencies,
): Promise<AppleProfileNamePersistenceResult> {
const { profile } = await dependencies.getProfile(session.accessToken);
if (profile.id !== session.userId) {
throw new Error('Apple profile subject did not match the authenticated user');
}
if (profile.name?.trim()) {
return 'existing';
}
await dependencies.updateProfileName(session.accessToken, name);
return 'updated';
}
+3 -1
View File
@@ -1,5 +1,5 @@
import type { Couple, Profile } from "@/types";
import { apiRequest } from "./apiClient";
import { apiRequest, apiRequestWithAccessToken } from "./apiClient";
export interface CoupleStateResponse {
couple: Couple | null;
@@ -16,6 +16,8 @@ export interface CoupleMutationResponse {
export const coupleApi = {
getState: () => apiRequest<CoupleStateResponse>("/v1/couple"),
getStateWithAccessToken: (accessToken: string) =>
apiRequestWithAccessToken<CoupleStateResponse>("/v1/couple", accessToken),
create: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "POST", body: {} }),
join: (inviteCode: string) => apiRequest<CoupleMutationResponse>("/v1/couple", {
method: "POST",
+32
View File
@@ -0,0 +1,32 @@
export type OnboardingStage = "name" | "gender" | "purpose" | "notifications";
/** A stored display name lets onboarding begin at the next required question. */
export function initialOnboardingStage(name: string | null | undefined): OnboardingStage {
return name?.trim() ? "gender" : "name";
}
/** A pending first-auth name is valid only for the matching signed-in subject. */
export function activeOnboardingStage(
stage: OnboardingStage,
storedDisplayName: string | null | undefined,
pendingDisplayName: string | null | undefined,
): OnboardingStage {
return stage === "name" && (storedDisplayName?.trim() || pendingDisplayName?.trim()) ? "gender" : stage;
}
/** The first reachable stage has no back action. */
export function previousOnboardingStage(
stage: OnboardingStage,
hasStoredDisplayName: boolean,
): OnboardingStage | null {
switch (stage) {
case "name":
return null;
case "gender":
return hasStoredDisplayName ? null : "name";
case "purpose":
return "gender";
case "notifications":
return "purpose";
}
}
+3 -1
View File
@@ -1,4 +1,4 @@
import { apiClient } from './apiClient';
import { apiClient, apiRequestWithAccessToken } from './apiClient';
import type { Gender } from '../types';
import type { NotificationPreferences } from '../store/notificationPreferencesStore';
@@ -20,6 +20,8 @@ type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at
export const profileSettingsApi = {
updateProfile: (update: ProfileUpdate) => apiClient.patch<{ updated: true }>('/v1/me/profile', update),
updateProfileWithAccessToken: (accessToken: string, update: ProfileUpdate) =>
apiRequestWithAccessToken<{ updated: true }>('/v1/me/profile', accessToken, { method: 'PATCH', body: update }),
updateLastActive: () => apiClient.post<void>('/v1/me/activity'),
getNotificationPreferences: () => apiClient.get<NotificationPreferences>('/v1/me/notification-preferences'),
updateNotificationPreference: (key: PreferenceKey, value: boolean) =>