diff --git a/frontend/mobile/app/_layout.tsx b/frontend/mobile/app/_layout.tsx index 37345b5c..f38c53bd 100644 --- a/frontend/mobile/app/_layout.tsx +++ b/frontend/mobile/app/_layout.tsx @@ -7,6 +7,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context"; import { fontAssets } from "../theme/typography"; import { useTheme } from "../hooks/useTheme"; +import { useInactivityLock } from "../hooks/useInactivityLock"; import { ConnectivityProvider, useConnectivity } from "../lib/connectivity"; import { WalletConnectApprovalModal } from "../components/WalletConnectApprovalModal"; @@ -35,6 +36,7 @@ export default function RootLayout() { + createStyles(colors), [colors]); + const router = useRouter(); + + const [isUnlocking, setIsUnlocking] = useState(false); + const [error, setError] = useState(null); + + const handleUnlock = useCallback(async () => { + setError(null); + setIsUnlocking(true); + try { + const hasHardware = await LocalAuthentication.hasHardwareAsync(); + const isEnrolled = await LocalAuthentication.isEnrolledAsync(); + if (!hasHardware || !isEnrolled) { + setError('No biometric or device passcode is set up. Add one in system settings.'); + return; + } + + const result = await LocalAuthentication.authenticateAsync({ + promptMessage: 'Unlock Veil', + cancelLabel: 'Cancel', + // Allow the device passcode when biometrics fail, matching OS behaviour. + disableDeviceFallback: false, + }); + + if (result.success) { + router.replace('/'); + return; + } + setError('Unlock failed. Please try again.'); + } catch { + setError('Unlock failed. Please try again.'); + } finally { + setIsUnlocking(false); + } + }, [router]); + + // Prompt immediately on arrival so the user isn't stranded on a dead screen. + useEffect(() => { + void handleUnlock(); + }, [handleUnlock]); + + return ( + + + 🔒 + + + + Wallet locked + Unlock with your biometric to continue. + + + {error && {error}} + + [styles.button, (pressed || isUnlocking) && styles.buttonPressed]} + > + {isUnlocking ? ( + + ) : ( + Unlock + )} + + + ); +} + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background, + padding: 32, + gap: 28, + }, + iconCircle: { + width: 72, + height: 72, + borderRadius: 36, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + justifyContent: 'center', + }, + iconGlyph: { + fontSize: 30, + }, + copy: { + alignItems: 'center', + gap: 6, + }, + title: { + color: colors.textStrong, + fontSize: 22, + fontWeight: '700', + }, + subtitle: { + color: colors.textSecondary, + fontSize: 15, + textAlign: 'center', + }, + error: { + color: colors.danger, + fontSize: 14, + textAlign: 'center', + }, + button: { + alignSelf: 'stretch', + maxWidth: 320, + backgroundColor: colors.accent, + borderRadius: 999, + paddingVertical: 14, + alignItems: 'center', + }, + buttonPressed: { + opacity: 0.75, + }, + buttonLabel: { + color: colors.onAccent, + fontSize: 16, + fontWeight: '700', + }, + }); diff --git a/frontend/mobile/hooks/useInactivityLock.ts b/frontend/mobile/hooks/useInactivityLock.ts new file mode 100644 index 00000000..a9b9dec2 --- /dev/null +++ b/frontend/mobile/hooks/useInactivityLock.ts @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import { AppState, type AppStateStatus } from 'react-native'; +import { useRouter, useSegments } from 'expo-router'; + +import { createIdleTimer } from '../lib/idleLock'; + +/** + * Locks the wallet after inactivity or when the app is backgrounded, so a lost + * or borrowed phone doesn't expose funds. The native port of the web wallet's + * `hooks/useInactivityLock.ts`. + * + * The countdown lives in `lib/idleLock.ts`; this hook wires it to React Native's + * `AppState` and expo-router. Sending the app to the background locks it + * immediately; returning to the foreground restarts the idle countdown. Either + * trigger routes to `/lock`, which re-prompts a biometric. It re-arms itself off + * the current route so it never fights the lock screen it just pushed. + * + * Mount once at the app root (alongside the connectivity gate in `_layout.tsx`). + */ +export function useInactivityLock(): void { + const router = useRouter(); + const segments = useSegments(); + const onLockRoute = segments[0] === 'lock'; + + useEffect(() => { + // Already locked — don't re-arm on top of the lock screen. + if (onLockRoute) return; + + const lock = () => router.replace('/lock'); + const timer = createIdleTimer({ onLock: lock }); + timer.reset(); + + const subscription = AppState.addEventListener('change', (state: AppStateStatus) => { + if (state === 'active') { + // Foregrounded: restart the idle countdown. + timer.reset(); + } else if (state === 'background') { + // Backgrounded: lock now so returning requires a biometric. ('inactive' + // is transient — a notification shade or call sheet — and is ignored.) + timer.stop(); + lock(); + } + }); + + return () => { + timer.stop(); + subscription.remove(); + }; + }, [router, onLockRoute]); +} diff --git a/frontend/mobile/lib/__tests__/idleLock.test.ts b/frontend/mobile/lib/__tests__/idleLock.test.ts new file mode 100644 index 00000000..ba71bfcb --- /dev/null +++ b/frontend/mobile/lib/__tests__/idleLock.test.ts @@ -0,0 +1,136 @@ +/** + * Tests for the inactivity auto-lock countdown. The timer is pure — its clock is + * injected — so the "locks after timeout", "reset postpones the lock", "never + * disabled", and "deferred while busy" paths all resolve deterministically + * without real time or React Native's AppState. + */ + +import { createIdleTimer, idleTimeoutToMs, DEFAULT_IDLE_TIMEOUT } from '../idleLock'; + +/** A controllable fake clock: `advance` fires any timers due at or before `t`. */ +function fakeClock() { + let now = 0; + let seq = 0; + const pending = new Map void }>(); + + const setTimeoutFn = (fn: () => void, ms: number) => { + const id = ++seq; + pending.set(id, { at: now + ms, fn }); + return id as unknown as ReturnType; + }; + const clearTimeoutFn = (id: ReturnType) => { + pending.delete(id as unknown as number); + }; + const advance = (ms: number) => { + now += ms; + for (const [id, t] of [...pending]) { + if (t.at <= now) { + pending.delete(id); + t.fn(); + } + } + }; + return { setTimeoutFn, clearTimeoutFn, advance, get size() { return pending.size; } }; +} + +describe('idleTimeoutToMs', () => { + it('converts minute options to milliseconds and never to null', () => { + expect(idleTimeoutToMs(5)).toBe(5 * 60_000); + expect(idleTimeoutToMs(30)).toBe(30 * 60_000); + expect(idleTimeoutToMs('never')).toBeNull(); + expect(idleTimeoutToMs(DEFAULT_IDLE_TIMEOUT)).toBe(5 * 60_000); + }); +}); + +describe('createIdleTimer', () => { + it('fires onLock once the timeout elapses', () => { + const clock = fakeClock(); + const onLock = jest.fn(); + const timer = createIdleTimer({ + onLock, + getTimeoutMs: () => 1_000, + setTimeoutFn: clock.setTimeoutFn, + clearTimeoutFn: clock.clearTimeoutFn, + }); + + timer.reset(); + clock.advance(999); + expect(onLock).not.toHaveBeenCalled(); + clock.advance(1); + expect(onLock).toHaveBeenCalledTimes(1); + }); + + it('reset restarts the countdown, postponing the lock', () => { + const clock = fakeClock(); + const onLock = jest.fn(); + const timer = createIdleTimer({ + onLock, + getTimeoutMs: () => 1_000, + setTimeoutFn: clock.setTimeoutFn, + clearTimeoutFn: clock.clearTimeoutFn, + }); + + timer.reset(); + clock.advance(800); + timer.reset(); // activity before the deadline + clock.advance(800); + expect(onLock).not.toHaveBeenCalled(); + clock.advance(200); + expect(onLock).toHaveBeenCalledTimes(1); + }); + + it('stop cancels a pending lock', () => { + const clock = fakeClock(); + const onLock = jest.fn(); + const timer = createIdleTimer({ + onLock, + getTimeoutMs: () => 1_000, + setTimeoutFn: clock.setTimeoutFn, + clearTimeoutFn: clock.clearTimeoutFn, + }); + + timer.reset(); + timer.stop(); + clock.advance(5_000); + expect(onLock).not.toHaveBeenCalled(); + }); + + it('never arms when the timeout is disabled', () => { + const clock = fakeClock(); + const onLock = jest.fn(); + const timer = createIdleTimer({ + onLock, + getTimeoutMs: () => null, + setTimeoutFn: clock.setTimeoutFn, + clearTimeoutFn: clock.clearTimeoutFn, + }); + + timer.reset(); + expect(clock.size).toBe(0); + clock.advance(1_000_000); + expect(onLock).not.toHaveBeenCalled(); + }); + + it('defers the lock while shouldDefer holds, then locks once clear', () => { + const clock = fakeClock(); + const onLock = jest.fn(); + let busy = true; + const timer = createIdleTimer({ + onLock, + getTimeoutMs: () => 1_000, + shouldDefer: () => busy, + deferMs: 500, + setTimeoutFn: clock.setTimeoutFn, + clearTimeoutFn: clock.clearTimeoutFn, + }); + + timer.reset(); + clock.advance(1_000); // deadline hit, but busy → rescheduled + expect(onLock).not.toHaveBeenCalled(); + clock.advance(500); // still busy → rescheduled again + expect(onLock).not.toHaveBeenCalled(); + busy = false; + clock.advance(500); + expect(onLock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/mobile/lib/idleLock.ts b/frontend/mobile/lib/idleLock.ts new file mode 100644 index 00000000..024e472f --- /dev/null +++ b/frontend/mobile/lib/idleLock.ts @@ -0,0 +1,85 @@ +/** + * Inactivity auto-lock — the native port of the web wallet's `lib/idle-lock.ts`. + * + * The countdown itself is framework-agnostic and lives here so it stays pure and + * testable: no `window`, no React Native `AppState`. The hook + * (`hooks/useInactivityLock.ts`) owns the platform wiring — it feeds activity in + * via `reset()` and drives lock-on-background off `AppState` — while this module + * only decides *when* the timeout has elapsed. The timeout options mirror the + * web (5 / 15 / 30 minutes, or never). + */ + +export type IdleTimeout = 5 | 15 | 30 | 'never'; + +export const IDLE_TIMEOUT_OPTIONS: readonly IdleTimeout[] = [5, 15, 30, 'never']; +export const DEFAULT_IDLE_TIMEOUT: IdleTimeout = 5; + +/** How long to wait before re-checking when locking is deferred (e.g. a live tx). */ +const DEFAULT_DEFER_MS = 35_000; + +/** Convert a timeout option to milliseconds, or null when auto-lock is disabled ('never'). */ +export function idleTimeoutToMs(timeout: IdleTimeout): number | null { + return timeout === 'never' ? null : timeout * 60 * 1000; +} + +export type IdleTimerOptions = { + /** Called when the idle timeout elapses (and `shouldDefer` does not hold). */ + onLock: () => void; + /** Current timeout in ms, or null to disable. Defaults to the 5-minute default. */ + getTimeoutMs?: () => number | null; + /** When it returns true, locking is postponed (e.g. an in-flight transaction). */ + shouldDefer?: () => boolean; + /** How long to wait before re-checking when locking is deferred. Defaults to 35s. */ + deferMs?: number; + /** Injectable timers so tests drive the clock. Default to the global ones. */ + setTimeoutFn?: (handler: () => void, ms: number) => ReturnType; + clearTimeoutFn?: (handle: ReturnType) => void; +}; + +export type IdleTimer = { + /** (Re)start the countdown from now. A no-op while the timeout is 'never'. */ + reset: () => void; + /** Cancel any pending countdown. */ + stop: () => void; +}; + +/** + * Create an idle timer. `reset()` restarts the countdown (call it on user + * activity or when the app returns to the foreground); `stop()` cancels it. When + * the countdown fires it calls `onLock`, unless `shouldDefer` holds — in which + * case it reschedules after `deferMs` and never interrupts an in-flight action. + */ +export function createIdleTimer(options: IdleTimerOptions): IdleTimer { + const getTimeoutMs = options.getTimeoutMs ?? (() => idleTimeoutToMs(DEFAULT_IDLE_TIMEOUT)); + const deferMs = options.deferMs ?? DEFAULT_DEFER_MS; + const set = options.setTimeoutFn ?? ((h, ms) => setTimeout(h, ms)); + const clear = options.clearTimeoutFn ?? ((h) => clearTimeout(h)); + + let timer: ReturnType | null = null; + + function cancel(): void { + if (timer !== null) { + clear(timer); + timer = null; + } + } + + function fire(): void { + timer = null; + // Never interrupt an in-flight transaction — reschedule and check again. + if (options.shouldDefer?.()) { + timer = set(fire, deferMs); + return; + } + options.onLock(); + } + + function reset(): void { + cancel(); + const ms = getTimeoutMs(); + if (ms === null || ms <= 0) return; // 'never' → auto-lock disabled + timer = set(fire, ms); + } + + return { reset, stop: cancel }; +} diff --git a/frontend/mobile/package-lock.json b/frontend/mobile/package-lock.json index d59c0a09..ba1bf12b 100644 --- a/frontend/mobile/package-lock.json +++ b/frontend/mobile/package-lock.json @@ -32,6 +32,7 @@ "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-linking": "~57.0.4", + "expo-local-authentication": "~57.0.2", "expo-router": "~57.0.8", "expo-secure-store": "~57.0.1", "expo-sharing": "~57.0.7", @@ -95,6 +96,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1160,7 +1162,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1176,7 +1177,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -1208,7 +1208,6 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, @@ -3307,6 +3306,7 @@ "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.1.1.tgz", "integrity": "sha512-z+PnLz1n6ECKhgoHZHkfc+dijXZEyZnNFSajbtE0NEbsJhmX8x9GlOeiMQIKX2E4DUqPSgfIh4FYBv1M49KgPQ==", "license": "MIT", + "peer": true, "dependencies": { "idb": "8.0.3" }, @@ -3320,6 +3320,7 @@ "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": ">=0.59" @@ -3362,7 +3363,6 @@ "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.0.tgz", "integrity": "sha512-bYQcWiPySNvF4dns9Ls9gMmwgq66ohvM9Fwc/Kn8r85t66UNHxch3p1QwPiSorDelFauZwJbgo9+ReibTgvpbA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@babel/plugin-proposal-export-default-from": "^7.24.7", @@ -3546,7 +3546,6 @@ "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.0.tgz", "integrity": "sha512-SjKej3E5qIahqo/G+rSOrmJUQM44RyKtWtO+VfmKAAMoJWkBFomM22hTLKCIS5cdbIAJ9COAmU+KAi2wVSO0wQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.25.2", "@react-native/babel-preset": "0.86.0", @@ -3565,7 +3564,6 @@ "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.0.tgz", "integrity": "sha512-7v+xbTeEci9ZcQ/Z1OqI4RXcqN69wSMDYL5BAMvOReZ7U04+aDQ0/SQhClYPn6x2/RxM4WzMKSAuNyLKqvYVtw==", "license": "MIT", - "peer": true, "dependencies": { "@react-native/js-polyfills": "0.86.0", "@react-native/metro-babel-transformer": "0.86.0", @@ -3868,7 +3866,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3888,7 +3885,6 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -3901,7 +3897,6 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3915,8 +3910,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -3970,8 +3964,7 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4101,6 +4094,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5570,6 +5564,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -6460,8 +6455,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-serializer": { "version": "2.0.0", @@ -6945,6 +6939,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.8.tgz", "integrity": "sha512-0IxxoPZbT54IH4fHL5NihkvED9HBVQx3uNdPvyv8pFUHWJ81RdFjL0aJys1IB7hbo09KTz4xW4I2aWVOXKAcJQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "^57.0.10", @@ -7053,6 +7048,7 @@ "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.7.tgz", "integrity": "sha512-ShDwaKnh3UieCQ/dG0kO8PuicTTatn3WDGmXbq/fukyzPXWhdUxf37DINVDQS6D3DDG7nfqItij+QvnDHSQhTg==", "license": "MIT", + "peer": true, "dependencies": { "@expo/env": "~2.4.2" }, @@ -7094,6 +7090,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.1.tgz", "integrity": "sha512-QyS9L1Kh9sKJg4gfU6rdbpxpmH+DyzBX8z6jVvXMUDoqLr1GqmkO/Wu379KCXjL///kWbhpNlbi7AgBuj4VdIQ==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -7129,6 +7126,7 @@ "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.4.tgz", "integrity": "sha512-e1alfHNJdywIfJkCuKMc6M3hBfAGPd2gKMeF/6V7qwFWzHCS2mTBqU+KaO4FLpltA5Nt6CYEx6zmUlGfUF+8lA==", "license": "MIT", + "peer": true, "dependencies": { "expo-constants": "~57.0.7", "invariant": "^2.2.4" @@ -7138,6 +7136,18 @@ "react-native": "*" } }, + "node_modules/expo-local-authentication": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-57.0.2.tgz", + "integrity": "sha512-8K4zcrQ5wZkRS1rwEWY8qHbeGVMNh35e9D1VTVKlMHz1O47itW+pW6iBVuqwGFLozN4fRBeEDQH8hu9Gt58YuQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.9", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.9.tgz", @@ -7188,6 +7198,7 @@ "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.8.tgz", "integrity": "sha512-xAyTnZl597G9/r17GOuyTy6VlhjYCVmgzgmP00bhZ9b+VstPl3tTrOOhSFagVpeln47nKp7x7vgkANNheCv4eQ==", "license": "MIT", + "peer": true, "dependencies": { "@expo/log-box": "^57.0.1", "@expo/metro-runtime": "^57.0.7", @@ -7271,6 +7282,7 @@ "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.1.tgz", "integrity": "sha512-sBfVDH6dmKVHZxqUxbfkzS00PZELMZt1IpnHKxcOTMZtR/t7CtRAFrbXcisG+EyzeqHSVDacZT+1tbYfZt5D8w==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.16.0" } @@ -7540,6 +7552,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7850,6 +7863,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8281,7 +8295,8 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/ieee754": { "version": "1.2.1", @@ -8487,6 +8502,16 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -8665,6 +8690,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9993,9 +10019,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10016,9 +10039,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10266,7 +10286,6 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10323,6 +10342,19 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11761,6 +11793,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11780,6 +11813,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11816,6 +11850,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.0.tgz", "integrity": "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==", "license": "MIT", + "peer": true, "dependencies": { "@react-native/assets-registry": "0.86.0", "@react-native/codegen": "0.86.0", @@ -11891,6 +11926,7 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz", "integrity": "sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==", "license": "MIT", + "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "@types/react-test-renderer": "^19.1.0", @@ -11907,6 +11943,7 @@ "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz", "integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==", "license": "MIT", + "peer": true, "dependencies": { "fast-base64-decode": "^1.0.0" }, @@ -11956,6 +11993,7 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.0.tgz", "integrity": "sha512-+iPfvK34PKKYP/p/4TaBliFkbfvjGDIvXuiiaxvISP5ip7sWegvlacwU/uAV6zNDSSmX0tDyER7PurPMKGDipA==", "license": "MIT", + "peer": true, "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" @@ -11971,6 +12009,7 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -11995,6 +12034,7 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", "license": "MIT", + "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -12019,6 +12059,7 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -12051,6 +12092,7 @@ "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.0.tgz", "integrity": "sha512-JhE6IxDf6iabC0qu3+TAKA4v9RlluXmoIngPQX7/QUByf75lfrsHZ6/dQhyjEWnp1EEQiwzz8Cpew140ZcewDw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.27.1", "@babel/plugin-transform-class-properties": "^7.28.6", @@ -12086,6 +12128,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13335,6 +13378,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/frontend/mobile/package.json b/frontend/mobile/package.json index 260aed4b..375d6b45 100644 --- a/frontend/mobile/package.json +++ b/frontend/mobile/package.json @@ -37,6 +37,7 @@ "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-linking": "~57.0.4", + "expo-local-authentication": "~57.0.2", "expo-router": "~57.0.8", "expo-secure-store": "~57.0.1", "expo-sharing": "~57.0.7",