diff --git a/frontend/mobile/app/_layout.tsx b/frontend/mobile/app/_layout.tsx index f038e313..83806b9b 100644 --- a/frontend/mobile/app/_layout.tsx +++ b/frontend/mobile/app/_layout.tsx @@ -10,6 +10,7 @@ import { StyleSheet } from 'react-native'; import { fontAssets } from '../theme/typography'; import { useTheme } from '../hooks/useTheme'; +import { useInactivityLock } from '../hooks/useInactivityLock'; import { ConnectivityProvider, useConnectivity } from '../lib/connectivity'; import { hydrateNetwork } from '../lib/network'; import { hydrateLockSettings } from '../lib/appLock'; @@ -54,6 +55,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]); -export default function LockRoute() { return ( - - - + + + 🔒 - - Why we lock - - Wallet signs automatically lock when the app is backgrounded or after extended inactivity. - Approve the passkey prompt to resume your session. - + + + Wallet locked + Unlock with your biometric to continue. - - + + {error && {error}} + + [styles.button, (pressed || isUnlocking) && styles.buttonPressed]} + > + {isUnlocking ? ( + + ) : ( + Unlock + )} + + ); } -const styles = StyleSheet.create({ - lockCircle: { - alignSelf: 'center', - width: 96, - height: 96, - borderRadius: 48, - backgroundColor: colors.surface, - borderColor: colors.gold, - borderWidth: 2, - alignItems: 'center', - justifyContent: 'center', - marginTop: 4, - }, - lockGlyph: { color: colors.gold, fontSize: 38, lineHeight: 40 }, - card: { - padding: 18, - backgroundColor: colors.surface, - borderRadius: 14, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.border, - gap: 8, - }, - cardTitle: { color: colors.gold, fontSize: 12, fontWeight: '700', letterSpacing: 1.4, textTransform: 'uppercase' }, - cardText: { color: colors.offWhite, fontSize: 14, lineHeight: 20 }, -}); +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..3a34ed55 --- /dev/null +++ b/frontend/mobile/hooks/useInactivityLock.ts @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { AppState } from 'react-native'; +import { useRouter, useSegments } from 'expo-router'; + +import { createIdleWatcher } from '../lib/appLock'; + +/** + * Locks the wallet after inactivity or when the app is backgrounded, so a lost + * or borrowed phone doesn't expose funds. + * + * The countdown lives in `lib/appLock.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 watcher = createIdleWatcher({ onLock: lock }); + watcher.start(); + + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'background') { + // Backgrounded: lock now so returning requires a biometric. + watcher.stop(); + lock(); + } + }); + + return () => { + watcher.stop(); + subscription.remove(); + }; + }, [router, onLockRoute]); +} diff --git a/frontend/mobile/package-lock.json b/frontend/mobile/package-lock.json index 0abe92a8..d0d5159d 100644 --- a/frontend/mobile/package-lock.json +++ b/frontend/mobile/package-lock.json @@ -35,6 +35,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", @@ -1512,35 +1513,38 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -5188,6 +5192,40 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", @@ -9330,6 +9368,18 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "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-manifests": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-57.0.1.tgz", diff --git a/frontend/mobile/package.json b/frontend/mobile/package.json index 7016dc66..7f3e9f16 100644 --- a/frontend/mobile/package.json +++ b/frontend/mobile/package.json @@ -45,6 +45,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",