Mobile-BillTracker/src/LoginScreen.tsx

186 lines
5.7 KiB
TypeScript

import { useState } from 'react';
import { login, totpChallenge } from './api';
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';
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);
// api.login normalizes network/timeout/429/5xx into res.error.
const res = await login(serverUrl, username.trim(), password);
setLoading(false);
if (!res.ok) {
setError(res.error || 'Invalid username or password.');
return;
}
const data = res.data;
if (data?.requires_totp) {
setChallengeToken(data.challenge_token || '');
setStage('totp');
return;
}
if (data?.requires_webauthn) {
setStage('webauthn');
return;
}
onSuccess();
}
async function handleTotp() {
if (!code.trim()) {
setError('Enter your authenticator code.');
return;
}
setError('');
setLoading(true);
// totpChallenge fetches + echoes the CSRF token (the challenge endpoint,
// unlike /login, is not CSRF-exempt).
const res = await totpChallenge(serverUrl, challengeToken, code.trim());
setLoading(false);
if (!res.ok) {
setError(res.error || 'Invalid authenticator code.');
return;
}
onSuccess();
}
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.includes('127.0.0.1') ? 'this device' : 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>
);
}