Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
## [Unreleased]

### Added
- **Mobile Biometric Authentication:** Implemented FaceID/TouchID login support.
- **Features:**
- "Enable Biometric Login" toggle in Account settings (visible only if hardware supports it).
- "Login with FaceID" button on Login screen.
- Secure credential storage using `expo-secure-store`.
- Automatic handling of user switching (disables biometrics if new user logs in).
- Syncs tokens to secure storage on refresh.
- **Technical:** Integrated `expo-local-authentication` and `expo-secure-store`. Updated `mobile/context/AuthContext.js` with secure storage logic. Added `NSFaceIDUsageDescription` to iOS config.

- **Password Strength Meter:** Added a visual password strength indicator to the signup form.
- **Features:**
- Real-time strength calculation (Length, Uppercase, Lowercase, Number, Symbol).
Expand Down
10 changes: 10 additions & 0 deletions .Jules/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,16 @@ Commonly used components:
- `<Portal>` and `<Modal>` for overlays
- `<ActivityIndicator>` for loading states

### Mobile Authentication & Biometrics

**Date:** 2026-02-14
**Context:** Implementing FaceID/TouchID login

1. **Dual Storage Strategy:** Use `AsyncStorage` for active session (cleared on logout) and `SecureStore` for persistent biometric credentials (kept on logout).
2. **User Switching:** When a new user logs in manually, check if their ID matches the one in `SecureStore`. If not, disable biometrics and clear secure storage to prevent account crossover.
3. **Token Sync:** Always update `SecureStore` when tokens refresh (in `useEffect`), otherwise biometric login will fail with expired tokens.
4. **iOS Config:** Must add `NSFaceIDUsageDescription` to `app.json` > `ios` > `infoPlist` to avoid crashes.

### Safe Area Pattern

**Date:** 2026-01-01
Expand Down
12 changes: 6 additions & 6 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,12 @@

### Mobile

- [ ] **[ux]** Biometric authentication option
- Files: `mobile/context/AuthContext.js`, add local auth
- Context: FaceID/TouchID for quick login
- Impact: Faster, more secure login
- Size: ~70 lines
- Added: 2026-01-01
- [x] **[ux]** Biometric authentication option
- Completed: 2026-02-14
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Completion date is in the future relative to PR creation.

The task shows a completion date of 2026-02-14, but this PR was created on 2026-02-09. This creates a 5-day discrepancy. Either the completion date should reflect when the work was actually finished (likely on or before 2026-02-09), or the task should not yet be marked as complete.

📅 Suggested fix
- Completed: 2026-02-14
+ Completed: 2026-02-09
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- Completed: 2026-02-14
- Completed: 2026-02-09
🤖 Prompt for AI Agents
In @.Jules/todo.md at line 128, The "Completed: 2026-02-14" entry in
.Jules/todo.md is a future date relative to the PR (2026-02-09); update the
"Completed: 2026-02-14" line to a valid date on or before 2026-02-09 (e.g.,
"Completed: 2026-02-09") or remove the "Completed:" line entirely to leave the
task unmarked as complete so the file accurately reflects the actual completion
status.

- Files: `mobile/context/AuthContext.js`, `mobile/screens/LoginScreen.js`, `mobile/screens/AccountScreen.js`
- Context: Integrated FaceID/TouchID for quick, secure login
- Impact: Faster login experience while maintaining security
- Size: ~150 lines
Comment on lines +127 to +132
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider adding this task to the "✅ Completed Tasks" section for consistency.

Other completed tasks in this file follow a pattern of appearing both in their original priority section (marked with [x]) and duplicated in the "✅ Completed Tasks" section at the bottom (lines 144-170). For example:

  • "Comprehensive empty states" appears at lines 30-35 and again at 146-149
  • "Error boundary with retry" appears at lines 37-42 and again at 159-162
  • "Pull-to-refresh with haptic feedback" appears at lines 53-58 and again at 163-166

The biometric authentication task should follow the same pattern to maintain documentation consistency.

