Files
authenticator/src/components/TotpForm.tsx
2025-06-07 23:20:49 +08:00

253 lines
8.8 KiB
TypeScript

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { addAccount, type TotpAccount } from '../services/db';
import { parseOtpAuthUri } from '../services/uriParser';
export function TotpForm() {
const navigate = useNavigate();
const [name, setName] = useState('');
const [issuer, setIssuer] = useState('');
const [secret, setSecret] = useState('');
const [digits, setDigits] = useState<6 | 7 | 8>(6);
const [algorithm, setAlgorithm] = useState<'SHA1' | 'SHA256' | 'SHA512'>('SHA1');
const [period, setPeriod] = useState(30);
const [uri, setUri] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const isFormDisabled = uri.startsWith('otpauth://');
const isUriDisabled = secret.length > 0;
const handleUriChange = (value: string) => {
const trimmedValue = value.trim();
setUri(trimmedValue);
if (trimmedValue.startsWith('otpauth://')) {
try {
const parsed = parseOtpAuthUri(trimmedValue);
setName(parsed.name || '');
setIssuer(parsed.issuer || '');
setSecret(parsed.secret || '');
setAlgorithm(parsed.algorithm || 'SHA1');
setDigits(parsed.digits || 6);
setPeriod(parsed.period || 30);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Invalid URI');
// Clear form if URI is invalid
setName('');
setIssuer('');
setSecret('');
}
} else if (trimmedValue === '') {
// Clear form if URI is cleared
setName('');
setIssuer('');
setSecret('');
setError(null);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !secret) {
setError('Name and Secret Key are required.');
return;
}
setError(null);
setIsSubmitting(true);
const newAccount: Omit<TotpAccount, 'id' | 'createdAt'> = {
name,
issuer,
secret,
digits,
period,
algorithm,
};
try {
await addAccount({ ...newAccount, createdAt: new Date() });
navigate('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save the account.');
setIsSubmitting(false);
}
};
const baseInputStyles = "block w-full px-1 py-2 bg-transparent border-0 border-b-2 border-slate-300 dark:border-slate-600 focus:outline-none focus:ring-0 focus:border-indigo-500 transition-colors text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500";
const disabledInputStyles = "disabled:opacity-50 disabled:cursor-not-allowed disabled:border-slate-200 dark:disabled:border-slate-700";
const inputStyles = `${baseInputStyles} ${disabledInputStyles}`;
const selectStyles = `${inputStyles} dark:[color-scheme:dark]`;
return (
<div className="space-y-8">
{/* URI Input Card */}
<div className="bg-white dark:bg-slate-900 rounded-xl p-8 shadow-fluent-md">
<div className="space-y-6">
<div>
<label htmlFor="uri" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Add with otpauth:// URI
</label>
<div className="mt-2">
<textarea
id="uri"
name="uri"
rows={3}
value={uri}
onChange={(e) => handleUriChange(e.target.value)}
disabled={isUriDisabled}
className={inputStyles}
placeholder="Paste your otpauth:// URI here"
/>
</div>
</div>
</div>
</div>
{/* Separator */}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-slate-300 dark:border-slate-700" />
</div>
<div className="relative flex justify-center">
<span className="bg-slate-100 dark:bg-slate-800 px-2 text-sm text-slate-500">OR</span>
</div>
</div>
{/* Manual Form Card */}
<div className="bg-white dark:bg-slate-900 rounded-xl p-8 shadow-fluent-md">
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Account Name
</label>
<div className="mt-2">
<input
id="name"
name="name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isFormDisabled}
className={inputStyles}
placeholder="e.g., GitHub"
/>
</div>
</div>
<div>
<label htmlFor="issuer" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Issuer (Optional)
</label>
<div className="mt-2">
<input
id="issuer"
name="issuer"
type="text"
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
disabled={isFormDisabled}
className={inputStyles}
placeholder="e.g., user@example.com"
/>
</div>
</div>
<div>
<label htmlFor="secret" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Secret Key
</label>
<div className="mt-2">
<input
id="secret"
name="secret"
type="text"
required
value={secret}
onChange={(e) => setSecret(e.target.value)}
disabled={isFormDisabled}
className={inputStyles}
placeholder="Enter your secret key"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-3">
<div>
<label htmlFor="digits" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Digits
</label>
<div className="mt-2">
<select
id="digits"
name="digits"
value={digits}
onChange={(e) => setDigits(Number(e.target.value) as 6 | 7 | 8)}
disabled={isFormDisabled}
className={selectStyles}
>
<option>6</option>
<option>7</option>
<option>8</option>
</select>
</div>
</div>
<div>
<label htmlFor="period" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Period (seconds)
</label>
<div className="mt-2">
<input
id="period"
name="period"
type="number"
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
disabled={isFormDisabled}
className={inputStyles}
placeholder="30"
/>
</div>
</div>
<div>
<label htmlFor="algorithm" className="block text-sm font-medium text-slate-700 dark:text-slate-300">
Algorithm
</label>
<div className="mt-2">
<select
id="algorithm"
name="algorithm"
value={algorithm}
onChange={(e) => setAlgorithm(e.target.value as 'SHA1' | 'SHA256' | 'SHA512')}
disabled={isFormDisabled}
className={selectStyles}
>
<option>SHA1</option>
<option>SHA256</option>
<option>SHA512</option>
</select>
</div>
</div>
</div>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex w-full justify-center rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-fluent-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-150 active:scale-[0.98]"
>
{isSubmitting ? 'Saving...' : 'Save Account'}
</button>
</div>
</form>
</div>
</div>
);
}