feat: server-mode login, biometric unlock, CapacitorHttp native auth (batch 4.5)

This commit is contained in:
null 2026-06-14 18:08:01 -05:00
parent 5a8135fcd0
commit d55e73120c
10 changed files with 470 additions and 28 deletions

4
.gitignore vendored
View File

@ -14,6 +14,10 @@ Thumbs.db
# IDE
.vscode/
.idea/
*.code-workspace
# Build output
build-output/
# Private project/agent docs — never commit
PROJECT.md

View File

@ -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;

16
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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

View File

@ -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<Stage>('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 <LoadingScreen onReady={() => 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 (
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" />
</div>
);
async function handleBiometricSetupDone(enabled: boolean) {
await Preferences.set({ key: BIOMETRIC_KEY, value: enabled ? 'true' : 'false' });
setStage('redirecting');
}
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
async function handleBiometricFallback() {
await Preferences.set({ key: BIOMETRIC_KEY, value: 'false' });
setStage('login');
}
switch (stage) {
case 'local':
return <LoadingScreen onReady={() => window.location.replace(LOCAL_URL)} />;
case 'setup':
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
case 'login':
return (
<LoginScreen
serverUrl={serverUrl}
onSuccess={handleLoginSuccess}
onBack={() => setStage('setup')}
/>
);
case 'biometric-setup':
return <BiometricSetup onDone={handleBiometricSetupDone} />;
case 'biometric-lock':
return <BiometricLock onUnlocked={() => setStage('redirecting')} onFallback={handleBiometricFallback} />;
default:
// 'checking' / 'redirecting' — brief blank while we check preferences
// or hand off to the WebView.
return (
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" />
</div>
);
}
}

67
src/BiometricLock.tsx Normal file
View File

@ -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 (
<div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}>
{checking ? (
<div className="spinner" />
) : (
<>
<h1 className="setup-title">Locked</h1>
<p className="setup-subtitle">{error || 'Authenticate to continue.'}</p>
<button className="btn-primary" onClick={attempt} style={{ width: '100%' }}>
Try Again
</button>
<button className="btn-secondary" onClick={onFallback} style={{ width: '100%' }}>
Sign In Instead
</button>
</>
)}
</div>
</div>
);
}

69
src/BiometricSetup.tsx Normal file
View File

@ -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 (
<div className="setup-root">
<div className="setup-card" style={{ alignItems: 'center', textAlign: 'center' }}>
<div className="logo-mark" aria-hidden="true">
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="10" fill="#6366f1" fillOpacity="0.15" />
<path
d="M20 12a6 6 0 0 0-6 6v2a6 6 0 0 0 .5 2.4M20 12a6 6 0 0 1 6 6v2c0 3-1 5-2 6.5M14 20v2c0 2.5.7 4.3 1.8 5.8M26 20v2c0 1.3-.2 2.6-.6 3.8M17.5 27.5c.8.9 1.8 1.7 2.9 2.2M11 14a9 9 0 0 1 18 0"
stroke="#6366f1"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h1 className="setup-title">Enable biometric unlock?</h1>
<p className="setup-subtitle">
Use your fingerprint or face to quickly unlock Bill Tracker the next time you open the
app.
</p>
{error && <p className="form-error">{error}</p>}
<button className="btn-primary" onClick={handleEnable} disabled={loading} style={{ width: '100%' }}>
{loading ? 'Verifying…' : 'Enable Biometric Unlock'}
</button>
<button className="btn-secondary" onClick={() => onDone(false)} disabled={loading} style={{ width: '100%' }}>
Not Now
</button>
</div>
</div>
);
}

215
src/LoginScreen.tsx Normal file
View File

@ -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<Stage>('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 (
<div className="setup-root">
<div className="setup-card">
<h1 className="setup-title">Security key required</h1>
<p className="setup-subtitle">
This account signs in with a security key. Continue to sign in through the server's
web interface.
</p>
<button className="btn-primary" onClick={onSuccess}>
Continue
</button>
<button className="btn-secondary" onClick={onBack}>
Back
</button>
</div>
</div>
);
}
if (stage === 'totp') {
return (
<div className="setup-root">
<div className="setup-card">
<h1 className="setup-title">Two-factor code</h1>
<p className="setup-subtitle">Enter the code from your authenticator app.</p>
<div className="form-group">
<label className="form-label" htmlFor="totp-code">Code</label>
<input
id="totp-code"
className="form-input"
type="text"
inputMode="numeric"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="123456"
value={code}
onChange={e => { setCode(e.target.value); setError(''); }}
onKeyDown={handleKey}
disabled={loading}
autoFocus
/>
{error && <p className="form-error">{error}</p>}
</div>
<button className="btn-primary" onClick={handleTotp} disabled={loading || !code.trim()}>
{loading ? 'Verifying…' : 'Verify'}
</button>
<button className="btn-secondary" onClick={onBack} disabled={loading}>
Back
</button>
</div>
</div>
);
}
return (
<div className="setup-root">
<div className="setup-card">
<h1 className="setup-title">Sign in</h1>
<p className="setup-subtitle">Sign in to {serverUrl}</p>
<div className="form-group">
<label className="form-label" htmlFor="login-username">Username</label>
<input
id="login-username"
className="form-input"
type="text"
inputMode="text"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="Username"
value={username}
onChange={e => { setUsername(e.target.value); setError(''); }}
onKeyDown={handleKey}
disabled={loading}
autoFocus
/>
</div>
<div className="form-group">
<label className="form-label" htmlFor="login-password">Password</label>
<input
id="login-password"
className="form-input"
type="password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="Password"
value={password}
onChange={e => { setPassword(e.target.value); setError(''); }}
onKeyDown={handleKey}
disabled={loading}
/>
{error && <p className="form-error">{error}</p>}
</div>
<button className="btn-primary" onClick={handleLogin} disabled={loading || !username.trim() || !password}>
{loading ? 'Signing in…' : 'Sign In'}
</button>
<button className="btn-secondary" onClick={onBack} disabled={loading}>
Back
</button>
</div>
</div>
);
}

View File

@ -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'}
</button>
<div className="divider">