🤖 Prompt for AI Agents
In @.Jules/todo.md around lines 127 - 132, Add the completed "Biometric
authentication option" entry to the "✅ Completed Tasks" section so the task
appears both in its original priority section and in the completed list; locate
the original checklist line starting with "[x] **[ux]** Biometric authentication
option" and copy its summary (including Completed date, Files, Context, Impact,
Size) into the existing "✅ Completed Tasks" block at the bottom, matching the
formatting used by other duplicated entries like "Comprehensive empty states"
and "Error boundary with retry."


---

Expand Down
3 changes: 2 additions & 1 deletion mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSPhotoLibraryUsageDescription": "Allow Splitwiser to select a group icon from your photo library."
"NSPhotoLibraryUsageDescription": "Allow Splitwiser to select a group icon from your photo library.",
"NSFaceIDUsageDescription": "Allow Splitwiser to use FaceID for quick and secure login."
}
},
"android": {
Expand Down
135 changes: 133 additions & 2 deletions mobile/context/AuthContext.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createContext, useEffect, useState } from "react";
import * as authApi from "../api/auth";
import * as LocalAuthentication from "expo-local-authentication";
import * as SecureStore from "expo-secure-store";
import {
clearAuthTokens,
setAuthTokens,
Expand All @@ -14,14 +16,26 @@ export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(null);
const [refresh, setRefresh] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isBiometricSupported, setIsBiometricSupported] = useState(false);
const [isBiometricEnabled, setIsBiometricEnabled] = useState(false);

