Mobile App
The OpenInsure mobile app gives policyholders native access to their coverage, claims, payments, and documents on iOS and Android. Built with Expo SDK 55 and React Native's New Architecture.
Expo SDK 55
React Native 0.84, New Architecture, Expo Router, NativeWind 5
OTP Auth
2-step OTP login + biometric (Face ID / Touch ID) + SecureStore JWT
Offline First
useOfflineQuery caches API responses (7-day TTL) + PDF cache (30-day)
EAS Build
Fingerprint → OTA update or native build + store submit
Architecture
apps/mobile/
├── app/
│ ├── _layout.tsx # Root: SplashScreen, AuthGate, providers
│ ├── login.tsx # 2-step OTP login (policy number → 6-digit code)
│ ├── auth/verify.tsx # Magic link deep-link handler
│ └── (tabs)/
│ ├── _layout.tsx # NativeTabs (5 tabs)
│ ├── (home,claims)/
│ │ ├── home.tsx # Dashboard: KPIs, quick actions, recent claims
│ │ ├── claims.tsx # Claims list (FlatList, offline cached)
│ │ ├── file-claim.tsx # FNOL form (Zod validated)
│ │ ├── i/[id].tsx # Claim detail + error boundary
│ │ ├── support.tsx # AI chat support
│ │ └── members/ # Policy member management (admin)
│ ├── (policy)/
│ │ ├── index.tsx # Policy detail + coverages + endorsements
│ │ ├── id-card.tsx # Digital ID card with QR code
│ │ ├── renewal.tsx # Renewal intent form
│ │ ├── request-coi.tsx # COI request (Zod validated)
│ │ └── request-endorsement.tsx # Endorsement request (Zod validated)
│ ├── (documents)/
│ │ └── index.tsx # Document vault (authenticated download)
│ ├── (payments)/
│ │ └── index.tsx # Invoices + Stripe payment initiation
│ └── (settings)/
│ ├── index.tsx # Account, biometric, notifications, sign-out
│ └── profile.tsx # Edit profile (Zod validated)
├── components/
│ └── ui/ # NativeWind primitives: Button, Input, Card, etc.
├── lib/
│ ├── api-provider.tsx # MobileApiProvider: QueryClient + token injection
│ ├── store/auth-mobile.ts # TanStack Store: OTP auth + biometric + SecureStore
│ ├── use-offline-query.ts # Cache wrapper for TanStack Query
│ ├── use-reduced-motion.ts # Accessibility: respect system reduce-motion
│ ├── validation.ts # Zod v4 schemas (FNOL, COI, endorsement, profile, member)
│ ├── offline-cache.ts # AsyncStorage (JSON) + FileSystem (PDF) caching
│ ├── document-download.ts # PDF download → share sheet
│ ├── notifications.ts # Push token registration
│ ├── sentry.ts # Error tracking (PII-filtered)
│ └── brand.ts # White-label config
├── test/
│ ├── setup.ts # Vitest mocks for RN modules
│ └── mocks/factories.ts # Realistic insurance mock data
└── eas.json # Build profiles + App Store submit config
EAS project: 2c00cf79-cdc7-40e0-a4bd-6e4866336b22
Account / slug: mhcis / openinsure
Bundle IDs: dev.openinsure.mobile (iOS + Android)
Apple Team ID: 2D65GH64H3 | ASC App ID: 6761020503
Authentication
Auth state lives in a TanStack Store at lib/store/auth-mobile.ts. There is no React Context — all screens subscribe directly with individual selectors.
const user = useAuthStore((s) => s.user);
const requestOtp = useAuthStore((s) => s.requestOtp);
const verifyOtp = useAuthStore((s) => s.verifyOtp);
OTP Login Flow
- User enters their policy number on the login screen 2. App calls
POST /auth/policyholder-otp-requestwith the policy number 3. API sends a 6-digit OTP to the email on file (10-minute expiry) 4. User enters the OTP code 5. App callsPOST /auth/policyholder-tokenwith policy number + OTP 6. API returns a JWT (8-hour expiry) — stored in SecureStore (Keychain) 7. Push notification token is registered (best-effort)
Biometric (Face ID / Touch ID)
On app launch, app/_layout.tsx blocks routing behind a biometricPending gate. If biometric is enabled and the device supports it, a Face ID / Touch ID prompt fires before any screen renders.
Failed biometric clears the session and forces re-login via OTP.
Session Teardown
clearSession() clears: SecureStore JWT, biometric flag, offline caches (AsyncStorage + FileSystem PDFs), and the TanStack Query cache via the API provider's token-change listener. No PII persists on device after logout.
API Integration
The mobile app calls the API directly with a Bearer JWT — no Next.js proxy layer.
Endpoint Mapping
| Mobile Screen | API Endpoint | Method |
|---|---|---|
| Home (policy) | /v1/portal/policies/:id | GET |
| Home (claims) | /v1/claims?orgId=...&policyId=... | GET |
| Home (invoices) | /v1/portal/invoices | GET |
| Claims list | /v1/claims | GET |
| File claim | /v1/claims/fnol | POST |
| Policy detail | /v1/portal/policies/:id | GET |
| Coverages | /v1/policies/:id/jacket/coverages | GET |
| Endorsements | /v1/policies/:id/endorsements | GET |
| Request COI | /v1/coi/generate | POST |
| Request endorsement | /v1/policies/:id/endorsements | POST |
| Documents | /v1/portal/documents | GET |
| Document download | /v1/portal/documents/:id/download | GET |
| Invoices | /v1/portal/invoices | GET |
| Pay invoice | /v1/invoices/:id/payment-intent | POST |
| Profile | /v1/portal/profile | GET / PUT |
| Push token | /v1/portal/push-tokens | POST |
| Support chat | /v1/chat | POST |
| Renewal intent | /v1/policies/renewal/intent | POST |
The MobileApiProvider creates a fetcher that prepends the API base URL and injects the Bearer token from the auth store. 401 responses auto-clear the session.
Offline Support
useOfflineQuery
lib/use-offline-query.ts wraps useApiQuery with offline cache fallback:
- On successful fetch → persists to AsyncStorage via
setCache(path, data) - On fetch failure when offline → returns cached data with
isStale: true - Screens show a "Showing cached data" banner when serving stale data
Used on: Home dashboard, policy detail, and claims list.
Cache TTLs
| Cache | TTL | Storage |
|---|---|---|
| API JSON responses | 7 days | AsyncStorage |
| PDF documents | 30 days | FileSystem |
pruneExpired() runs on app start to clean up stale entries.
Form Validation
All 6 form screens use Zod v4 schemas from lib/validation.ts:
| Form | Schema | Fields Validated |
|---|---|---|
| File Claim | fnolClaimSchema | lossDate, lossDescription (min 10 chars), estimatedLoss (optional, positive) |
| Request COI | coiRequestSchema | holderName, holderAddress (required) |
| Request Endorsement | endorsementRequestSchema | changeType, description (min 10 chars) |
| Edit Profile | profileSchema | email, phone, ZIP (format validated) |
| Add Member | memberSchema | entityName, email, EIN (XX-XXXXXXX), memberType |
The <Input error={}> component displays inline validation errors with red border + helper text.
Testing
42 tests across 3 test files, run with Vitest:
pnpm --filter @openinsure/mobile test
| Test File | Tests | Coverage |
|---|---|---|
lib/__tests__/validation.test.ts | 15 | All 5 Zod schemas, edge cases |
lib/__tests__/offline-cache.test.ts | 8 | TTL expiry, pruning, JSON parse errors |
lib/store/__tests__/auth-mobile.test.ts | 19 | OTP request/verify, init, logout, biometric, 401 |
Accessibility
- Reduce Motion:
useReducedMotion()hook gates Reanimated entry animations on 3 key screens - Accessibility labels: Button (
accessibilityRole="button"), Input (accessibilityLabel,accessibilityState), ListItem (accessibilityLabel,accessibilityHint) - Privacy Manifest: Declares no tracking, email + crash data collection for app functionality
Icon System
The app uses phosphor-react-native@^3.0.3 (NOT @phosphor-icons/react — that's for web). Tab bar icons use Expo's NativeTabs.Trigger.Icon sf="..." SF Symbols.
import { Bell, FileText, CreditCard } from 'phosphor-react-native';
EAS Build Workflows
Workflows live in apps/mobile/.eas/workflows/.
Production (production.yml)
Triggered by CircleCI deploy-mobile job on merge to master.
fingerprint → get-build (check existing)
├─ JS-only change → OTA update to production channel (minutes)
└─ Native change → full build + App Store / Play Store submit (hours)
Preview (preview.yml)
Delivers OTA update to preview channel — testers get changes instantly.
Development (development.yml)
Manual trigger. Builds dev client for local debugging with expo start --dev-client.
CircleCI Integration
The deploy-mobile job in .circleci/config.yml:
- Gates on full CI suite (test, typecheck, lint, slo-check)
- Maps
EXPO_CIRCLECI_ACCESS_TOKEN→EXPO_TOKEN - Fails fast if token is missing
- Calls
eas workflow:run .eas/workflows/production.yml
Local Development
cd apps/mobile
# Start Metro bundler
npx expo start
# Run on iOS simulator
npx expo run:ios
# Run on Android emulator
npx expo run:android
# Point at local API worker
EXPO_PUBLIC_API_URL=http://localhost:8787 npx expo start
# Run tests
pnpm test
First Build Setup
Apple credentials must be set up interactively the first time. Run eas build --profile production --platform ios and follow the prompts to generate a Distribution Certificate and provisioning
profile.
cd apps/mobile
# iOS production build (interactive first time for Apple credentials)
eas build --platform ios --profile production
# Android production build
eas build --platform android --profile production
# Submit to stores
eas submit --platform ios --profile production
eas submit --platform android --profile production