-
Notifications
You must be signed in to change notification settings - Fork 26
[Mobile] Add biometric authentication option (FaceID/TouchID) #322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,125 +1,5 @@ | ||
| # Splitwiser UI/UX Changelog | ||
|
|
||
| > All UI/UX changes made by Jules automated enhancement agent. | ||
|
|
||
| --- | ||
| # Changelog | ||
|
|
||
| ## [Unreleased] | ||
| - **[ux] Mobile**: Added biometric authentication option (FaceID/TouchID) to enable quicker and more secure logins for returning users. Implemented the feature directly in `AuthContext` with a toggle in `AccountScreen` and a fallback/login button in `LoginScreen`. | ||
|
|
||
| ### Added | ||
| - **Password Strength Meter:** Added a visual password strength indicator to the signup form. | ||
| - **Features:** | ||
| - Real-time strength calculation (Length, Uppercase, Lowercase, Number, Symbol). | ||
| - Visual feedback with segmented progress bar and color coding. | ||
| - Specific criteria checklist (6+ chars, Mixed case, Number, Symbol). | ||
| - Dual-theme support (Neobrutalism & Glassmorphism). | ||
| - Accessible ARIA live region for screen readers. | ||
| - **Technical:** Created `web/components/ui/PasswordStrength.tsx`. Integrated into `web/pages/Auth.tsx`. | ||
|
|
||
| - **Mobile Haptics:** Implemented system-wide haptic feedback for all interactive elements. | ||
| - **Features:** | ||
| - Created `HapticButton`, `HapticIconButton`, `HapticFAB`, `HapticCard`, `HapticList`, `HapticCheckbox`, `HapticMenu`, `HapticSegmentedButtons`, `HapticAppbar` (including `HapticAppbarAction`, `HapticAppbarBackAction`) wrappers. | ||
| - Integrated into all screens (`Home`, `GroupDetails`, `AddExpense`, `Friends`, `Account`, `EditProfile`, `Login`, `Signup`, `JoinGroup`, `GroupSettings`, `SplitwiseImport`). | ||
| - Uses `expo-haptics` with `Light` impact style for subtle feedback. | ||
| - **Technical:** Centralized haptic logic in `mobile/components/ui/` to ensure consistency and maintainability. | ||
|
|
||
| - **Mobile Accessibility:** Completed accessibility audit for all mobile screens. | ||
| - **Features:** | ||
| - Added `accessibilityLabel` to all interactive elements (buttons, inputs, list items). | ||
| - Added `accessibilityRole` to ensure screen readers identify element types correctly. | ||
| - Added `accessibilityHint` for clearer context on destructive actions or complex interactions. | ||
| - Covered Auth, Dashboard, Groups, and Utility screens. | ||
| - **Technical:** Updated all files in `mobile/screens/` to compliant with React Native accessibility standards. | ||
|
|
||
| - **Mobile Pull-to-Refresh:** Implemented native pull-to-refresh interactions with haptic feedback for key lists. | ||
| - **Features:** | ||
| - Integrated `RefreshControl` into `HomeScreen`, `FriendsScreen`, and `GroupDetailsScreen`. | ||
| - Added haptic feedback (`Haptics.ImpactFeedbackStyle.Light`) on refresh trigger. | ||
| - Separated 'isRefreshing' state from 'isLoading' to prevent full-screen spinner interruptions. | ||
| - Themed the refresh spinner using `react-native-paper`'s primary color. | ||
| - **Technical:** Installed `expo-haptics`. Refactored data fetching logic to support silent updates. | ||
|
|
||
| - **Confirmation Dialog System:** Replaced browser's native `alert`/`confirm` with a custom, accessible, and themed modal system. | ||
| - **Features:** | ||
| - Dual-theme support (Glassmorphism & Neobrutalism). | ||
| - Asynchronous `useConfirm` hook returning a Promise. | ||
| - Specialized variants (danger, warning, info) with appropriate styling and icons. | ||
| - Fully accessible `Modal` component (added `role="dialog"`, `aria-labelledby`, `aria-modal`). | ||
| - **Technical:** Created `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`. Updated `web/pages/GroupDetails.tsx` to use the new system. | ||
|
|
||
| - **Error Boundary System:** Implemented a global React Error Boundary to catch render errors gracefully. | ||
| - **Features:** | ||
| - Dual-theme support (Glassmorphism & Neobrutalism) for the error fallback UI. | ||
| - "Retry" button to reset error state and re-render. | ||
| - "Home" button to navigate back to safety. | ||
| - Captures errors in `AppRoutes` and displays a user-friendly message instead of a white screen. | ||
| - **Technical:** Created `web/components/ErrorBoundary.tsx` using a hybrid Class+Functional approach to support Hooks in the fallback UI. Integrated into `web/App.tsx`. | ||
|
|
||
| - Inline form validation in Auth page with real-time feedback and proper ARIA accessibility support (`aria-invalid`, `aria-describedby`, `role="alert"`). | ||
| - Dashboard skeleton loading state (`DashboardSkeleton`) to improve perceived performance during data fetch. | ||
| - Comprehensive `EmptyState` component for Groups and Friends pages to better guide new users. | ||
| - Toast notification system (`ToastContext`, `Toast` component) for providing non-blocking user feedback. | ||
| - Keyboard navigation support for Groups page, enabling accessibility for power users. | ||
|
|
||
| ### Changed | ||
| - **Web App:** Refactored `GroupDetails` destructive actions (Delete Group, Delete Expense, Leave Group, Remove Member) to use the new `ConfirmDialog` instead of `window.confirm`. | ||
| - **Accessibility:** Updated `Modal` component to include proper ARIA roles and labels, fixing a long-standing accessibility gap. | ||
| - Updated JULES_PROMPT.md based on review of successful PRs: | ||
| - Emphasized complete system implementation over piecemeal changes | ||
| - Added best practices from successful PRs (Toast system, keyboard navigation iteration) | ||
| - Refined task categories to focus on complete features | ||
| - Enhanced validation checklist | ||
| - Added implementation phases guide | ||
| - Documented successful patterns to repeat | ||
|
|
||
| ### Planned | ||
| - See `todo.md` for queued tasks | ||
|
|
||
| --- | ||
|
|
||
| ## [2026-01-13] - Prompt Optimization | ||
|
|
||
| ### Changed | ||
| - **Streamlined JULES_PROMPT.md** (336 → 179 lines, 47% reduction): | ||
| - Removed completed task lists (moved to changelog/knowledge) | ||
| - Removed redundant "best practices" examples | ||
| - Consolidated duplicate sections | ||
| - Kept only actionable guidance | ||
| - **Reviewed merged PRs** (#227, #236, #234, #226, #225) | ||
| - **Updated knowledge.md** with detailed PR reviews and successful patterns | ||
|
|
||
| **Key Insights:** | ||
| 1. Complete systems > piecemeal changes | ||
| 2. Accessibility and theme support from the start | ||
| 3. Semantic HTML over manual ARIA when possible | ||
| 4. Multiple commits in PRs were often from review feedback, not agent iteration | ||
|
|
||
| **Files Modified:** | ||
| - `.jules/JULES_PROMPT.md` (streamlined and optimized) | ||
| - `.jules/knowledge.md` (added PR review section) | ||
| - `.jules/changelog.md` | ||
|
|
||
| --- | ||
|
|
||
| ## [2026-01-01] - Initial Setup | ||
|
|
||
| ### Added | ||
| - Created Jules agent documentation and tracking system | ||
| - `.Jules/JULES_PROMPT.md` - Main agent instructions | ||
| - `.Jules/todo.md` - Task queue with prioritized improvements | ||
| - `.Jules/knowledge.md` - Codebase knowledge base | ||
| - `.Jules/changelog.md` - This changelog | ||
|
|
||
| ### Analysis Completed | ||
| - Full audit of `web/` application structure | ||
| - Full audit of `mobile/` application structure | ||
| - Identified accessibility gaps | ||
| - Identified UX improvement opportunities | ||
| - Documented theming system patterns | ||
| - Documented component APIs | ||
|
|
||
| **Files Created:** | ||
| - `.jules/JULES_PROMPT.md` | ||
| - `.jules/todo.md` | ||
| - `.jules/knowledge.md` | ||
| - `.jules/changelog.md` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import AsyncStorage from "@react-native-async-storage/async-storage"; | ||
| import { createContext, useEffect, useState } from "react"; | ||
| import * as LocalAuthentication from "expo-local-authentication"; | ||
| import * as authApi from "../api/auth"; | ||
| import { | ||
| clearAuthTokens, | ||
|
|
@@ -14,30 +15,60 @@ export const AuthProvider = ({ children }) => { | |
| const [token, setToken] = useState(null); | ||
| const [refresh, setRefresh] = useState(null); | ||
| const [isLoading, setIsLoading] = useState(true); | ||
| const [isBiometricEnabled, setIsBiometricEnabled] = useState(false); | ||
| const [storedCredentials, setStoredCredentials] = useState(null); | ||
|
|
||
| // 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 biometricPref = await AsyncStorage.getItem("biometric_enabled"); | ||
|
|
||
| const isBiometric = biometricPref === "true"; | ||
| setIsBiometricEnabled(isBiometric); | ||
|
|
||
| if (storedToken && storedUser) { | ||
| setToken(storedToken); | ||
| setRefresh(storedRefresh); | ||
| await setAuthTokens({ | ||
| newAccessToken: storedToken, | ||
| newRefreshToken: storedRefresh, | ||
| }); | ||
| // Normalize user id shape: ensure `_id` exists even if API stored `id` | ||
| // Normalize user id shape | ||
| const parsed = JSON.parse(storedUser); | ||
| const normalized = parsed?._id | ||
| ? parsed | ||
| : parsed?.id | ||
| ? { ...parsed, _id: parsed.id } | ||
| : parsed; | ||
| setUser(normalized); | ||
|
|
||
| setStoredCredentials({ | ||
| token: storedToken, | ||
| refresh: storedRefresh, | ||
| user: normalized | ||
| }); | ||
|
|
||
| if (isBiometric) { | ||
| const result = await LocalAuthentication.authenticateAsync({ | ||
| promptMessage: "Authenticate to login", | ||
| fallbackLabel: "Use Password", | ||
| }); | ||
|
Comment on lines
25
to
+52
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check whether auth/biometric secrets are still stored in AsyncStorage and whether secure storage is configured.
# Expectation: Token/refresh credential storage for biometric login should use secure storage, not plain AsyncStorage.
set -euo pipefail
echo "Dependencies mentioning secure storage:"
jq -r '.dependencies // {} | to_entries[] | select(.key | test("secure|keychain|keystore"; "i")) | "\(.key): \(.value)"' mobile/package.json 2>/dev/null || true
echo
echo "Auth storage usage:"
rg -n -C 3 'AsyncStorage\.(getItem|setItem|removeItem)\("(auth_token|refresh_token|user_data|biometric_enabled)"|expo-secure-store|SecureStore|requireAuthentication|Keychain' .Repository: Devasy/splitwiser Length of output: 7840 Store authentication tokens in secure storage, not AsyncStorage. The code stores 🤖 Prompt for AI Agents |
||
|
|
||
| if (result.success) { | ||
| setToken(storedToken); | ||
| setRefresh(storedRefresh); | ||
| await setAuthTokens({ | ||
| newAccessToken: storedToken, | ||
| newRefreshToken: storedRefresh, | ||
| }); | ||
| setUser(normalized); | ||
| } | ||
| } else { | ||
| setToken(storedToken); | ||
| setRefresh(storedRefresh); | ||
| await setAuthTokens({ | ||
| newAccessToken: storedToken, | ||
| newRefreshToken: storedRefresh, | ||
| }); | ||
| setUser(normalized); | ||
| } | ||
| } | ||
| } catch (error) { | ||
| console.error("Failed to load stored authentication:", error); | ||
|
|
@@ -106,6 +137,69 @@ export const AuthProvider = ({ children }) => { | |
| saveUser(); | ||
| }, [user]); | ||
|
|
||
| const authenticateBiometric = async () => { | ||
| if (!storedCredentials) return false; | ||
|
|
||
| try { | ||
| const result = await LocalAuthentication.authenticateAsync({ | ||
| promptMessage: "Authenticate to login", | ||
| fallbackLabel: "Use Password", | ||
| }); | ||
|
|
||
| if (result.success) { | ||
| setToken(storedCredentials.token); | ||
| setRefresh(storedCredentials.refresh); | ||
| await setAuthTokens({ | ||
| newAccessToken: storedCredentials.token, | ||
| newRefreshToken: storedCredentials.refresh, | ||
| }); | ||
| setUser(storedCredentials.user); | ||
| return true; | ||
| } | ||
| } catch (error) { | ||
| console.error("Biometric authentication failed:", error); | ||
| } | ||
| return false; | ||
| }; | ||
|
|
||
| const enableBiometric = async () => { | ||
| try { | ||
| const isEnrolled = await LocalAuthentication.isEnrolledAsync(); | ||
| if (!isEnrolled) { | ||
| return { success: false, error: "No biometrics enrolled on this device." }; | ||
| } | ||
|
|
||
| const result = await LocalAuthentication.authenticateAsync({ | ||
| promptMessage: "Authenticate to enable biometric login", | ||
| }); | ||
|
|
||
| if (result.success) { | ||
| await AsyncStorage.setItem("biometric_enabled", "true"); | ||
| setIsBiometricEnabled(true); | ||
| // Also save current credentials if not already stored | ||
| if (token && user) { | ||
| setStoredCredentials({ token, refresh, user }); | ||
| } | ||
| return { success: true }; | ||
| } | ||
| return { success: false, error: "Authentication failed." }; | ||
| } catch (error) { | ||
| console.error("Failed to enable biometrics:", error); | ||
| return { success: false, error: "An error occurred." }; | ||
| } | ||
| }; | ||
|
|
||
| const disableBiometric = async () => { | ||
| try { | ||
| await AsyncStorage.setItem("biometric_enabled", "false"); | ||
| setIsBiometricEnabled(false); | ||
| return { success: true }; | ||
| } catch (error) { | ||
| console.error("Failed to disable biometrics:", error); | ||
| return { success: false, error: "An error occurred." }; | ||
| } | ||
| }; | ||
|
|
||
| const login = async (email, password) => { | ||
| try { | ||
| const response = await authApi.login(email, password); | ||
|
|
@@ -159,6 +253,9 @@ export const AuthProvider = ({ children }) => { | |
| setToken(null); | ||
| setRefresh(null); | ||
| setUser(null); | ||
| setStoredCredentials(null); | ||
| await AsyncStorage.removeItem("biometric_enabled"); | ||
| setIsBiometricEnabled(false); | ||
| await clearAuthTokens(); | ||
| }; | ||
|
|
||
|
|
@@ -182,6 +279,11 @@ export const AuthProvider = ({ children }) => { | |
| signup, | ||
| logout, | ||
| updateUserInContext, | ||
| isBiometricEnabled, | ||
| enableBiometric, | ||
| disableBiometric, | ||
| authenticateBiometric, | ||
| storedCredentials, | ||
| }} | ||
| > | ||
| {children} | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ | |
| "expo": "^54.0.25", | ||
| "expo-haptics": "~15.0.8", | ||
| "expo-image-picker": "~17.0.8", | ||
| "expo-local-authentication": "~17.0.8", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Inspect Expo app config files for expo-local-authentication plugin / Face ID permission.
# Expectation: An app config contains expo-local-authentication and/or NSFaceIDUsageDescription/faceIDPermission.
set -euo pipefail
fd -i '^(app|app\.config)\.(json|js|ts)$|package\.json$' . | while read -r file; do
echo "----- $file -----"
rg -n -C 4 'expo-local-authentication|faceIDPermission|NSFaceIDUsageDescription|plugins|ios' "$file" || true
doneRepository: Devasy/splitwiser Length of output: 1760 🏁 Script executed: #!/bin/bash
# Check complete mobile/app.json file
echo "=== Full mobile/app.json ==="
wc -l mobile/app.json
echo ""
cat -n mobile/app.jsonRepository: Devasy/splitwiser Length of output: 1388 🏁 Script executed: #!/bin/bash
# Search for LocalAuthentication usage in the codebase
echo "=== Searching for LocalAuthentication usage ==="
rg -n 'LocalAuthentication|authenticateAsync|isEnrolledAsync|local-authentication' mobile/ --type js --type tsRepository: Devasy/splitwiser Length of output: 618 Add NSFaceIDUsageDescription to the iOS infoPlist configuration. The dependency is being added, and "infoPlist": {
"NSPhotoLibraryUsageDescription": "Allow Splitwiser to select a group icon from your photo library.",
"NSFaceIDUsageDescription": "Allow Splitwiser to use Face ID for authentication."
}🤖 Prompt for AI Agents |
||
| "expo-status-bar": "~3.0.8", | ||
| "react": "19.1.0", | ||
| "react-dom": "19.1.0", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add the required blank line after the heading.
markdownlintflags## [Unreleased]because it is not followed by a blank line.🧹 Proposed fix
## [Unreleased] + - **[ux] Mobile**: Added biometric authentication option (FaceID/TouchID) to enable quicker and more secure logins for returning users. Implemented the feature directly in `AuthContext` with a toggle in `AccountScreen` and a fallback/login button in `LoginScreen`.📝 Committable suggestion
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)
[warning] 3-3: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below
(MD022, blanks-around-headings)
🤖 Prompt for AI Agents