// Load token and user data from AsyncStorage on app start
useEffect(() => {
const loadStoredAuth = async () => {
try {
const storedToken = await AsyncStorage.getItem("auth_token");
const storedRefresh = await AsyncStorage.getItem("refresh_token");
const storedUser = await AsyncStorage.getItem("user_data");
const storedUser = await AsyncStorage.getItem("user_data");
const biometricEnabled = await AsyncStorage.getItem("biometric_enabled");

if (biometricEnabled === 'true') {
setIsBiometricEnabled(true);
}

// Check biometric support
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
setIsBiometricSupported(hasHardware && isEnrolled);

if (storedToken && storedUser) {
setToken(storedToken);
Expand Down Expand Up @@ -123,6 +137,20 @@ export const AuthProvider = ({ children }) => {
? { ...userData, _id: userData.id }
: userData;
setUser(normalizedUser);

// If biometric is enabled, check if we are logging in as a different user
if (isBiometricEnabled) {
const storedUserData = await SecureStore.getItemAsync("secure_user_data");
const storedUser = storedUserData ? JSON.parse(storedUserData) : null;

// If there is a stored user and it doesn't match the new user, disable biometrics
// to prevent account crossover/confusion.
if (storedUser && storedUser._id !== normalizedUser._id) {
await disableBiometrics();
}
// If same user (or no stored user), the useEffect will handle syncing the new tokens
}

return true;
} catch (error) {
console.error(
Expand All @@ -148,10 +176,19 @@ export const AuthProvider = ({ children }) => {

const logout = async () => {
try {
// Clear stored authentication data
// Clear stored authentication data from AsyncStorage
await AsyncStorage.removeItem("auth_token");
await AsyncStorage.removeItem("refresh_token");
await AsyncStorage.removeItem("user_data");

// NOTE: We deliberately do NOT clear SecureStore if biometric is enabled
// This allows "Login with FaceID" to work after logout
if (!isBiometricEnabled) {
await SecureStore.deleteItemAsync("secure_auth_token");
await SecureStore.deleteItemAsync("secure_refresh_token");
await SecureStore.deleteItemAsync("secure_user_data");
}

} catch (error) {
console.error("Failed to clear stored authentication:", error);
}
Expand All @@ -162,6 +199,95 @@ export const AuthProvider = ({ children }) => {
await clearAuthTokens();
};

const enableBiometrics = async () => {
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to enable biometric login",
});

if (result.success) {
if (token && refresh && user) {
await SecureStore.setItemAsync("secure_auth_token", token);
await SecureStore.setItemAsync("secure_refresh_token", refresh);
await SecureStore.setItemAsync("secure_user_data", JSON.stringify(user));
await AsyncStorage.setItem("biometric_enabled", "true");
setIsBiometricEnabled(true);
return true;
}
}
return false;
} catch (error) {
console.error("Failed to enable biometrics:", error);
return false;
}
};

const disableBiometrics = async () => {
try {
await SecureStore.deleteItemAsync("secure_auth_token");
await SecureStore.deleteItemAsync("secure_refresh_token");
await SecureStore.deleteItemAsync("secure_user_data");
await AsyncStorage.setItem("biometric_enabled", "false");
setIsBiometricEnabled(false);
return true;
} catch (error) {
console.error("Failed to disable biometrics:", error);
return false;
}
};

const loginWithBiometrics = async () => {
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Login with biometrics",
});

if (result.success) {
const storedToken = await SecureStore.getItemAsync("secure_auth_token");
const storedRefresh = await SecureStore.getItemAsync("secure_refresh_token");
const storedUser = await SecureStore.getItemAsync("secure_user_data");

if (storedToken && storedUser) {
setToken(storedToken);
setRefresh(storedRefresh);
await setAuthTokens({
newAccessToken: storedToken,
newRefreshToken: storedRefresh || storedToken, // Fallback if refresh missing
});

const parsed = JSON.parse(storedUser);
const normalized = parsed?._id
? parsed
: parsed?.id
? { ...parsed, _id: parsed.id }
: parsed;
setUser(normalized);
return true;
}
}
return false;
} catch (error) {
console.error("Biometric login failed:", error);
return false;
}
};
Comment on lines +239 to +273
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: access token used as refresh token fallback on Line 255.

newRefreshToken: storedRefresh || storedToken, // Fallback if refresh missing

If storedRefresh is missing, this sends the access token as a refresh token. The server will reject it (different token types), and the user will experience a silent auth breakdown shortly after a "successful" biometric login. Remove the fallback and handle the missing refresh token explicitly.

Additionally, loginWithBiometrics does not validate whether the stored tokens are still valid (e.g., expired). After biometric authentication succeeds, the user may be immediately logged out by the API client's interceptor if tokens have expired. Consider performing a token refresh call (using authApi.refresh) after restoring stored tokens.

Proposed fix
         if (storedToken && storedUser) {
           setToken(storedToken);
           setRefresh(storedRefresh);
           await setAuthTokens({
             newAccessToken: storedToken,
-            newRefreshToken: storedRefresh || storedToken, // Fallback if refresh missing
+            newRefreshToken: storedRefresh,
           });
 
+          // Attempt a token refresh to ensure credentials are still valid
+          if (storedRefresh) {
+            try {
+              const refreshResponse = await authApi.refresh(storedRefresh);
+              const { access_token, refresh_token } = refreshResponse.data;
+              setToken(access_token);
+              setRefresh(refresh_token);
+              await setAuthTokens({
+                newAccessToken: access_token,
+                newRefreshToken: refresh_token,
+              });
+            } catch (refreshError) {
+              // Refresh failed — tokens are stale, force re-login
+              console.error("Stored tokens expired:", refreshError);
+              await disableBiometrics();
+              return false;
+            }
+          } else {
+            // No refresh token stored — cannot guarantee session validity
+            await disableBiometrics();
+            return false;
+          }
+
           const parsed = JSON.parse(storedUser);
🤖 Prompt for AI Agents
In `@mobile/context/AuthContext.js` around lines 239 - 273, In
loginWithBiometrics, remove the unsafe fallback that uses storedToken as a
refresh token (the newRefreshToken: storedRefresh || storedToken pattern) and
instead treat a missing storedRefresh as an explicit error path: do not call
setAuthTokens with a fake refresh token, surface the missing refresh token
(e.g., return false or trigger re-login/clear state) and keep
setToken/setRefresh only with real values; additionally, after restoring
storedToken and storedRefresh, call the auth refresh endpoint (e.g.,
authApi.refresh) to validate/refresh tokens before marking the user
authenticated so expired tokens are handled, and ensure the code references the
same SecureStore keys ("secure_auth_token", "secure_refresh_token",
"secure_user_data") and functions (loginWithBiometrics, setAuthTokens, setToken,
setRefresh, setUser) when implementing the explicit missing-refresh handling and
refresh call.


// Sync tokens and user data to SecureStore whenever they change, if biometrics is enabled
useEffect(() => {
const syncSecureStore = async () => {
if (isBiometricEnabled && token && refresh && user) {
try {
await SecureStore.setItemAsync("secure_auth_token", token);
await SecureStore.setItemAsync("secure_refresh_token", refresh);
await SecureStore.setItemAsync("secure_user_data", JSON.stringify(user));
} catch (error) {
console.error("Failed to sync secure store:", error);
}
}
};
syncSecureStore();
}, [token, refresh, user, isBiometricEnabled]);

const updateUserInContext = (updatedUser) => {
// Normalize on updates too
const normalizedUser = updatedUser?._id
Expand All @@ -178,10 +304,15 @@ export const AuthProvider = ({ children }) => {
user,
token,
isLoading,
isBiometricSupported,
isBiometricEnabled,
login,
signup,
logout,
updateUserInContext,
enableBiometrics,
disableBiometrics,
loginWithBiometrics
}}
>
{children}
Expand Down
23 changes: 23 additions & 0 deletions mobile/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"expo": "^54.0.25",
"expo-haptics": "~15.0.8",
"expo-image-picker": "~17.0.8",
"expo-local-authentication": "^17.0.8",
"expo-secure-store": "^15.0.8",
Comment on lines +21 to +22
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Inconsistent version range operators for Expo packages.

The existing Expo packages (expo-haptics, expo-image-picker, expo-status-bar) use tilde (~) to pin to patch-level updates, but the new packages use caret (^). For Expo SDK-aligned packages, tilde is preferred to avoid pulling in minor versions that may be incompatible with the current SDK version.

Proposed fix: align with existing convention
-    "expo-local-authentication": "^17.0.8",
-    "expo-secure-store": "^15.0.8",
+    "expo-local-authentication": "~17.0.8",
+    "expo-secure-store": "~15.0.8",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"expo-local-authentication": "^17.0.8",
"expo-secure-store": "^15.0.8",
"expo-local-authentication": "~17.0.8",
"expo-secure-store": "~15.0.8",
🤖 Prompt for AI Agents
In `@mobile/package.json` around lines 21 - 22, The new Expo dependencies
expo-local-authentication and expo-secure-store use caret ranges (^) but the
project pins Expo packages with tilde (~) for SDK stability (see expo-haptics,
expo-image-picker, expo-status-bar); update the version specifiers for
expo-local-authentication and expo-secure-store in package.json from ^17.0.8 and
^15.0.8 to ~17.0.8 and ~15.0.8 respectively so they follow the existing tilde
convention and avoid pulling incompatible minor versions.

"expo-status-bar": "~3.0.8",
"react": "19.1.0",
"react-dom": "19.1.0",
Expand Down
47 changes: 45 additions & 2 deletions mobile/screens/AccountScreen.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { useContext } from "react";
import { Alert, StyleSheet, View } from "react-native";
import { Appbar, Avatar, Divider, List, Text } from "react-native-paper";
import { Appbar, Avatar, Divider, List, Switch, Text } from "react-native-paper";
import { HapticListItem } from '../components/ui/HapticList';
import { AuthContext } from "../context/AuthContext";

const AccountScreen = ({ navigation }) => {
const { user, logout } = useContext(AuthContext);
const {
user,
logout,
isBiometricSupported,
isBiometricEnabled,
enableBiometrics,
disableBiometrics
} = useContext(AuthContext);

const handleLogout = () => {
logout();
Expand All @@ -15,6 +22,20 @@ const AccountScreen = ({ navigation }) => {
Alert.alert("Coming Soon", "This feature is not yet implemented.");
};

const toggleBiometric = async (value) => {
if (value) {
const success = await enableBiometrics();
if (!success) {
Alert.alert("Error", "Failed to enable biometric authentication.");
}
} else {
const success = await disableBiometrics();
if (!success) {
Alert.alert("Error", "Failed to disable biometric authentication.");
}
}
};

return (
<View style={styles.container}>
<Appbar.Header>
Expand Down Expand Up @@ -52,6 +73,28 @@ const AccountScreen = ({ navigation }) => {
accessibilityRole="button"
/>
<Divider />

{isBiometricSupported && (
<>
<HapticListItem
title="Biometric Login"
description="Use FaceID/TouchID to login"
left={() => <List.Icon icon="face-recognition" />}
right={() => (
<Switch
value={isBiometricEnabled}
onValueChange={toggleBiometric}
/>
)}
onPress={() => toggleBiometric(!isBiometricEnabled)}
accessibilityLabel="Enable Biometric Login"
accessibilityRole="switch"
accessibilityState={{ checked: isBiometricEnabled }}
/>
<Divider />
</>
)}
Comment on lines +77 to +96
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential double-toggle: Switch.onValueChange and HapticListItem.onPress may both fire on a single tap.

When the user taps the Switch area, React Native Paper's List.Item may also propagate the press event to its onPress handler (Line 89), causing toggleBiometric to be called twice in quick succession (e.g., enable → immediately disable). This results in a no-op or flicker from the user's perspective, and two round-trips to SecureStore.

Consider removing the onPress on the HapticListItem entirely and letting the Switch be the sole toggle trigger, or add a guard (e.g., a debounce or isToggling state) to prevent concurrent calls.

Proposed fix: remove redundant onPress
               <HapticListItem
                 title="Biometric Login"
                 description="Use FaceID/TouchID to login"
                 left={() => <List.Icon icon="face-recognition" />}
                 right={() => (
                   <Switch
                     value={isBiometricEnabled}
                     onValueChange={toggleBiometric}
                   />
                 )}
-                onPress={() => toggleBiometric(!isBiometricEnabled)}
                 accessibilityLabel="Enable Biometric Login"
                 accessibilityRole="switch"
                 accessibilityState={{ checked: isBiometricEnabled }}
               />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{isBiometricSupported && (
<>
<HapticListItem
title="Biometric Login"
description="Use FaceID/TouchID to login"
left={() => <List.Icon icon="face-recognition" />}
right={() => (
<Switch
value={isBiometricEnabled}
onValueChange={toggleBiometric}
/>
)}
onPress={() => toggleBiometric(!isBiometricEnabled)}
accessibilityLabel="Enable Biometric Login"
accessibilityRole="switch"
accessibilityState={{ checked: isBiometricEnabled }}
/>
<Divider />
</>
)}
{isBiometricSupported && (
<>
<HapticListItem
title="Biometric Login"
description="Use FaceID/TouchID to login"
left={() => <List.Icon icon="face-recognition" />}
right={() => (
<Switch
value={isBiometricEnabled}
onValueChange={toggleBiometric}
/>
)}
accessibilityLabel="Enable Biometric Login"
accessibilityRole="switch"
accessibilityState={{ checked: isBiometricEnabled }}
/>
<Divider />
</>
)}
🤖 Prompt for AI Agents
In `@mobile/screens/AccountScreen.js` around lines 77 - 96, The HapticListItem’s
onPress and the Switch’s onValueChange can both call toggleBiometric causing a
double-toggle; remove the redundant press handler on HapticListItem (the
fragment containing HapticListItem) so only the Switch triggers toggling, or
alternatively add a short guard state (e.g., isToggling) around toggleBiometric
to ignore concurrent calls; locate HapticListItem, the Switch
(value={isBiometricEnabled} onValueChange={toggleBiometric}), and the
toggleBiometric function to implement the chosen fix.


<HapticListItem
title="Send Feedback"
left={() => <List.Icon icon="message-alert-outline" />}
Expand Down
Loading
Loading