Compare commits

Author SHA1 Message Date
jjbrunton 3659cc8c2f chore(ios): record v1.0.8 build 49 2026-09-01 14:08:05 +01:00
jjbrunton 4b227ad62c chore(mobile): prepare iOS build 48 2026-09-01 13:47:47 +01:00
jjbrunton 7c8d91c89c fix(mobile): address Apple review onboarding flows 2026-09-01 09:42:48 +01:00
jjbrunton 6883befff3 fix(mobile): persist matches tutorial dismissal 2026-08-31 19:39:33 +01:00
jjbruntonandClaude Opus 5 636811e66a fix(api): allow the admin console origin through CORS
The production allowlist held only https://sauci.app, so every
/v1/admin/* preflight from https://manage.sauci.app returned 204 with no
Access-Control-Allow-Origin and the browser blocked the request. Login
then reported "Access denied. You are not an administrator." because the
admin app treats any /v1/admin/me failure as a permissions failure.

Add the admin console origin and record in the README that new admin
hosts belong in the Compose contract rather than a Dokploy override.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:10 +01:00
jjbruntonandClaude Opus 5 d825e3d80c ci(branch-policy): allow hotfix and release branches into main
The policy accepted only `staging` as a head for main, so any change
production needed before the next promotion had nowhere to go: the PR was
unmergeable by CI and branch protection blocks direct pushes.

Accept `hotfix/*` and `release/*` heads as well. The naming requirement
keeps a direct-to-main change deliberate and visible, and the master
freeze plus the "features target staging" rule are unchanged. Document
the escape hatch and the need to land the same commit on staging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:03:34 +01:00
jjbruntonandClaude Opus 5 13094267a6 fix(admin): keep admin app in the docker build context
The root .dockerignore excluded apps/admin, so the monorepo build context
dropped that workspace entirely: npm ci skipped it and
`npm run build -w @sauci/admin` failed with "No workspaces found".
The ignore file was authored for the mcp image and later extended with
re-includes for web; admin never got the same treatment.

Re-include apps/admin, and re-exclude node_modules/dist last so the
re-includes cannot pull host build artefacts into any image context.

Verified with `docker build -f apps/admin/Dockerfile .`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:56:41 +01:00
jjbruntonandClaude Fable 5 ecc31a13a2 feat(release): enforce manifest and Info.plist release invariants
Extend release-preflight to reject an Android manifest missing the
READ_MEDIA_IMAGES/READ_MEDIA_VIDEO tools:node="remove" attributes and
an iOS Info.plist that regained dev-launcher-only keys, both
regressions introduced silently by expo prebuild during the SDK 54 to
56 upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:03:04 +01:00
jjbruntonandClaude Fable 5 7a76936829 docs(agents): promote v1.0.8 release learnings
Record three confirmed v1.0.8 failure modes (expo prebuild native
regressions, npm 10/11 lockfile divergence, local EAS build
preconditions) and promote each into docs/releasing.md, a new
docs/development/dependencies.md, and the release-preflight script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 16:03:02 +01:00
38 changed files with 1071 additions and 261 deletions

No files matched your search

@@ -0,0 +1,66 @@
---
id: 2026-08-31-expo-prebuild-native-regression
date: 2026-08-31
status: promoted
scope: apps/mobile native projects (ios/, android/) and expo prebuild
evidence: v1.0.8 SDK 54 to 56 upgrade, commits 604eec3, 7aca39a, 6bd04a6, 79233f5, b8cb2add
destination: apps/mobile/scripts/release-preflight.mjs, apps/mobile/scripts/release-preflight.test.mjs, and docs/releasing.md
review_after: 2026-11-30
---
## Symptom
`expo prebuild --clean` during the SDK 54 to 56 upgrade (and the
@bacons/apple-targets 0.1 to 5.0 bump) silently regressed four checked-in
native invariants. Each regression was caught only by a release gate or by
manual diffing against the last shipped tag (3fc6f8a, v1.0.7), not by
prebuild itself.
## Reproduction
Run `expo prebuild --clean` in apps/mobile against the upgraded SDK and diff
the regenerated ios/ and android/ projects against the previously committed
projects.
## Cause
1. Widget identity: apple-targets 5.x derives PRODUCT_BUNDLE_IDENTIFIER from
the target folder name, changing com.sauci.app.LiveDrawWidget to
com.sauci.app.widget, and repointed CODE_SIGN_ENTITLEMENTS to the
gitignored ios/.targets/LiveDrawWidget/generated.entitlements.
2. Android manifest lost the tools:node="remove" attributes on
READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, so the built AAB regained
Play-forbidden permissions (verified via bundletool dump on the rejected
build 50 AAB).
3. iOS Info.plist gained dev-launcher-only keys NSBonjourServices
(_expo._tcp), NSLocalNetworkUsageDescription, and RCTMetroPort.
4. android/gradle.properties reset to stock -Xmx2048m
-XX:MaxMetaspaceSize=512m, causing Metaspace OOM in kspReleaseKotlin/CMake
under RN 0.85.
Separately, a package absent from the new SDK's bundledNativeModules
(expo-av) has no compatible release and fails the iOS archive (EXAV.h
imports removed ExpoModulesCore headers).
## Remediation
Fixed by restoring widget identity and tracking
targets/widget/generated.entitlements with a pinned bundleIdentifier
(604eec3); restoring manifest tools:node="remove" attributes (7aca39a);
removing the dev-launcher-only Info.plist keys (6bd04a6); raising
gradle.properties memory to 4096m/1024m (79233f5); and migrating expo-av to
expo-video/expo-audio (b8cb2add).
Extend apps/mobile/scripts/release-preflight.mjs with executable checks for
items 2 and 3 (item 1 is already covered by the existing LiveDrawWidget
preflight check; item 4 hard-fails the build by itself). Add an "After
native regeneration" checklist to docs/releasing.md.
## Evidence
`node apps/mobile/scripts/release-preflight.mjs` fails if the tracked
AndroidManifest.xml lacks tools:node="remove" on READ_MEDIA_IMAGES or
READ_MEDIA_VIDEO, or if ios/Sauci/Info.plist contains NSBonjourServices,
NSLocalNetworkUsageDescription, or RCTMetroPort.
`node --test apps/mobile/scripts/release-preflight.test.mjs` covers the new
checks.
@@ -0,0 +1,55 @@
---
id: 2026-08-31-local-eas-build-preconditions
date: 2026-08-31
status: promoted
scope: apps/mobile local EAS release builds
evidence: v1.0.8 local EAS build session, commit d8bedea
destination: docs/releasing.md
review_after: 2026-11-30
---
## Symptom
Local EAS release builds failed before native compilation for environment
reasons unrelated to the app source, and a successful iOS build left the
tracked source in a state that fails release-preflight afterward.
## Reproduction
Run `eas build --local` from a non-interactive shell without first exporting
ANDROID_HOME/ANDROID_SDK_ROOT/JAVA_HOME: fails with "SDK location not
found" because non-interactive shells do not load the user profile. Run it
with a non-empty EAS_LOCAL_BUILD_WORKINGDIR: fails with "Workingdir is not
empty". Run it outside apps/mobile: eas cannot find the project. Pipe the
build log through `tail`: the full log needed for diagnosis is lost. After a
successful iOS build, EAS auto-increment updates both Info.plists but not
the widget target's CURRENT_PROJECT_VERSION in the pbxproj, so
release-preflight fails until it is synced.
## Cause
`eas` must be installed as a global CLI (scripts call bare `eas`; eas-cli
23.0.0 verified). Non-interactive shells used for automation do not source
the profile that normally exports the Android/Java environment variables.
EAS_LOCAL_BUILD_WORKINGDIR must not exist or be empty. EAS iOS
auto-increment writes plist versions but does not touch the widget target's
pbxproj CURRENT_PROJECT_VERSION, so it drifts from the app's build number by
one increment after every successful build.
## Remediation
Before a local EAS build: verify `eas` is on PATH, explicitly export
ANDROID_HOME, ANDROID_SDK_ROOT, and JAVA_HOME (JDK 17), ensure
EAS_LOCAL_BUILD_WORKINGDIR does not exist or is empty, and run from
apps/mobile. Capture the full build log to a file rather than piping through
`tail`. After a successful build, sync the widget's CURRENT_PROJECT_VERSION
to the new build number (commit d8bedea, sed 46 to 47) and sync
ios/Podfile.lock from the successful build's working directory when
dependencies changed, since the archive's pod install resolution is
authoritative and local CocoaPods here is broken.
## Evidence
docs/releasing.md's local-build section documents the environment
preconditions and the post-build widget CURRENT_PROJECT_VERSION and
Podfile.lock sync steps; `npm run release:preflight` passes after the sync.
@@ -0,0 +1,55 @@
---
id: 2026-08-31-npm-lockfile-divergence
date: 2026-08-31
status: promoted
scope: root package.json, package-lock.json, apps/web dependencies, CI
evidence: v1.0.8 lockfile investigation, commits 90cde4a, 9609a90
destination: docs/development/dependencies.md
review_after: 2026-11-30
---
## Symptom
CI and the API Docker image (node 20.19.4/npm 10.8.2, per .nvmrc) broke
invisibly while a lockfile regenerated on this dev machine (node 26/npm
11.19) passed local `npm ci`. Pushes to staging bypass required status
checks, so the red CI ran unnoticed for hours.
## Reproduction
Regenerate package-lock.json with npm 11 on a wildcard-range dependency
(react-native-worklets depends on `@react-native/metro-config: "*"`), then
run `npm ci` under node 20.19.4/npm 10.8.2. npm 10 requires 0.85.3 at that
tree position; npm 11 resolves/hoists 0.87.x there. `npm ci` fails with
"Invalid: lock file's ... does not satisfy". The reverse direction also
fails: a lock regenerated by npm 10 fails `npm ci` under npm 11 with missing
platform-optional binaries (@next/swc-linux-x64-gnu breaking `next build`,
@rollup/rollup-linux-x64-gnu breaking the API Docker tsup build, lightningcss
variants breaking local npm ci), matching the npm/cli#4828-style
optional-dependency resolution gap.
## Cause
Two different npm major versions in active use (CI/Docker on npm 10, this
dev machine on npm 11) resolve wildcard/range dependencies to different
versions at the same tree position, and each npm's own platform-optional
binary selection is not portable to the other npm's lockfile.
## Remediation
Pin the wildcard-range dependency via a root package.json override
(`"@react-native/metro-config": "0.85.3"`, alongside the existing
rollup/react-native/expo pins). Pin apps/web `@next/swc` linux/darwin
binaries as direct optionalDependencies (must be bumped together with any
`next` version bump). Regenerate the lock and validate it both ways: local
`npm ci` plus `npm run verify:fast`, and
`docker run --platform linux/amd64 node:20.19.4 ... npm ci` (the
`--platform` flag is mandatory; Apple Silicon Docker otherwise runs arm64
and the platform-binary check is meaningless). After any push that bypassed
required status checks, check `gh run list --branch staging`.
## Evidence
commits 90cde4a and 9609a90 add the override and optionalDependencies pins
and record a lockfile that passes both the amd64-emulated `npm ci` under
node 20.19.4 and local `npm ci` plus `npm run verify:fast`.
+1
View File
@@ -76,6 +76,7 @@ environment limitation; do not call it a passing product check.
|---|---| |---|---|
| System boundaries and data flow | `docs/architecture/system.md` | | System boundaries and data flow | `docs/architecture/system.md` |
| Local services, ports, fixtures | `docs/development/local-stack.md` | | Local services, ports, fixtures | `docs/development/local-stack.md` |
| Dependency and lockfile discipline | `docs/development/dependencies.md` |
| Test layers and required gates | `docs/testing/strategy.md` | | Test layers and required gates | `docs/testing/strategy.md` |
| Database migration safety | `docs/operations/database-migrations.md` | | Database migration safety | `docs/operations/database-migrations.md` |
| Agent roles and effort routing | `docs/agents/routing.md` | | Agent roles and effort routing | `docs/agents/routing.md` |
+2
View File
@@ -13,3 +13,5 @@ native iOS/Android targets.
- Add stable accessibility labels or `testID` values for critical interactions. - Add stable accessibility labels or `testID` values for critical interactions.
- Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk - Verify focused Jest tests, typecheck, lint, then native/Expo behavior as risk
requires. UI work needs observable simulator evidence. requires. UI work needs observable simulator evidence.
- Before and after any `expo prebuild`, follow the "After native regeneration"
checklist in `docs/releasing.md`.
+1 -1
View File
@@ -25,7 +25,7 @@
"group.com.sauci.app" "group.com.sauci.app"
] ]
}, },
"buildNumber": "47", "buildNumber": "49",
"usesAppleSignIn": true, "usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist", "googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": { "infoPlist": {
+17 -6
View File
@@ -69,10 +69,12 @@ export default function MatchesScreen() {
checkNudgeCooldown, checkNudgeCooldown,
} = useMatchStore(); } = useMatchStore();
const { user, couple, partner } = useAuthStore(); const { user, couple, partner } = useAuthStore();
const userId = user?.id;
const router = useRouter(); const router = useRouter();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false); const [showTutorial, setShowTutorial] = useState(false);
const tutorialDismissedRef = useRef(false);
const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null); const [nudgeFeedback, setNudgeFeedback] = useState<string | null>(null);
const [headerHeight, setHeaderHeight] = useState(130); const [headerHeight, setHeaderHeight] = useState(130);
@@ -104,21 +106,30 @@ export default function MatchesScreen() {
// Check if tutorial should be shown when screen is focused or matches change // Check if tutorial should be shown when screen is focused or matches change
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
let isActive = true;
const checkTutorial = async () => { const checkTutorial = async () => {
if (matches.length > 0) { if (matches.length > 0 && userId) {
const seen = await hasSeenMatchesTutorial(); const seen = await hasSeenMatchesTutorial(userId);
if (!seen) { if (isActive && !tutorialDismissedRef.current && !seen) {
setShowTutorial(true); setShowTutorial(true);
} }
} }
}; };
checkTutorial(); void checkTutorial();
}, [matches.length])
return () => {
isActive = false;
};
}, [matches.length, userId])
); );
const handleTutorialComplete = async () => { const handleTutorialComplete = async () => {
await markMatchesTutorialSeen(); if (!userId) return;
tutorialDismissedRef.current = true;
setShowTutorial(false); setShowTutorial(false);
await markMatchesTutorialSeen(userId);
}; };
// Load whichever view is showing, once. Returning to this tab reuses what is // Load whichever view is showing, once. Returning to this tab reuses what is
+20 -178
View File
@@ -9,14 +9,12 @@ import {
Platform, Platform,
Keyboard, Keyboard,
ScrollView, ScrollView,
Image,
} from 'react-native'; } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Animated, { import Animated, {
FadeIn, FadeIn,
FadeInUp,
FadeOut, FadeOut,
SlideInRight, SlideInRight,
SlideOutLeft, SlideOutLeft,
@@ -32,8 +30,8 @@ import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications'; import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics'; import { Events } from '../../src/lib/analytics';
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen'; 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 { 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'; import type { Gender, UsageReason } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
@@ -51,15 +49,17 @@ const PURPOSE_OPTIONS: { value: UsageReason; label: string; icon: string }[] = [
{ value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' }, { value: 'strengthen_relationship', label: 'Strengthen our relationship', icon: 'shield-checkmark' },
]; ];
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'notifications'; type Stage = OnboardingStage;
export default function OnboardingScreen() { export default function OnboardingScreen() {
const router = useRouter(); const router = useRouter();
// Only subscribe to stable values to prevent re-renders from background fetchUser calls // Only subscribe to stable values to prevent re-renders from background fetchUser calls
const userId = useAuthStore((s) => s.user?.id); 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 fetchUser = useAuthStore((s) => s.fetchUser);
const initialUser = useRef(useAuthStore.getState().user); const initialUser = useRef(useAuthStore.getState().user);
const [stage, setStage] = useState<Stage>('avatar'); const [stage, setStage] = useState<Stage>(() => initialOnboardingStage(initialUser.current?.name));
const [name, setName] = useState(initialUser.current?.name || ''); const [name, setName] = useState(initialUser.current?.name || '');
const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null); const [gender, setGender] = useState<Gender | null>(initialUser.current?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null); const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
@@ -69,36 +69,16 @@ export default function OnboardingScreen() {
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const paywallNavigationRef = useRef(false); const paywallNavigationRef = useRef(false);
const { const pendingDisplayName = pendingAppleDisplayName?.userId === userId ? pendingAppleDisplayName?.name : undefined;
avatarUri, const displayName = persistedUserName?.trim() || pendingDisplayName || name;
isUploading: isUploadingAvatar, const hasStoredDisplayName = Boolean(persistedUserName?.trim() || pendingDisplayName);
showPicker: handleAvatarPress, const activeStage = activeOnboardingStage(stage, persistedUserName, pendingDisplayName);
uploadAvatar, const previousStage = previousOnboardingStage(activeStage, hasStoredDisplayName);
} = useAvatarPicker({
userId,
onSelect: () => setError(null),
});
useEffect(() => { useEffect(() => {
Events.onboardingStart(); 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 = () => { const handleNameSubmit = () => {
if (!name.trim()) { if (!name.trim()) {
setError('Please enter your name'); setError('Please enter your name');
@@ -154,7 +134,7 @@ export default function OnboardingScreen() {
}; };
const handleComplete = async () => { const handleComplete = async () => {
console.log('[Onboarding] handleComplete called, user:', userId, 'name:', name); console.log('[Onboarding] handleComplete called, user:', userId, 'name:', displayName);
if (!userId) { if (!userId) {
console.error('[Onboarding] No user ID available'); console.error('[Onboarding] No user ID available');
setError('Not logged in. Please restart the app.'); setError('Not logged in. Please restart the app.');
@@ -165,19 +145,10 @@ export default function OnboardingScreen() {
setError(null); setError(null);
try { 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 updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData = { const updateData = {
name: name.trim(), name: displayName.trim(),
gender, gender,
usage_reason: usageReason, usage_reason: usageReason,
// Preserve the safest legacy profile values. Catalogue // Preserve the safest legacy profile values. Catalogue
@@ -191,10 +162,6 @@ export default function OnboardingScreen() {
await profileSettingsApi.updateProfile(updateData); await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) {
updatedFields.push("avatar_url");
}
console.log('[Onboarding] Profile updated successfully, fetching user...'); console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser(); await fetchUser();
console.log('[Onboarding] User fetched, navigating to home...'); console.log('[Onboarding] User fetched, navigating to home...');
@@ -231,71 +198,7 @@ export default function OnboardingScreen() {
}; };
const renderStage = () => { const renderStage = () => {
switch (stage) { switch (activeStage) {
case 'avatar':
return (
<Animated.View
key="avatar"
entering={FadeInUp.duration(500)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="heart" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.title}>Welcome to Sauci</Text>
<Text style={styles.subtitle}>
Add a profile photo so your partner knows it's you
</Text>
</View>
<GlassCard style={styles.card}>
<View style={styles.avatarSection}>
<Pressable onPress={handleAvatarPress} style={styles.avatarTouchable}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : (
<View
style={styles.avatarPlaceholder}
>
<Ionicons name="camera" size={40} color={colors.textSecondary} />
</View>
)}
<View style={styles.avatarBadge}>
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
</View>
</Pressable>
<Text style={styles.avatarHint}>
{avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
</Text>
</View>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
Continue
</GlassButton>
<Pressable
style={styles.skipButton}
onPress={handleAvatarSkip}
testID="avatar-skip-button"
>
<Text style={styles.skipText}>Skip for now</Text>
</Pressable>
</View>
</Animated.View>
);
case 'name': case 'name':
return ( return (
<Animated.View <Animated.View
@@ -305,16 +208,12 @@ export default function OnboardingScreen() {
style={styles.stageContainer} style={styles.stageContainer}
> >
<View style={styles.header}> <View style={styles.header}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} />
) : (
<LinearGradient <LinearGradient
colors={gradients.primary as [string, string]} colors={gradients.primary as [string, string]}
style={styles.iconContainer} style={styles.iconContainer}
> >
<Ionicons name="person" size={40} color={colors.text} /> <Ionicons name="person" size={40} color={colors.text} />
</LinearGradient> </LinearGradient>
)}
<Text style={styles.title}>What can we call you?</Text> <Text style={styles.title}>What can we call you?</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
Nicknames and pet names are welcome too! Nicknames and pet names are welcome too!
@@ -368,7 +267,7 @@ export default function OnboardingScreen() {
style={styles.stageContainer} style={styles.stageContainer}
> >
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.greeting}>Hey, {name.trim()}!</Text> <Text style={styles.greeting}>Hey, {displayName.trim()}!</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
This helps us show you the right questions This helps us show you the right questions
</Text> </Text>
@@ -576,15 +475,12 @@ export default function OnboardingScreen() {
> >
{/* Header with back button and progress dots */} {/* Header with back button and progress dots */}
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}> <Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
{stage !== 'avatar' ? ( {previousStage ? (
<Pressable <Pressable
style={styles.backButton} style={styles.backButton}
onPress={() => { onPress={() => {
setError(null); setError(null);
if (stage === 'notifications') setStage('purpose'); setStage(previousStage);
else if (stage === 'purpose') setStage('gender');
else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar');
}} }}
> >
<Ionicons name="chevron-back" size={24} color={colors.text} /> <Ionicons name="chevron-back" size={24} color={colors.text} />
@@ -593,11 +489,10 @@ export default function OnboardingScreen() {
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
)} )}
<View style={styles.progressContainer}> <View style={styles.progressContainer}>
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} /> <View style={[styles.progressDot, activeStage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} /> <View style={[styles.progressDot, activeStage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} /> <View style={[styles.progressDot, activeStage === 'purpose' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} /> <View style={[styles.progressDot, activeStage === 'notifications' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'notifications' && styles.progressDotActive]} />
</View> </View>
<View style={styles.backButtonPlaceholder} /> <View style={styles.backButtonPlaceholder} />
</Animated.View> </Animated.View>
@@ -653,18 +548,6 @@ const styles = StyleSheet.create({
backgroundColor: colors.border, 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: { inputContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -757,47 +640,6 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg, marginBottom: spacing.lg,
...shadows.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: { title: {
...typography.largeTitle, ...typography.largeTitle,
color: colors.text, color: colors.text,
+36 -1
View File
@@ -46,6 +46,11 @@ if (Platform.OS === "android") {
import { authClient } from "../../src/lib/authClient"; import { authClient } from "../../src/lib/authClient";
import { getAuthError } from "../../src/lib/errors"; import { getAuthError } from "../../src/lib/errors";
import { Events } from "../../src/lib/analytics"; 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 { useAuthStore } from "../../src/store";
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui"; import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
import { colors, spacing, radius, typography } from "../../src/theme"; 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); console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
if (credential.identityToken) { if (credential.identityToken) {
const { error } = await authClient.auth.signInWithIdToken({ const { data, error } = await authClient.auth.signInWithIdToken({
provider: "apple", provider: "apple",
token: credential.identityToken, token: credential.identityToken,
}); });
@@ -299,6 +304,36 @@ export default function LoginScreen() {
console.log("[Apple Sign In] Supabase error:", error.message); console.log("[Apple Sign In] Supabase error:", error.message);
Alert.alert("Error", getAuthError(error)); Alert.alert("Error", getAuthError(error));
} else { } 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"); Events.signIn("apple");
} }
} else { } else {
@@ -844,7 +844,7 @@
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements; CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 47; CURRENT_PROJECT_VERSION = 49;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -885,7 +885,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements; CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 47; CURRENT_PROJECT_VERSION = 49;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = BXVPBATDZY; DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
+1 -1
View File
@@ -47,7 +47,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>47</string> <string>49</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key> <key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/> <false/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
+12
View File
@@ -39,6 +39,7 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
const androidGradle = file('apps/mobile/android/app/build.gradle'); const androidGradle = file('apps/mobile/android/app/build.gradle');
const androidStrings = file('apps/mobile/android/app/src/main/res/values/strings.xml'); const androidStrings = file('apps/mobile/android/app/src/main/res/values/strings.xml');
const xcode = file('apps/mobile/ios/Sauci.xcodeproj/project.pbxproj'); const xcode = file('apps/mobile/ios/Sauci.xcodeproj/project.pbxproj');
const androidManifest = file('apps/mobile/android/app/src/main/AndroidManifest.xml');
const iosInfo = file('apps/mobile/ios/Sauci/Info.plist'); const iosInfo = file('apps/mobile/ios/Sauci/Info.plist');
const widgetInfo = file('apps/mobile/targets/widget/Info.plist'); const widgetInfo = file('apps/mobile/targets/widget/Info.plist');
const iosExpo = file('apps/mobile/ios/Sauci/Supporting/Expo.plist'); const iosExpo = file('apps/mobile/ios/Sauci/Supporting/Expo.plist');
@@ -70,6 +71,17 @@ export function validateReleaseSources({ root = repoRoot, isTracked } = {}) {
fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`); fail(`iOS app, widget, and effective widget Xcode build numbers disagree: app ${appBuild}; widget plist ${widgetBuild}; widget settings ${widgetBuildSettings.join(', ')}`);
} }
for (const permission of ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO']) {
const removed = new RegExp(`<uses-permission[^>]*android:name="android\\.permission\\.${permission}"[^>]*tools:node="remove"`).test(androidManifest);
if (!removed) fail(`AndroidManifest.xml must keep tools:node="remove" on ${permission} (expo prebuild regresses this)`);
}
const forbiddenPlistKeys = ['NSBonjourServices', 'NSLocalNetworkUsageDescription', 'RCTMetroPort'];
const foundDevLauncherKeys = forbiddenPlistKeys.filter((key) => iosInfo.includes(key));
if (foundDevLauncherKeys.length) {
fail(`ios/Sauci/Info.plist must not contain dev-launcher-only keys reintroduced by expo prebuild: ${foundDevLauncherKeys.join(', ')}`);
}
const tracked = isTracked ?? ((relative) => gitStatus(['ls-files', '--error-unmatch', relative], root) === 0); const tracked = isTracked ?? ((relative) => gitStatus(['ls-files', '--error-unmatch', relative], root) === 0);
if (!tracked(certificatePath)) fail(`${certificatePath} must be tracked`); if (!tracked(certificatePath)) fail(`${certificatePath} must be tracked`);
const archiveIgnore = existsSync(join(root, '.easignore')) ? '.easignore' : '.gitignore'; const archiveIgnore = existsSync(join(root, '.easignore')) ? '.easignore' : '.gitignore';
@@ -67,6 +67,38 @@ test('rejects an OTA certificate without its root archive allowlist', () => {
} }
}); });
test('rejects an Android manifest that lost the media permission removal (expo prebuild regression)', () => {
const root = fixture();
try {
const manifest = 'apps/mobile/android/app/src/main/AndroidManifest.xml';
replace(
root,
manifest,
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" tools:node="remove"/>',
'<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>',
);
assert.throws(() => validate(root), /READ_MEDIA_IMAGES \(expo prebuild regresses this\)/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('rejects an iOS Info.plist that regained dev-launcher-only keys (expo prebuild regression)', () => {
const root = fixture();
try {
const infoPlist = 'apps/mobile/ios/Sauci/Info.plist';
replace(
root,
infoPlist,
'</dict>\n</plist>',
'<key>NSBonjourServices</key>\n<array><string>_expo._tcp</string></array>\n</dict>\n</plist>',
);
assert.throws(() => validate(root), /dev-launcher-only keys reintroduced by expo prebuild: NSBonjourServices/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('requires the local EAS plugin executable rather than its package directory', () => { test('requires the local EAS plugin executable rather than its package directory', () => {
const root = mkdtempSync(join(tmpdir(), 'sauci-eas-plugin-')); const root = mkdtempSync(join(tmpdir(), 'sauci-eas-plugin-'));
const executable = join(root, 'bin-run'); const executable = join(root, 'bin-run');
@@ -1,14 +1,19 @@
import { act, renderHook } from '@testing-library/react-native'; 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 FileSystem from 'expo-file-system/legacy';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { useAvatarPicker } from '@/hooks/useAvatarPicker'; import { useAvatarPicker } from '@/hooks/useAvatarPicker';
jest.mock('@/lib/mediaApi', () => ({
uploadMedia: jest.fn(),
}));
jest.mock('expo-file-system/legacy', () => ({ jest.mock('expo-file-system/legacy', () => ({
getInfoAsync: jest.fn(), getInfoAsync: jest.fn(),
})); }));
jest.mock('expo-image-picker', () => ({ jest.mock('expo-image-picker', () => ({
getCameraPermissionsAsync: jest.fn(),
requestCameraPermissionsAsync: jest.fn(), requestCameraPermissionsAsync: jest.fn(),
requestMediaLibraryPermissionsAsync: jest.fn(), requestMediaLibraryPermissionsAsync: jest.fn(),
launchCameraAsync: jest.fn(), launchCameraAsync: jest.fn(),
@@ -23,7 +28,10 @@ describe('useAvatarPicker', () => {
Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' }); Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined); jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation(() => undefined);
jest.spyOn(Linking, 'openSettings').mockResolvedValue(undefined);
(ImagePicker.launchImageLibraryAsync as jest.Mock).mockResolvedValue({ canceled: true, assets: [] }); (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 }); (FileSystem.getInfoAsync as jest.Mock).mockResolvedValue({ exists: true });
}); });
@@ -64,4 +72,41 @@ describe('useAvatarPicker', () => {
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1); expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalledTimes(1);
expect(ImagePicker.launchImageLibraryAsync).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"; import { authClient } from "@/lib/authClient";
function response(status: number, body?: unknown) { function response(status: number, body?: unknown) {
@@ -40,6 +40,17 @@ describe("apiClient", () => {
expect(fetchMock).not.toHaveBeenCalled(); 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 () => { it("preserves API status and error details", async () => {
fetchMock.mockResolvedValue(response(403, { fetchMock.mockResolvedValue(response(403, {
error: { message: "Couple access denied", code: "FORBIDDEN" }, 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,45 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store";
import {
hasSeenMatchesTutorial,
markMatchesTutorialSeen,
resetMatchesTutorial,
} from "@/lib/matchesTutorialSeen";
jest.mock("expo-secure-store", () => ({
getItemAsync: jest.fn(),
}));
describe("matchesTutorialSeen", () => {
beforeEach(async () => {
await AsyncStorage.clear();
jest.mocked(SecureStore.getItemAsync).mockReset();
jest.mocked(SecureStore.getItemAsync).mockResolvedValue(null);
});
it("persists a dismissal for the signed-in account", async () => {
await markMatchesTutorialSeen("user-1");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
});
it("does not dismiss the tutorial for another account on the same device", async () => {
await markMatchesTutorialSeen("user-1");
await expect(hasSeenMatchesTutorial("user-2")).resolves.toBe(false);
});
it("migrates an existing legacy dismissal into durable account storage", async () => {
jest.mocked(SecureStore.getItemAsync).mockResolvedValue("true");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(true);
await expect(AsyncStorage.getItem("matches_tutorial_seen_user-1")).resolves.toBe("true");
});
it("lets the development reset show the tutorial for the current account again", async () => {
await markMatchesTutorialSeen("user-1");
await resetMatchesTutorial("user-1");
await expect(hasSeenMatchesTutorial("user-1")).resolves.toBe(false);
});
});
@@ -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'; import { profileSettingsApi } from '../../lib/profileSettingsApi';
jest.mock('../../lib/apiClient', () => ({ jest.mock('../../lib/apiClient', () => ({
@@ -7,6 +7,7 @@ jest.mock('../../lib/apiClient', () => ({
post: jest.fn(), post: jest.fn(),
patch: jest.fn(), patch: jest.fn(),
}, },
apiRequestWithAccessToken: jest.fn(),
})); }));
describe('profileSettingsApi', () => { describe('profileSettingsApi', () => {
@@ -27,4 +28,14 @@ describe('profileSettingsApi', () => {
type: 'bug', title: 'Title', description: 'Details', 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", () => { describe("authStore", () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); 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 // fetchUser always fetches couple state now, since it also carries the
// sealed answer count for a user who has no couple yet. Individual tests // sealed answer count for a user who has no couple yet. Individual tests
// override this when they care about the couple/partner shape. // 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, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
jest.spyOn(coupleApi, "getStateWithAccessToken").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
useAuthStore.setState({ useAuthStore.setState({
user: null, user: null,
couple: null, couple: null,
@@ -42,6 +46,7 @@ describe("authStore", () => {
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,
isAnonymous: false, isAnonymous: false,
pendingAppleDisplayName: null,
} as any); } as any);
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any); useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
usePacksStore.setState({ enabledPackIds: ["p1"] } 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 () => { it("bootstraps and reads the profile through GET /v1/me", async () => {
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({ (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(); await useAuthStore.getState().fetchUser();
expect(getSpy).toHaveBeenCalledWith("/v1/me"); expect(getSpy).toHaveBeenCalledWith("/v1/me", "token-me");
expect(useAuthStore.getState()).toMatchObject({ expect(useAuthStore.getState()).toMatchObject({
user: profile, user: profile,
isAuthenticated: true, isAuthenticated: true,
@@ -114,9 +119,9 @@ describe("authStore", () => {
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => { it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
useAuthStore.setState({ sealedCount: 3 } as any); useAuthStore.setState({ sealedCount: 3 } as any);
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({ (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 }); (authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
await useAuthStore.getState().fetchUser(); await useAuthStore.getState().fetchUser();
@@ -135,6 +140,123 @@ describe("authStore", () => {
expect(authClient.auth.signOut).toHaveBeenCalled(); 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 () => { it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any); useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
@@ -158,4 +280,14 @@ describe("authStore", () => {
isAuthenticated: false, 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();
});
}); });
@@ -223,7 +223,8 @@ export function ProfileScreen() {
label="Reset Matches Tutorial" label="Reset Matches Tutorial"
description="Show the tutorial again" description="Show the tutorial again"
onPress={async () => { onPress={async () => {
await resetMatchesTutorial(); if (!user?.id) return;
await resetMatchesTutorial(user.id);
Alert.alert("Success", "Matches tutorial has been reset."); Alert.alert("Success", "Matches tutorial has been reset.");
}} }}
/> />
@@ -41,9 +41,14 @@ if (Platform.OS === "android") {
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui"; import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
import { colors, spacing, radius, typography } from "../../../theme"; import { colors, spacing, radius, typography } from "../../../theme";
import { authClient } from "../../../lib/authClient"; 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 { getAuthError } from "../../../lib/errors";
import { profileSettingsApi } from "../../../lib/profileSettingsApi";
import { useAuthStore } from "../../../store"; import { useAuthStore } from "../../../store";
import { ScreenHeader } from "../components"; import { ScreenHeader } from "../components";
import type { Profile } from "../../../types";
type SaveMode = "magic-link" | "password"; type SaveMode = "magic-link" | "password";
@@ -182,28 +187,40 @@ export function SaveAccountScreen() {
return; return;
} }
// Apple only provides name/email on first auth grant const appleProfileName = formatAppleProfileName(credential.fullName);
if (credential.fullName) { if (appleProfileName) {
const nameParts = [ try {
credential.fullName.givenName, const { data: { session } } = await authClient.auth.getSession();
credential.fullName.middleName, if (!session?.user.id || !session.access_token) {
credential.fullName.familyName, throw new Error("Apple account link did not retain an authenticated session");
].filter(Boolean); }
const fullName = nameParts.join(" ");
if (fullName) { await persistAppleProfileName(
await authClient.auth.updateUser({ { userId: session.user.id, accessToken: session.access_token },
data: { appleProfileName,
full_name: fullName, {
given_name: credential.fullName.givenName, getProfile: (token) => apiRequestWithAccessToken<{ profile: Profile }>("/v1/me", token),
family_name: credential.fullName.familyName, 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 // Refresh user/profile
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(); await fetchUser();
}
showSuccess("Account saved with Apple."); showSuccess("Account saved with Apple.");
router.navigate("/(app)/profile" as any); router.navigate("/(app)/profile" as any);
+14 -2
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef } from 'react'; 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 ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import { uploadMedia } from '../lib/mediaApi'; import { uploadMedia } from '../lib/mediaApi';
@@ -88,9 +88,21 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
let result: ImagePicker.ImagePickerResult; let result: ImagePicker.ImagePickerResult;
if (source === 'camera') { 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(); const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
return; return;
} }
result = await ImagePicker.launchCameraAsync({ result = await ImagePicker.launchCameraAsync({
+17 -8
View File
@@ -37,14 +37,11 @@ async function parseResponse(response: Response): Promise<unknown> {
} }
} }
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> { export async function apiRequestWithAccessToken<T>(
const { data, error } = await authClient.auth.getSession(); path: string,
const accessToken = data.session?.access_token; accessToken: string,
options: ApiRequestOptions = {},
if (error || !accessToken) { ): Promise<T> {
throw new ApiError("An authenticated session is required", 401, error);
}
const headers = new Headers(options.headers); const headers = new Headers(options.headers);
headers.set("Accept", "application/json"); headers.set("Accept", "application/json");
headers.set("Authorization", `Bearer ${accessToken}`); headers.set("Authorization", `Bearer ${accessToken}`);
@@ -83,6 +80,17 @@ export async function apiRequest<T>(path: string, options: ApiRequestOptions = {
return responseBody as T; 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> { export async function authenticatedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const { data, error } = await authClient.auth.getSession(); const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token; const accessToken = data.session?.access_token;
@@ -94,6 +102,7 @@ export async function authenticatedFetch(path: string, init: RequestInit = {}):
export const apiClient = { export const apiClient = {
get: <T>(path: string) => apiRequest<T>(path), 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 }), post: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "POST", body }),
put: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PUT", 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 }), 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 type { Couple, Profile } from "@/types";
import { apiRequest } from "./apiClient"; import { apiRequest, apiRequestWithAccessToken } from "./apiClient";
export interface CoupleStateResponse { export interface CoupleStateResponse {
couple: Couple | null; couple: Couple | null;
@@ -16,6 +16,8 @@ export interface CoupleMutationResponse {
export const coupleApi = { export const coupleApi = {
getState: () => apiRequest<CoupleStateResponse>("/v1/couple"), getState: () => apiRequest<CoupleStateResponse>("/v1/couple"),
getStateWithAccessToken: (accessToken: string) =>
apiRequestWithAccessToken<CoupleStateResponse>("/v1/couple", accessToken),
create: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "POST", body: {} }), create: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "POST", body: {} }),
join: (inviteCode: string) => apiRequest<CoupleMutationResponse>("/v1/couple", { join: (inviteCode: string) => apiRequest<CoupleMutationResponse>("/v1/couple", {
method: "POST", method: "POST",
+36 -17
View File
@@ -1,51 +1,70 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native"; import { Platform } from "react-native";
const STORAGE_KEY = "matches_tutorial_seen"; const STORAGE_KEY_PREFIX = "matches_tutorial_seen";
const LEGACY_STORAGE_KEY = "matches_tutorial_seen";
async function getStorage(): Promise<boolean> { const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(userId: string): Promise<boolean> {
try { try {
let data: string | null = null; const key = buildKey(userId);
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY); return window.localStorage.getItem(key) === "true";
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
} }
return data === "true"; return false;
}
const storedValue = await AsyncStorage.getItem(key);
if (storedValue !== null) {
return storedValue === "true";
}
// Keep dismissals made before the account-scoped AsyncStorage key was
// introduced. Future reads use the durable app storage key above.
const legacyValue = await SecureStore.getItemAsync(LEGACY_STORAGE_KEY);
if (legacyValue === "true") {
await AsyncStorage.setItem(key, "true");
return true;
}
return false;
} catch (error) { } catch (error) {
console.error("Error reading matches tutorial state:", error); console.error("Error reading matches tutorial state:", error);
} }
return false; return false;
} }
async function setStorage(seen: boolean): Promise<void> { async function setStorage(userId: string, seen: boolean): Promise<void> {
try { try {
const data = seen ? "true" : "false"; const data = seen ? "true" : "false";
const key = buildKey(userId);
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data); window.localStorage.setItem(key, data);
} }
} else { } else {
await SecureStore.setItemAsync(STORAGE_KEY, data); await AsyncStorage.setItem(key, data);
} }
} catch (error) { } catch (error) {
console.error("Error saving matches tutorial state:", error); console.error("Error saving matches tutorial state:", error);
} }
} }
export async function hasSeenMatchesTutorial(): Promise<boolean> { /** The Matches tutorial is shown at most once per signed-in account. */
return getStorage(); export async function hasSeenMatchesTutorial(userId: string): Promise<boolean> {
return getStorage(userId);
} }
export async function markMatchesTutorialSeen(): Promise<void> { export async function markMatchesTutorialSeen(userId: string): Promise<void> {
await setStorage(true); await setStorage(userId, true);
} }
export async function resetMatchesTutorial(): Promise<void> { export async function resetMatchesTutorial(userId: string): Promise<void> {
await setStorage(false); await setStorage(userId, false);
} }
+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 { Gender } from '../types';
import type { NotificationPreferences } from '../store/notificationPreferencesStore'; import type { NotificationPreferences } from '../store/notificationPreferencesStore';
@@ -20,6 +20,8 @@ type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at
export const profileSettingsApi = { export const profileSettingsApi = {
updateProfile: (update: ProfileUpdate) => apiClient.patch<{ updated: true }>('/v1/me/profile', update), 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'), updateLastActive: () => apiClient.post<void>('/v1/me/activity'),
getNotificationPreferences: () => apiClient.get<NotificationPreferences>('/v1/me/notification-preferences'), getNotificationPreferences: () => apiClient.get<NotificationPreferences>('/v1/me/notification-preferences'),
updateNotificationPreference: (key: PreferenceKey, value: boolean) => updateNotificationPreference: (key: PreferenceKey, value: boolean) =>
+96 -18
View File
@@ -7,6 +7,22 @@ import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics"; import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types"; 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 { interface AuthState {
user: Profile | null; user: Profile | null;
@@ -17,14 +33,18 @@ interface AuthState {
isLoading: boolean; isLoading: boolean;
isAuthenticated: boolean; isAuthenticated: boolean;
isAnonymous: boolean; isAnonymous: boolean;
/** First-authorization Apple name held only until the API profile reflects it. */
pendingAppleDisplayName: { userId: string; name: string } | null;
// Actions // Actions
fetchUser: () => Promise<void>; fetchUser: (expectedSession?: AuthSessionSnapshot) => Promise<void>;
fetchCouple: () => Promise<void>; fetchCouple: () => Promise<void>;
refreshPartner: () => Promise<Profile | null>; refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
setUser: (user: Profile | null) => void; setUser: (user: Profile | null) => void;
setPendingAppleDisplayName: (pending: { userId: string; name: string } | null) => void;
clearPendingAppleDisplayName: (userId: string) => void;
updateLastActive: () => Promise<void>; updateLastActive: () => Promise<void>;
} }
@@ -50,45 +70,94 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: true, isLoading: true,
isAuthenticated: false, isAuthenticated: false,
isAnonymous: 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(); const { data: { session } } = await authClient.auth.getSession();
return generation === fetchUserGeneration
&& session?.user?.id === snapshot.userId
&& session.access_token === snapshot.accessToken;
};
if (!session?.user) { try {
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); // 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; return;
} }
// This endpoint verifies the Supabase access token and creates the // This endpoint verifies the Supabase access token and creates the
// product profile on first use, keeping profile data out of Supabase. // 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 // Only update user if data actually changed to avoid unnecessary re-renders
const currentUser = get().user; const currentUser = get().user;
const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile); 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) { if (userChanged) {
set({ set({
user: profile, user: profile,
couple,
partner,
sealedCount: sealed_count,
isAuthenticated: true, isAuthenticated: true,
isAnonymous, 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) { } catch (error) {
if (generation !== fetchUserGeneration) return;
const currentSnapshot = snapshot;
if (currentSnapshot && !(await isCurrentSession(currentSnapshot))) return;
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out"); 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(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches(); useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks(); usePacksStore.getState().clearPacks();
@@ -141,6 +210,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isAuthenticated: false, isAuthenticated: false,
isAnonymous: false, isAnonymous: false,
isLoading: false, isLoading: false,
pendingAppleDisplayName: null,
}); });
// Clear other stores // Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
@@ -169,7 +239,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ set({
user, user,
isAuthenticated: !!user, isAuthenticated: !!user,
...(user === null && { isAnonymous: false }), ...(user === null && { isAnonymous: false, pendingAppleDisplayName: null }),
isLoading: false, isLoading: false,
// Clear couple/partner when user is null (signed out) // Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null, sealedCount: 0 }) ...(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. * Update the user's last_active_at timestamp.
* Called when the app comes to the foreground to track user activity. * Called when the app comes to the foreground to track user activity.
+1
View File
@@ -92,6 +92,7 @@ jest.mock('@react-native-async-storage/async-storage', () =>
); );
jest.mock('expo-image-picker', () => ({ jest.mock('expo-image-picker', () => ({
getCameraPermissionsAsync: jest.fn(async () => ({ status: 'undetermined', canAskAgain: true })),
requestCameraPermissionsAsync: jest.fn(async () => ({ status: 'granted' })), requestCameraPermissionsAsync: jest.fn(async () => ({ status: 'granted' })),
launchImageLibraryAsync: jest.fn(async () => ({ canceled: true, assets: [] })), launchImageLibraryAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
launchCameraAsync: jest.fn(async () => ({ canceled: true, assets: [] })), launchCameraAsync: jest.fn(async () => ({ canceled: true, assets: [] })),
+1 -1
View File
@@ -8,7 +8,7 @@
<string>com.apple.widgetkit-extension</string> <string>com.apple.widgetkit-extension</string>
</dict> </dict>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>47</string> <string>49</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.8</string> <string>1.0.8</string>
</dict> </dict>
+16
View File
@@ -87,6 +87,22 @@ const { error } = await supabase.auth.signInWithIdToken({
}); });
``` ```
Apple provides the selected full name only on the first native authorization.
The mobile client formats it and reads the API-owned profile with the exact
subject and bearer returned by that authorization. It writes the display name
only when that matching profile name is blank, preserving existing nicknames.
While this first-auth write is pending, the name is held only in subject-keyed
mobile state so onboarding does not briefly require it again; failure clears
that value and safely returns to the normal name step. Apple full/given/family
names and email are not copied into Supabase user metadata.
Profile refreshes bind both `/v1/me` and couple-state reads to the session
subject and bearer captured at their start. A response is applied only while
that exact session remains current, and a returned profile must have the same
subject. This prevents an older request from replacing a newer account's local
profile, relationship state, or loading state. A subject mismatch clears the
in-memory user-scoped state rather than leaving a prior account visible.
**OAuth Fallback (web, Expo Go):** **OAuth Fallback (web, Expo Go):**
```typescript ```typescript
const { error } = await supabase.auth.signInWithOAuth({ const { error } = await supabase.auth.signInWithOAuth({
+76
View File
@@ -0,0 +1,76 @@
# Dependency and lockfile discipline
## Two npm realities
CI runners and the API Docker image use the node version pinned in
`.nvmrc` (node 20.19.4 with npm 10.8.2). A developer machine can run a newer
npm (for example npm 11). npm 10 and npm 11 resolve wildcard or range
dependencies differently at the same place in the dependency tree, and each
npm's platform-optional binary selection is not portable to a lockfile
regenerated by the other npm. A lockfile that passes `npm ci` under one npm
major version can fail it under the other with either an "Invalid: lock
file's ... does not satisfy" error or missing platform-optional binaries
(for example `@next/swc-linux-x64-gnu` breaking `next build`,
`@rollup/rollup-linux-x64-gnu` breaking the API Docker `tsup` build, or a
missing `lightningcss` variant breaking local `npm ci`).
## Pin wildcard-range dependencies with root overrides
When a transitive dependency declares a wildcard or wide range (for example
`react-native-worklets` depending on `@react-native/metro-config: "*"`), pin
the exact version the app needs in the root `package.json` `overrides` block
rather than leaving it to whichever npm resolved the lockfile last:
```json
"overrides": {
"rollup": "4.54.0",
"react-native": "0.85.3",
"expo": "~56.0.0",
"@react-native/metro-config": "0.85.3"
}
```
## Pin platform-optional binaries that must travel with the framework version
`apps/web/package.json` pins the `@next/swc` linux and darwin binaries as
direct `optionalDependencies` so npm does not have to infer them per
platform:
```json
"optionalDependencies": {
"@next/swc-linux-x64-gnu": "15.5.24",
"@next/swc-linux-x64-musl": "15.5.24",
"@next/swc-darwin-arm64": "15.5.24"
}
```
These versions must be bumped together with any `next` version bump, or the
pinned binaries will drift from the framework version and `npm ci` or
`next build` can fail on the platform that needs the stale binary.
## Validate a regenerated lockfile both ways
After changing `overrides` or any dependency version, regenerate
`package-lock.json` and validate it under both npm majors before committing:
```bash
# Local npm (matches this machine's npm major)
npm ci
npm run verify:fast
# The CI/Docker npm major, emulated on Apple Silicon
docker run --rm --platform linux/amd64 -v "$PWD:/repo" -w /repo node:20.19.4 npm ci
```
The `--platform linux/amd64` flag is mandatory on Apple Silicon: without it,
Docker runs the arm64 image and the platform-binary check proves nothing.
## After any bypassed push
Pushes to `staging` bypass required status checks. After any such push, check
`gh run list --branch staging` to confirm CI is actually green rather than
assuming it ran and passed.
Do not pipe `npm` commands through `tail` when diagnosing a lockfile or build
failure; it can swallow the command's exit code and the detail needed to find
the root cause.
+1
View File
@@ -8,6 +8,7 @@ historical notes. Agents should load only the section relevant to their task.
- [System architecture](architecture/system.md) - [System architecture](architecture/system.md)
- [Client data freshness](architecture/client-data-freshness.md) - [Client data freshness](architecture/client-data-freshness.md)
- [Local full-stack development](development/local-stack.md) - [Local full-stack development](development/local-stack.md)
- [Dependency and lockfile discipline](development/dependencies.md)
- [Testing strategy](testing/strategy.md) - [Testing strategy](testing/strategy.md)
- [Database migration safety](operations/database-migrations.md) - [Database migration safety](operations/database-migrations.md)
- [Backend deployment and branching](operations/backend-deployments.md) - [Backend deployment and branching](operations/backend-deployments.md)
+35
View File
@@ -249,6 +249,23 @@ and dependency installation failures occur before native compilation: fix and
preflight those inputs without starting another build. Make one meaningful build preflight those inputs without starting another build. Make one meaningful build
per newly proven input state. per newly proven input state.
Before starting: confirm `eas` is installed and on PATH (scripts call bare
`eas`); explicitly export `ANDROID_HOME`, `ANDROID_SDK_ROOT`, and `JAVA_HOME`
(JDK 17) rather than relying on a shell profile, because non-interactive shells
used for automation do not load it ("SDK location not found" otherwise); ensure
`EAS_LOCAL_BUILD_WORKINGDIR` does not exist or is empty ("Workingdir is not
empty" otherwise); and run every command from `apps/mobile`. Capture the full
build log to a file; do not pipe it through `tail`, which drops the detail
needed to diagnose a failure.
After a successful iOS build, EAS auto-increment updates both app and widget
Info.plists but not the widget target's `CURRENT_PROJECT_VERSION` in the
pbxproj, so `release:preflight` fails until it is synced to the new build
number (for example `sed -i '' 's/CURRENT_PROJECT_VERSION = 46;/CURRENT_PROJECT_VERSION = 47;/' ios/Sauci.xcodeproj/project.pbxproj`
for both Debug and Release). If native dependencies changed, also sync
`ios/Podfile.lock` from the successful build's working directory: the archive's
`pod install` resolution is authoritative when local CocoaPods is broken.
Report provider progress by phase: packaging, native compilation, submission Report provider progress by phase: packaging, native compilation, submission
queue, or store processing. Do not describe every failure as a rebuild. queue, or store processing. Do not describe every failure as a rebuild.
@@ -292,6 +309,24 @@ verify it, and commit it before starting the release from a clean worktree. Use
5.x, a non-clean prebuild against an already-generated iOS project fails while 5.x, a non-clean prebuild against an already-generated iOS project fails while
updating the widget's Xcode target. updating the widget's Xcode target.
**After native regeneration:** `expo prebuild --clean` can silently regress
checked-in native invariants (confirmed during the SDK 54 to 56 upgrade). Before
committing a regenerated project:
- Diff pbxproj signing and identity keys (PRODUCT_BUNDLE_IDENTIFIER,
CODE_SIGN_ENTITLEMENTS) against the last release tag; apple-targets can derive
a widget's bundle identifier from its folder name instead of the pinned config.
- Semantic-diff Info.plist and entitlements files against the last release tag;
prebuild can reintroduce dev-launcher-only keys (NSBonjourServices,
NSLocalNetworkUsageDescription, RCTMetroPort) and drop widget entitlements.
- Check android/gradle.properties for a reset `org.gradle.jvmargs` (prebuild
resets it to the stock -Xmx2048m/512m, which OOMs release Kotlin/CMake tasks).
- Run `npm run release:preflight`; it enforces the Android manifest media
permission removal and rejects the dev-launcher-only Info.plist keys.
- Treat `expo-doctor` unmaintained-package warnings as release blockers on an
SDK upgrade: a package absent from the new SDK's bundledNativeModules can fail
the iOS archive with no compatible release available.
**Out of memory:** **Out of memory:**
```bash ```bash
# Increase Gradle memory # Increase Gradle memory