From d55e73120c941543ca3d98a8c2cf6c4a630499af Mon Sep 17 00:00:00 2001 From: null Date: Sun, 14 Jun 2026 18:08:01 -0500 Subject: [PATCH] feat: server-mode login, biometric unlock, CapacitorHttp native auth (batch 4.5) --- .gitignore | 4 + capacitor.config.ts | 9 ++ package-lock.json | 16 +++ package.json | 3 +- scripts/sync-nodejs-project.sh | 3 +- src/App.tsx | 110 +++++++++++++---- src/BiometricLock.tsx | 67 ++++++++++ src/BiometricSetup.tsx | 69 +++++++++++ src/LoginScreen.tsx | 215 +++++++++++++++++++++++++++++++++ src/SetupScreen.tsx | 2 +- 10 files changed, 470 insertions(+), 28 deletions(-) create mode 100644 src/BiometricLock.tsx create mode 100644 src/BiometricSetup.tsx create mode 100644 src/LoginScreen.tsx diff --git a/.gitignore b/.gitignore index 6e296ff..e03252d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ Thumbs.db # IDE .vscode/ .idea/ +*.code-workspace + +# Build output +build-output/ # Private project/agent docs — never commit PROJECT.md diff --git a/capacitor.config.ts b/capacitor.config.ts index 90731ba..08e1f98 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -16,6 +16,15 @@ const config: CapacitorConfig = { // in the system browser instead of the app's WebView. allowNavigation: ['127.0.0.1'], }, + plugins: { + CapacitorHttp: { + // Routes fetch()/XHR through native HTTP so the server-mode login + // request isn't blocked by CORS, and the resulting session cookie is + // written to the WebView's cookie store for the subsequent WebView + // navigation to the server URL. + enabled: true, + }, + }, }; export default config; diff --git a/package-lock.json b/package-lock.json index c632afc..b7017fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "bill-tracker-mobile", "version": "1.0.0", "dependencies": { + "@aparajita/capacitor-biometric-auth": "^10.0.0", "@capacitor/android": "^8.4.0", "@capacitor/app": "^8.1.0", "@capacitor/core": "^8.4.0", @@ -28,6 +29,21 @@ "vite": "^8.0.0" } }, + "node_modules/@aparajita/capacitor-biometric-auth": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@aparajita/capacitor-biometric-auth/-/capacitor-biometric-auth-10.0.0.tgz", + "integrity": "sha512-azjWaRucB8x1/gSzSTp/NxelaOZyg+m765gSzjwUkSQgg30RrZPmPq6pyLeeXVSZNDY3Rxf5Hj8obgZaXirCFQ==", + "license": "MIT", + "dependencies": { + "@capacitor/android": "^8.0.2", + "@capacitor/app": "^8.0.0", + "@capacitor/core": "^8.0.2", + "@capacitor/ios": "^8.0.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@capacitor/android": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz", diff --git a/package.json b/package.json index ab5a675..5be0f4f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "android:run": "npm run sync && npm run sync:server && npm run sync:android-assets && npx cap run android --no-sync" }, "dependencies": { + "@aparajita/capacitor-biometric-auth": "^10.0.0", "@capacitor/android": "^8.4.0", "@capacitor/app": "^8.1.0", "@capacitor/core": "^8.4.0", @@ -31,4 +32,4 @@ "typescript": "^5.6.2", "vite": "^8.0.0" } -} \ No newline at end of file +} diff --git a/scripts/sync-nodejs-project.sh b/scripts/sync-nodejs-project.sh index a49fd44..aed332b 100755 --- a/scripts/sync-nodejs-project.sh +++ b/scripts/sync-nodejs-project.sh @@ -54,6 +54,7 @@ rm -rf "$DEST/db/node_modules" mkdir -p "$DEST/docs" cp "$REPO_ROOT/docs/advisory_non_bill_transaction_filters_us_ms_5000.json" "$DEST/docs/" cp "$REPO_ROOT/docs/top_200_us_subscriptions_researched_2026-06-06.json" "$DEST/docs/" +cp "$REPO_ROOT/docs/merchant_store_match_us_nems_online_5k_v0_2.json" "$DEST/docs/" # nodejs-mobile-cordova 0.4.3 embeds Node 12.19, which doesn't support # optional chaining (?.) / nullish coalescing (??) used in this project and @@ -74,4 +75,4 @@ if [ -d "$NODE_MODULES" ]; then node "$MOBILE_DIR/scripts/transpile-node12.js" "$NODE_MODULES" fi -echo "Done. Next: cd \"$MOBILE_DIR/nodejs-assets/nodejs-project\" && npm install" \ No newline at end of file +echo "Done. Next: cd \"$MOBILE_DIR/nodejs-assets/nodejs-project\" && npm install" diff --git a/src/App.tsx b/src/App.tsx index b36d2a9..27a967f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,50 +1,110 @@ import { useEffect, useState } from 'react'; import { Preferences } from '@capacitor/preferences'; +import { BiometricAuth } from '@aparajita/capacitor-biometric-auth'; import SetupScreen from './SetupScreen'; import LoadingScreen from './LoadingScreen'; +import LoginScreen from './LoginScreen'; +import BiometricSetup from './BiometricSetup'; +import BiometricLock from './BiometricLock'; const LOCAL_URL = 'http://127.0.0.1:3000'; +const SERVER_URL_KEY = 'serverUrl'; +const BIOMETRIC_KEY = 'biometricEnabled'; + +type Stage = + | 'checking' + | 'setup' + | 'login' + | 'biometric-setup' + | 'biometric-lock' + | 'local' + | 'redirecting'; export default function App() { - const [ready, setReady] = useState(false); - const [localMode, setLocalMode] = useState(false); + const [stage, setStage] = useState('checking'); + const [serverUrl, setServerUrl] = useState(''); useEffect(() => { - Preferences.get({ key: 'serverUrl' }).then(({ value }) => { + Preferences.get({ key: SERVER_URL_KEY }).then(async ({ value }) => { if (value === 'local') { - setLocalMode(true); - } else if (value) { - // Navigate the WebView to the saved server URL. - // From this point the remote server's UI takes over entirely. - window.location.replace(value); - } else { - setReady(true); + setStage('local'); + return; } + if (value) { + setServerUrl(value); + const { value: biometricEnabled } = await Preferences.get({ key: BIOMETRIC_KEY }); + setStage(biometricEnabled === 'true' ? 'biometric-lock' : 'redirecting'); + return; + } + setStage('setup'); }); }, []); - async function handleConnect(url: string) { - await Preferences.set({ key: 'serverUrl', value: url }); - window.location.replace(url); + useEffect(() => { + if (stage === 'redirecting') { + window.location.replace(serverUrl); + } + }, [stage, serverUrl]); + + function handleConnect(url: string) { + setServerUrl(url); + setStage('login'); } async function handleLocalMode() { - await Preferences.set({ key: 'serverUrl', value: 'local' }); - setLocalMode(true); + await Preferences.set({ key: SERVER_URL_KEY, value: 'local' }); + setStage('local'); } - if (localMode) { - return window.location.replace(LOCAL_URL)} />; + async function handleLoginSuccess() { + await Preferences.set({ key: SERVER_URL_KEY, value: serverUrl }); + try { + const { isAvailable } = await BiometricAuth.checkBiometry(); + setStage(isAvailable ? 'biometric-setup' : 'redirecting'); + } catch { + setStage('redirecting'); + } } - if (!ready) { - // Brief blank while we check preferences before redirecting - return ( -
-
-
- ); + async function handleBiometricSetupDone(enabled: boolean) { + await Preferences.set({ key: BIOMETRIC_KEY, value: enabled ? 'true' : 'false' }); + setStage('redirecting'); } - return ; + async function handleBiometricFallback() { + await Preferences.set({ key: BIOMETRIC_KEY, value: 'false' }); + setStage('login'); + } + + switch (stage) { + case 'local': + return window.location.replace(LOCAL_URL)} />; + + case 'setup': + return ; + + case 'login': + return ( + setStage('setup')} + /> + ); + + case 'biometric-setup': + return ; + + case 'biometric-lock': + return setStage('redirecting')} onFallback={handleBiometricFallback} />; + + default: + // 'checking' / 'redirecting' — brief blank while we check preferences + // or hand off to the WebView. + return ( +
+
+
+ ); + } } diff --git a/src/BiometricLock.tsx b/src/BiometricLock.tsx new file mode 100644 index 0000000..06de442 --- /dev/null +++ b/src/BiometricLock.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react'; +import { BiometricAuth, BiometryError, BiometryErrorType } from '@aparajita/capacitor-biometric-auth'; + +interface Props { + onUnlocked: () => void; + /** User can't (or won't) use biometrics — fall back to server login. */ + onFallback: () => void; +} + +/** + * App-lock gate shown on launch when biometric unlock is enabled. The + * server session cookie persists in the WebView's cookie store between + * launches, so this is what stands between "phone unlocked" and "Bill + * Tracker dashboard visible". + */ +export default function BiometricLock({ onUnlocked, onFallback }: Props) { + const [error, setError] = useState(''); + const [checking, setChecking] = useState(true); + + async function attempt() { + setError(''); + setChecking(true); + try { + await BiometricAuth.authenticate({ + reason: 'Unlock Bill Tracker', + cancelTitle: 'Cancel', + allowDeviceCredential: true, + androidTitle: 'Unlock Bill Tracker', + }); + onUnlocked(); + } catch (err) { + if (err instanceof BiometryError && err.code === BiometryErrorType.userCancel) { + setError(''); + } else { + setError('Authentication failed. Try again.'); + } + } finally { + setChecking(false); + } + } + + useEffect(() => { + attempt(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {checking ? ( +
+ ) : ( + <> +

Locked

+

{error || 'Authenticate to continue.'}

+ + + + )} +
+
+ ); +} diff --git a/src/BiometricSetup.tsx b/src/BiometricSetup.tsx new file mode 100644 index 0000000..306ff14 --- /dev/null +++ b/src/BiometricSetup.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { BiometricAuth } from '@aparajita/capacitor-biometric-auth'; + +interface Props { + /** Called with whether biometric unlock should be enabled going forward. */ + onDone: (enabled: boolean) => void; +} + +/** + * Shown once, right after a successful server login, while biometry is + * available on the device. Confirms the user can actually authenticate + * before turning the lock on. + */ +export default function BiometricSetup({ onDone }: Props) { + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleEnable() { + setError(''); + setLoading(true); + try { + await BiometricAuth.authenticate({ + reason: 'Confirm to enable biometric unlock', + cancelTitle: 'Cancel', + allowDeviceCredential: true, + androidTitle: 'Enable biometric unlock', + }); + onDone(true); + } catch { + setError('Could not verify your identity. You can try again or skip for now.'); + } finally { + setLoading(false); + } + } + + return ( +
+
+ + +

Enable biometric unlock?

+

+ Use your fingerprint or face to quickly unlock Bill Tracker the next time you open the + app. +

+ + {error &&

{error}

} + + + +
+
+ ); +} diff --git a/src/LoginScreen.tsx b/src/LoginScreen.tsx new file mode 100644 index 0000000..53dee4a --- /dev/null +++ b/src/LoginScreen.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react'; + +interface Props { + serverUrl: string; + /** Called once the backend has confirmed the credentials and set a session cookie. */ + onSuccess: () => void; + onBack: () => void; +} + +type Stage = 'credentials' | 'totp' | 'webauthn'; + +interface ApiError { + message?: string; +} + +async function postJson(url: string, body: unknown): Promise<{ ok: boolean; data: any }> { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + return { ok: res.ok, data }; +} + +export default function LoginScreen({ serverUrl, onSuccess, onBack }: Props) { + const [stage, setStage] = useState('credentials'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [challengeToken, setChallengeToken] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + function handleKey(e: React.KeyboardEvent) { + if (e.key !== 'Enter') return; + if (stage === 'credentials') handleLogin(); + if (stage === 'totp') handleTotp(); + } + + async function handleLogin() { + if (!username.trim() || !password) { + setError('Enter your username and password.'); + return; + } + setError(''); + setLoading(true); + try { + const { ok, data } = await postJson(`${serverUrl}/api/auth/login`, { + username: username.trim(), + password, + }); + + if (!ok) { + setError((data as ApiError)?.message || 'Invalid username or password.'); + return; + } + + if (data.requires_totp) { + setChallengeToken(data.challenge_token); + setStage('totp'); + return; + } + + if (data.requires_webauthn) { + setStage('webauthn'); + return; + } + + onSuccess(); + } catch { + setError('Could not reach the server. Check the URL and your connection.'); + } finally { + setLoading(false); + } + } + + async function handleTotp() { + if (!code.trim()) { + setError('Enter your authenticator code.'); + return; + } + setError(''); + setLoading(true); + try { + const { ok, data } = await postJson(`${serverUrl}/api/auth/totp/challenge`, { + challenge_token: challengeToken, + code: code.trim(), + }); + + if (!ok) { + setError((data as ApiError)?.message || 'Invalid authenticator code.'); + return; + } + + onSuccess(); + } catch { + setError('Could not reach the server. Check the URL and your connection.'); + } finally { + setLoading(false); + } + } + + if (stage === 'webauthn') { + return ( +
+
+

Security key required

+

+ This account signs in with a security key. Continue to sign in through the server's + web interface. +

+ + +
+
+ ); + } + + if (stage === 'totp') { + return ( +
+
+

Two-factor code

+

Enter the code from your authenticator app.

+ +
+ + { setCode(e.target.value); setError(''); }} + onKeyDown={handleKey} + disabled={loading} + autoFocus + /> + {error &&

{error}

} +
+ + + +
+
+ ); + } + + return ( +
+
+

Sign in

+

Sign in to {serverUrl}

+ +
+ + { setUsername(e.target.value); setError(''); }} + onKeyDown={handleKey} + disabled={loading} + autoFocus + /> +
+ +
+ + { setPassword(e.target.value); setError(''); }} + onKeyDown={handleKey} + disabled={loading} + /> + {error &&

{error}

} +
+ + + +
+
+ ); +} diff --git a/src/SetupScreen.tsx b/src/SetupScreen.tsx index 58bd94b..8c78b9c 100644 --- a/src/SetupScreen.tsx +++ b/src/SetupScreen.tsx @@ -84,7 +84,7 @@ export default function SetupScreen({ onConnect, onLocalMode }: Props) { onClick={handleConnect} disabled={connecting || !url.trim()} > - {connecting ? 'Connecting…' : 'Connect to Server'} + {connecting ? 'Connecting…' : 'Continue'}