mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 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
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user