whisper-money/resources/js/components/onboarding/step-encryption-setup.tsx

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' };
}