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
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
build-output/
|
||||||
|
|
||||||
# Private project/agent docs — never commit
|
# Private project/agent docs — never commit
|
||||||
PROJECT.md
|
PROJECT.md
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,15 @@ const config: CapacitorConfig = {
|
||||||
// in the system browser instead of the app's WebView.
|
// in the system browser instead of the app's WebView.
|
||||||
allowNavigation: ['127.0.0.1'],
|
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;
|
export default config;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "bill-tracker-mobile",
|
"name": "bill-tracker-mobile",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aparajita/capacitor-biometric-auth": "^10.0.0",
|
||||||
"@capacitor/android": "^8.4.0",
|
"@capacitor/android": "^8.4.0",
|
||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/core": "^8.4.0",
|
"@capacitor/core": "^8.4.0",
|
||||||
|
|
@ -28,6 +29,21 @@
|
||||||
"vite": "^8.0.0"
|
"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": {
|
"node_modules/@capacitor/android": {
|
||||||
"version": "8.4.0",
|
"version": "8.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.0.tgz",
|
"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"
|
"android:run": "npm run sync && npm run sync:server && npm run sync:android-assets && npx cap run android --no-sync"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aparajita/capacitor-biometric-auth": "^10.0.0",
|
||||||
"@capacitor/android": "^8.4.0",
|
"@capacitor/android": "^8.4.0",
|
||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/core": "^8.4.0",
|
"@capacitor/core": "^8.4.0",
|
||||||
|
|
@ -31,4 +32,4 @@
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ rm -rf "$DEST/db/node_modules"
|
||||||
mkdir -p "$DEST/docs"
|
mkdir -p "$DEST/docs"
|
||||||
cp "$REPO_ROOT/docs/advisory_non_bill_transaction_filters_us_ms_5000.json" "$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/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
|
# nodejs-mobile-cordova 0.4.3 embeds Node 12.19, which doesn't support
|
||||||
# optional chaining (?.) / nullish coalescing (??) used in this project and
|
# 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"
|
node "$MOBILE_DIR/scripts/transpile-node12.js" "$NODE_MODULES"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Done. Next: cd \"$MOBILE_DIR/nodejs-assets/nodejs-project\" && npm install"
|
echo "Done. Next: cd \"$MOBILE_DIR/nodejs-assets/nodejs-project\" && npm install"
|
||||||
|
|
|
||||||
110
src/App.tsx
110
src/App.tsx
|
|
@ -1,50 +1,110 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Preferences } from '@capacitor/preferences';
|
import { Preferences } from '@capacitor/preferences';
|
||||||
|
import { BiometricAuth } from '@aparajita/capacitor-biometric-auth';
|
||||||
import SetupScreen from './SetupScreen';
|
import SetupScreen from './SetupScreen';
|
||||||
import LoadingScreen from './LoadingScreen';
|
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 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() {
|
export default function App() {
|
||||||
const [ready, setReady] = useState(false);
|
const [stage, setStage] = useState<Stage>('checking');
|
||||||
const [localMode, setLocalMode] = useState(false);
|
const [serverUrl, setServerUrl] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Preferences.get({ key: 'serverUrl' }).then(({ value }) => {
|
Preferences.get({ key: SERVER_URL_KEY }).then(async ({ value }) => {
|
||||||
if (value === 'local') {
|
if (value === 'local') {
|
||||||
setLocalMode(true);
|
setStage('local');
|
||||||
} else if (value) {
|
return;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
useEffect(() => {
|
||||||
await Preferences.set({ key: 'serverUrl', value: url });
|
if (stage === 'redirecting') {
|
||||||
window.location.replace(url);
|
window.location.replace(serverUrl);
|
||||||
|
}
|
||||||
|
}, [stage, serverUrl]);
|
||||||
|
|
||||||
|
function handleConnect(url: string) {
|
||||||
|
setServerUrl(url);
|
||||||
|
setStage('login');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLocalMode() {
|
async function handleLocalMode() {
|
||||||
await Preferences.set({ key: 'serverUrl', value: 'local' });
|
await Preferences.set({ key: SERVER_URL_KEY, value: 'local' });
|
||||||
setLocalMode(true);
|
setStage('local');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localMode) {
|
async function handleLoginSuccess() {
|
||||||
return <LoadingScreen onReady={() => window.location.replace(LOCAL_URL)} />;
|
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) {
|
async function handleBiometricSetupDone(enabled: boolean) {
|
||||||
// Brief blank while we check preferences before redirecting
|
await Preferences.set({ key: BIOMETRIC_KEY, value: enabled ? 'true' : 'false' });
|
||||||
return (
|
setStage('redirecting');
|
||||||
<div style={{ background: '#09090b', height: '100dvh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
||||||
<div className="spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
onClick={handleConnect}
|
||||||
disabled={connecting || !url.trim()}
|
disabled={connecting || !url.trim()}
|
||||||
>
|
>
|
||||||
{connecting ? 'Connecting…' : 'Connect to Server'}
|
{connecting ? 'Connecting…' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="divider">
|
<div className="divider">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue