253 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|