feat: server-mode login, biometric unlock, CapacitorHttp native auth (batch 4.5)
This commit is contained in:
parent
5a8135fcd0
commit
d55e73120c
|
|
@ -14,6 +14,10 @@ Thumbs.db
|
|||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.code-workspace
|
||||
|
||||
# Build output
|
||||
build-output/
|
||||
|
||||
# Private project/agent docs — never commit
|
||||
PROJECT.md
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
102
src/App.tsx
102
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<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) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBiometricSetupDone(enabled: boolean) {
|
||||
await Preferences.set({ key: BIOMETRIC_KEY, value: enabled ? 'true' : 'false' });
|
||||
setStage('redirecting');
|
||||
}
|
||||
|
||||
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)} />;
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
// Brief blank while we check preferences before redirecting
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return <SetupScreen onConnect={handleConnect} onLocalMode={handleLocalMode} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue