Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d2b8ed0
feat(auth): migrate to Better Auth + OAuth 2.1 for MCP
andrew-bierman May 1, 2026
8e6cf90
fix(auth): replace raw typeof checks with @packrat/guards
andrew-bierman May 1, 2026
561b40c
fix(mcp): replace raw regex literals with magic-regexp + fix TS casts
andrew-bierman May 1, 2026
22e392c
chore(deps): move magic-regexp to workspace catalog
andrew-bierman May 1, 2026
6dafda0
fix(api): restore isAuthenticated on alltrails preview route
andrew-bierman May 1, 2026
d7c5a45
chore: sort apps/expo/package.json keys
andrew-bierman May 1, 2026
ef16d99
fix(casts): add safe-cast annotations to pass strict cast checker
andrew-bierman May 1, 2026
9debcc5
chore: update bun.lock after adding magic-regexp
andrew-bierman May 1, 2026
e891d57
docs: mark better-auth migration plan as completed
andrew-bierman May 1, 2026
6395c2e
refactor(auth): remove legacy JWT/token utilities and empty auth routes
andrew-bierman May 1, 2026
eb0bf68
test(auth): add comprehensive Better Auth integration tests
andrew-bierman May 1, 2026
9c4fe24
chore: remove stale nativewindui@1.1.0 patch
andrew-bierman May 1, 2026
d385b45
fix(db): split UUID+Better Auth migration into 6 working parts
mikib0 May 2, 2026
23f7ed0
fix(db): handle social feed tables in UUID migration 0045
mikib0 May 2, 2026
40b14f9
chore: update lockfile
mikib0 May 2, 2026
d7a4ef2
chore(api/auth): add static auth.config.ts stub for Better Auth CLI i…
mikib0 May 2, 2026
7409b3a
fix(api/db): add missing required better-auth fields
mikib0 May 2, 2026
02b9610
fix(api/better-auth): add missing jwks table to adapter schema config…
mikib0 May 2, 2026
d40e856
fix(api/auth): handle pre-migration bcrypt password hashes in Better …
mikib0 May 2, 2026
2352804
chore(api/tests): fix failing API tests caused by a missing `name` field
mikib0 May 2, 2026
0fd4bdf
fix(api/schemas): update userId and timestamp field types after UUID …
mikib0 May 3, 2026
8b9732e
fix(expo/auth): avoid logout on network failure
mikib0 May 3, 2026
e6c62ed
fix(api/auth): add expo server plugin to fix sign-out 403
mikib0 May 3, 2026
ce8d0f0
fix(expo/auth): annotate safe-casts to pass pre-push strict check
mikib0 May 3, 2026
d92d7c3
chore(expo): add expo-network dependency
mikib0 May 3, 2026
e2f1b8f
Merge remote-tracking branch 'origin/development' into feat/better-au…
mikib0 May 3, 2026
490b9a0
fix(api/db): make uuid migration resilient to missing social feed tables
mikib0 May 3, 2026
a9e92f2
fix(expo/auth): restore guest mode and reactive isAuthed sync
mikib0 May 3, 2026
bcb4221
fix(expo/auth): post-sign-out prompt, clear RQ cache, fix sign-in hang
mikib0 May 3, 2026
8108a15
fix(api/auth): register Apple provider for native id-token flow
mikib0 May 4, 2026
da32d86
fix(lint): resolve biome warnings for code quality
Copilot May 4, 2026
18566c5
fix(api): remove deleted/lastActiveAt fields from users table after B…
Copilot May 4, 2026
d9357df
fix(lint): remove unused variables and imports after Better Auth migr…
Copilot May 4, 2026
ce0496e
fix(types): remove lastActiveAt/deletedAt references and fix user id …
Copilot May 4, 2026
c557aeb
merge: resolve conflicts with development
andrew-bierman May 7, 2026
8ed5595
fix(merge): repair post-merge type breakage from development conflicts
andrew-bierman May 7, 2026
0605558
chore(merge): reconcile bun.lock with deps from development merge
andrew-bierman May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 44 additions & 52 deletions apps/expo/app/(app)/(tabs)/profile/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { clientEnvs } from '@packrat/env/expo-client';
import { isString } from '@packrat/guards';
import type { AlertMethods } from '@packrat/ui/nativewindui';
import {
ActivityIndicator,
Alert as AlertComponent,
Avatar,
AvatarFallback,
AvatarImage,
Expand All @@ -18,6 +16,7 @@ import {
import AsyncStorage from '@react-native-async-storage/async-storage';
import { AndroidTabBarInsetFix } from 'expo-app/components/AndroidTabBarInsetFix';
import { Icon } from 'expo-app/components/Icon';
import { isLoadingAtom, suppressSignOutNavAtom } from 'expo-app/features/auth/atoms/authAtoms';
import { withAuthWall } from 'expo-app/features/auth/hocs';
import { useAuth } from 'expo-app/features/auth/hooks/useAuth';
import { useUser } from 'expo-app/features/auth/hooks/useUser';
Expand All @@ -33,8 +32,8 @@ import { testIds } from 'expo-app/lib/testIds';
import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl';
import * as FileSystem from 'expo-file-system/legacy';
import { Link, router, Stack } from 'expo-router';
import * as Updates from 'expo-updates';
import { useRef, useState } from 'react';
import { useSetAtom } from 'jotai';
import { useState } from 'react';
import { Alert, Linking, Platform, Pressable, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';

Expand Down Expand Up @@ -246,44 +245,47 @@ function ListHeaderComponent() {

function ListFooterComponent() {
const { signOut } = useAuth();
const { colors } = useColorScheme();
const { t } = useTranslation();
const setIsLoading = useSetAtom(isLoadingAtom);
const setSuppressSignOutNav = useSetAtom(suppressSignOutNavAtom);

const alertRef = useRef<AlertMethods>(null);
const [isSigningOut, setIsSigningOut] = useState(false);

const handleSignOut = async () => {
try {
setIsSigningOut(true);
await signOut();
alertRef.current?.alert({
title: t('auth.loggedOut'),
message: t('auth.loggedOutMessage'),
materialIcon: { name: 'check-circle-outline', color: colors.green },
buttons: [
{
text: t('auth.stayLoggedOut'),
style: 'cancel',
onPress: async () => {
await AsyncStorage.setItem('skipped_login', 'true');
await Updates.reloadAsync();
},
setIsSigningOut(true);
await signOut();
setIsSigningOut(false);

// signOut() has completed: auth cleared, spinner showing, auto-navigation
// suppressed. Ask the user what to do next.
Alert.alert(
t('auth.loggedOut'),
t('auth.loggedOutMessage'),
[
{
text: t('auth.stayLoggedOut'),
onPress: async () => {
// Clear spinner first so AppLayout doesn't show auth screen,
// then release the suppress flag and navigate home as guest.
setIsLoading(false);
setSuppressSignOutNav(false);
await AsyncStorage.setItem('skipped_login', 'true');
router.replace('/');
},
{
text: t('auth.signInAgain'),
style: 'default',
onPress: async () => {
await AsyncStorage.setItem('skipped_login', 'false');
await Updates.reloadAsync();
},
},
{
text: t('auth.signInAgain'),
style: 'destructive',
onPress: () => {
// Release suppress while isLoadingAtom is still true — AppLayout's
// useEffect sees isLoadingGlobal=true && !isAuthed and navigates to
// /auth via the NativeTabs-safe useEffect path.
setSuppressSignOutNav(false);
},
],
});
} catch (error) {
console.error('Logout failed:', error);
} finally {
setIsSigningOut(false);
}
},
],
{ cancelable: false },
);
};

return (
Expand All @@ -294,22 +296,13 @@ function ListFooterComponent() {
disabled={isSigningOut}
onPress={() => {
if (hasUnsyncedChanges()) {
alertRef.current?.alert({
title: t('profile.syncInProgress'),
message: t('profile.syncMessage'),
materialIcon: { name: 'repeat' },
buttons: [
{
text: t('common.cancel'),
style: 'cancel',
},
{
text: t('auth.proceedLogOut'),
style: 'destructive',
onPress: handleSignOut,
},
],
});
// Use native Alert on both platforms so the dialog buttons are
// accessible to automated testing tools (custom portal-based
// dialogs are not surfaced in XCTest/UIAutomator accessibility trees).
Alert.alert(t('profile.syncInProgress'), t('profile.syncMessage'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('auth.logOut'), style: 'destructive', onPress: handleSignOut },
]);
return;
}
handleSignOut();
Expand All @@ -324,7 +317,6 @@ function ListFooterComponent() {
<Text className="text-destructive">{t('auth.logOut')}</Text>
)}
</Button>
<AlertComponent title="" buttons={[]} ref={alertRef} />
</View>
<AndroidTabBarInsetFix />
</>
Expand Down
89 changes: 73 additions & 16 deletions apps/expo/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { use$ } from '@legendapp/state/react';
import { ActivityIndicator } from '@packrat/ui/nativewindui';
import { ThemeToggle } from 'expo-app/components/ThemeToggle';
import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms';
import {
isLoadingAtom,
needsReauthAtom,
suppressSignOutNavAtom,
} from 'expo-app/features/auth/atoms/authAtoms';
import { useAuthInit } from 'expo-app/features/auth/hooks/useAuthInit';
import { isAuthed } from 'expo-app/features/auth/store';
import { getPackTemplateDetailOptions } from 'expo-app/features/pack-templates/utils/getPackTemplateDetailOptions';
import { getPackTemplateItemDetailOptions } from 'expo-app/features/pack-templates/utils/getPackTemplateItemDetailOptions';
import SyncBanner from 'expo-app/features/packs/components/SyncBanner';
Expand All @@ -10,10 +16,12 @@ import { getPackItemDetailOptions } from 'expo-app/features/packs/utils/getPackI
import { getTripDetailOptions } from 'expo-app/features/trips/utils/getTripDetailOptions';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import type { TranslationFunction } from 'expo-app/lib/i18n/types';
import { testIds } from 'expo-app/lib/testIds';
import 'expo-dev-client';
import { Stack } from 'expo-router';
import { type Href, router, Stack, useRouter } from 'expo-router';
import { useAtomValue } from 'jotai';
import { View } from 'react-native';
import { useEffect, useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

export {
Expand All @@ -23,11 +31,46 @@ export {

export default function AppLayout() {
const isLoading = useAuthInit();
const isAuthedValue = use$(isAuthed);
const { t } = useTranslation();
const needsReauth = useAtomValue(needsReauthAtom);
const isLoadingGlobal = useAtomValue(isLoadingAtom);
const suppressSignOutNav = useAtomValue(suppressSignOutNavAtom);
const insets = useSafeAreaInsets();
// Latches true once we dispatch router.replace('/auth') on sign-out.
// Keeps the spinner rendered until AppLayout unmounts so that
// auth/index.tsx resetting isLoadingAtom=false never causes AppLayout
// to re-render its Stack mid-transition. If the Stack re-initialized
// while the root navigator was still committing the replace, it would
// re-register with React Navigation and override the in-flight navigation,
// landing the user back on the Trips/Profile screen instead of auth.
const hasNavigatedToAuthRef = useRef(false);

if (isLoading) {
useEffect(() => {
// suppressSignOutNav is true while profile/handleSignOut is showing the
// post-sign-out prompt; skip auto-navigation until the user picks an option.
if (isLoadingGlobal && !isAuthedValue && !suppressSignOutNav) {
hasNavigatedToAuthRef.current = true;
// safe-cast: '/auth' is a compile-time string literal recognised by expo-router
router.replace('/auth' as Href);
}
}, [isLoadingGlobal, isAuthedValue, suppressSignOutNav]);

// If the user has re-authenticated while AppLayout stayed mounted (Expo Router
// keeps the (app) screen in the stack during the auth transition), clear the
// sign-out latch so the spinner doesn't stay on indefinitely.
if (isAuthedValue && hasNavigatedToAuthRef.current) {
hasNavigatedToAuthRef.current = false;
}

// Show spinner when: (a) auth initialising on cold start, OR (b) a sign-out
// is in progress (isLoadingAtom=true) AND the user is no longer authenticated.
// The spinner unmounts NativeTabs so the useEffect above can dispatch to the
// root Stack. The !isAuthedValue guard keeps the Stack visible during re-auth
// sign-in, where isLoadingAtom is also true but the user is still authed.
// hasNavigatedToAuthRef keeps the spinner until AppLayout actually unmounts
// after the router.replace('/auth') transition completes.
if (isLoading || (isLoadingGlobal && !isAuthedValue) || hasNavigatedToAuthRef.current) {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#3B82F6" />
Expand Down Expand Up @@ -285,12 +328,19 @@ const getSettingsOptions = (t: TranslationFunction) =>
headerRight: () => <ThemeToggle />,
}) as const;

const getTripNewOptions = (t: TranslationFunction) =>
({
title: t('trips.createTrip'),
presentation: 'modal',
animation: 'slide_from_bottom',
}) as const;
const getTripNewOptions = (t: TranslationFunction) => ({
title: t('trips.createTrip'),
presentation: 'modal' as const,
animation: 'slide_from_bottom' as const,
headerLeft: () => {
const router = useRouter();
return (
<Pressable testID={testIds.trips.cancelBtn} onPress={() => router.back()} className="px-2">
<Text className="text-primary">{t('common.cancel')}</Text>
</Pressable>
);
},
});

const getTripEditOptions = (t: TranslationFunction) =>
({
Expand All @@ -311,12 +361,19 @@ const CONSENT_MODAL_OPTIONS = {
animation: 'fade_from_bottom', // for android
} as const;

const getPackNewOptions = (t: TranslationFunction) =>
({
title: t('packs.createPack'),
presentation: 'modal',
animation: 'fade_from_bottom', // for android
}) as const;
const getPackNewOptions = (t: TranslationFunction) => ({
title: t('packs.createPack'),
presentation: 'modal' as const,
animation: 'fade_from_bottom' as const,
headerLeft: () => {
const router = useRouter();
return (
<Pressable testID={testIds.packs.cancelBtn} onPress={() => router.back()} className="px-2">
<Text className="text-primary">{t('common.cancel')}</Text>
</Pressable>
);
},
});

const getItemNewOptions = (t: TranslationFunction) =>
({
Expand Down
5 changes: 3 additions & 2 deletions apps/expo/app/(app)/ai-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import { LocationContext } from 'expo-app/features/ai/components/LocationContext
import { CustomChatTransport } from 'expo-app/features/ai/lib/CustomChatTransport';
import { getLocalModel, initLocalModel } from 'expo-app/features/ai/lib/localModelManager';
import { createLocalTools } from 'expo-app/features/ai/lib/tools';
import { tokenAtom } from 'expo-app/features/auth/atoms/authAtoms';
import { useActiveLocation } from 'expo-app/features/weather/hooks';
import type { WeatherLocation } from 'expo-app/features/weather/types';
import { authClient } from 'expo-app/lib/auth-client';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { getContextualGreeting, getContextualSuggestions } from 'expo-app/utils/chatContextHelpers';
Expand Down Expand Up @@ -90,7 +90,8 @@ export default function AIChat() {
const locationRef = React.useRef(context.location);
locationRef.current = context.location;

const token = useAtomValue(tokenAtom);
const { data: _authSession } = authClient.useSession();
const token = _authSession?.session?.token ?? null;
Comment on lines +93 to +94
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't send Authorization: Bearer null.

When the session is still loading or the user is signed out, this builds a bogus bearer credential for every remote chat request. Omit the header until a real token exists.

Suggested fix
-  const token = _authSession?.session?.token ?? null;
+  const token = _authSession?.session?.token;-      headers: {
-        Authorization: `Bearer ${token}`,
-      },
+      headers: token
+        ? {
+            Authorization: `Bearer ${token}`,
+          }
+        : {},

Also applies to: 136-138

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/app/`(app)/ai-chat.tsx around lines 93 - 94, The code currently
builds a token variable from authClient.useSession() and may send
"Authorization: Bearer null" when no session exists; change the request header
construction so the Authorization header is only added when token is a
non-null/non-undefined string (i.e. check _authSession?.session?.token
truthiness before adding the header). Update places referencing the token (the
token const and the remote chat request headers around the fetch/axios calls at
lines ~136-138) to conditionally include Authorization and omit the header
entirely when token is null.

const [input, setInput] = React.useState('');
const [lastUserMessage, setLastUserMessage] = React.useState('');
const [previousMessages, setPreviousMessages] = React.useState<UIMessage[]>([]);
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/app/(app)/current-pack/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export default function CurrentPackScreen() {
</Text>
<Text variant="subhead" className="mt-1 text-muted-foreground">
{t('packs.lastUpdated', {
time: getRelativeTime(pack.localUpdatedAt ?? pack.updatedAt, t as any),
time: getRelativeTime(pack.localUpdatedAt ?? pack.updatedAt, t),
})}
</Text>
</View>
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/app/(app)/feed/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ActivityIndicator, View } from 'react-native';

export default function PostDetailRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const currentUserId = userStore.id.peek() as number | undefined;
const currentUserId = userStore.id.peek() as string | undefined;

const { data: post, isLoading } = useQuery({
queryKey: ['feed', Number(id)],
Expand Down
4 changes: 2 additions & 2 deletions apps/expo/app/(app)/recent-packs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function RecentPackCard({ pack }: { pack: Pack }) {
{pack.totalWeight ?? 0} g
</Text>
<Text variant="footnote" className="text-muted-foreground">
{getRelativeTime(pack.localCreatedAt ?? pack.createdAt, t as any)}
{getRelativeTime(pack.localCreatedAt ?? pack.createdAt, t)}
</Text>
</View>
</View>
Expand All @@ -45,7 +45,7 @@ function RecentPackCard({ pack }: { pack: Pack }) {
</View>
<Text variant="caption1" className="text-muted-foreground">
{t('packs.lastUpdated', {
time: getRelativeTime(pack.localUpdatedAt ?? pack.updatedAt, t as any),
time: getRelativeTime(pack.localUpdatedAt ?? pack.updatedAt, t),
})}
</Text>
</View>
Expand Down
2 changes: 1 addition & 1 deletion apps/expo/app/auth/(login)/reset-password.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export default function ResetPasswordScreen() {
setIsLoading(true);

// Call the API to reset the password
await resetPassword(params.email, { code: params.code, newPassword: value.password });
await resetPassword(params.email, { token: params.code, newPassword: value.password });

// Show success message and navigate to login
Alert.alert(t('common.success'), t('auth.resetPasswordSuccess'), [
Expand Down
23 changes: 3 additions & 20 deletions apps/expo/features/auth/atoms/authAtoms.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,8 @@
import kvStorage from 'expo-app/lib/kvStorage';
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';

// User type definition
export type User = {
id: number;
email: string;
firstName?: string;
lastName?: string;
emailVerified: boolean;
};

// Token storage atom
export const tokenAtom = atomWithStorage<string | null>('access_token', null, kvStorage);

export const refreshTokenAtom = atomWithStorage<string | null>('refresh_token', null, kvStorage);

// Loading state atom
export const isLoadingAtom = atom(false);

export const redirectToAtom = atom<string>('/');

// Re-authentication state
export const needsReauthAtom = atom(false);
// Prevents AppLayout's useEffect from auto-navigating to /auth during the
// sign-out flow so the profile screen can show a post-sign-out prompt first.
export const suppressSignOutNavAtom = atom(false);
Loading
Loading