242 lines
9.1 KiB
TypeScript
242 lines
9.1 KiB
TypeScript
import { StepHeader } from '@/components/onboarding/step-header';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
|
import {
|
|
bufferToBase64,
|
|
encrypt,
|
|
exportKey,
|
|
generateSalt,
|
|
getAESKeyFromPBKDF,
|
|
getKeyFromPassword,
|
|
} from '@/lib/crypto';
|
|
import { storeKey } from '@/lib/key-storage';
|
|
import { __ } from '@/utils/i18n';
|
|
import axios from 'axios';
|
|
import { AlertCircle, CheckCircle2, KeyRound } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { StepButton } from './step-button';
|
|
|
|
interface StepEncryptionSetupProps {
|
|
onComplete: () => void;
|
|
}
|
|
|
|
export function StepEncryptionSetup({ onComplete }: StepEncryptionSetupProps) {
|
|
const { refreshKeyState } = useEncryptionKey();
|
|
const [password, setPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [storagePreference, setStoragePreference] = useState<
|
|
'session' | 'persistent'
|
|
>('session');
|
|
const [processing, setProcessing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const passwordStrength = getPasswordStrength(password);
|
|
|
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
|
|
if (password.length < 12) {
|
|
setError(__('Password must be at least 12 characters'));
|
|
return;
|
|
}
|
|
|
|
if (password !== confirmPassword) {
|
|
setError(__('Passwords do not match'));
|
|
return;
|
|
}
|
|
|
|
setProcessing(true);
|
|
|
|
try {
|
|
const salt = generateSalt();
|
|
const pbkdfKey = await getKeyFromPassword(password);
|
|
const aesKey = await getAESKeyFromPBKDF(pbkdfKey, salt);
|
|
const { encrypted, iv } = await encrypt('Hello, world', aesKey);
|
|
const exportedKey = await exportKey(aesKey);
|
|
|
|
await axios.post('/api/encryption/setup', {
|
|
salt: bufferToBase64(salt),
|
|
encrypted_content: encrypted,
|
|
iv: iv,
|
|
});
|
|
|
|
storeKey(exportedKey, storagePreference === 'persistent');
|
|
refreshKeyState();
|
|
onComplete();
|
|
} catch (err) {
|
|
console.error('Encryption setup error:', err);
|
|
setError(__('Failed to setup encryption. Please try again.'));
|
|
setProcessing(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex animate-in flex-col items-center duration-500 fade-in slide-in-from-bottom-4">
|
|
<StepHeader
|
|
icon={KeyRound}
|
|
iconContainerClassName="bg-gradient-to-br from-amber-400 to-orange-500"
|
|
title={__('Create Your Encryption Password')}
|
|
description={__(
|
|
"This password will encrypt all your financial data. Make it strong and memorable \u2014 we can't recover it for you.",
|
|
)}
|
|
/>
|
|
|
|
<form onSubmit={handleSubmit} className="w-full max-w-md space-y-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">
|
|
{__('Encryption Password')}
|
|
</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder={__('Enter a strong password')}
|
|
disabled={processing}
|
|
autoComplete="new-password"
|
|
required
|
|
minLength={12}
|
|
/>
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex flex-1 items-center gap-2">
|
|
<div className="flex flex-1 gap-1">
|
|
{[1, 2, 3, 4].map((level) => (
|
|
<div
|
|
key={level}
|
|
className={`h-1.5 flex-1 rounded-full transition-colors ${
|
|
level <= passwordStrength.level
|
|
? passwordStrength.color
|
|
: 'bg-muted'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{__(passwordStrength.label)}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className={`text-xs ${password.length >= 12 ? 'text-emerald-600' : 'text-muted-foreground'}`}
|
|
>
|
|
{password.length}
|
|
{__('/12 min')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirmPassword">
|
|
{__('Confirm Password')}
|
|
</Label>
|
|
<Input
|
|
id="confirmPassword"
|
|
type="password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
placeholder={__('Confirm your password')}
|
|
disabled={processing}
|
|
autoComplete="new-password"
|
|
required
|
|
/>
|
|
|
|
{confirmPassword && password === confirmPassword && (
|
|
<div className="flex items-center gap-1 text-xs text-emerald-600">
|
|
<CheckCircle2 className="h-3 w-3" />
|
|
{__('Passwords match')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="storagePreference">
|
|
{__('Key Storage')}
|
|
</Label>
|
|
<Select
|
|
value={storagePreference}
|
|
onValueChange={(value) =>
|
|
setStoragePreference(
|
|
value as 'session' | 'persistent',
|
|
)
|
|
}
|
|
disabled={processing}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="session">
|
|
{__('Session only (more secure)')}
|
|
</SelectItem>
|
|
<SelectItem value="persistent">
|
|
{__('Keep me logged in (convenient)')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-xs text-muted-foreground">
|
|
{storagePreference === 'session'
|
|
? __(
|
|
'Your key will be cleared when you close the browser.',
|
|
)
|
|
: __('Your key will be stored until you log out.')}
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<StepButton
|
|
type="submit"
|
|
disabled={processing || password.length < 12}
|
|
loading={processing}
|
|
loadingText={__('Setting up encryption...')}
|
|
text={__('Setup Encryption')}
|
|
className="w-full sm:w-full"
|
|
/>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getPasswordStrength(password: string): {
|
|
level: number;
|
|
label: string;
|
|
color: string;
|
|
} {
|
|
if (!password) {
|
|
return { level: 0, label: '', color: 'bg-muted' };
|
|
}
|
|
|
|
let score = 0;
|
|
|
|
if (password.length >= 6) score++;
|
|
if (password.length >= 12) score++;
|
|
if (/[A-Z]/.test(password) || /[a-z]/.test(password)) score++;
|
|
if (/\d/.test(password)) score++;
|
|
if (/[0-9]/.test(password)) score++;
|
|
|
|
if (score <= 1) {
|
|
return { level: 1, label: 'Weak', color: 'bg-red-500' };
|
|
}
|
|
if (score === 2) {
|
|
return { level: 2, label: 'Fair', color: 'bg-orange-500' };
|
|
}
|
|
if (score === 3) {
|
|
return { level: 3, label: 'Good', color: 'bg-yellow-500' };
|
|
}
|
|
return { level: 4, label: 'Strong', color: 'bg-emerald-500' };
|
|
}
|