veza/apps/web/src/features/settings/components/AccountSettings.tsx

362 lines
12 KiB
TypeScript

import { useState, useRef } from 'react';
import { TwoFactorSettings } from './TwoFactorSettings';
import { useAuthStore } from '@/features/auth/store/authStore';
import { apiClient } from '@/services/api/client';
import { parseApiError } from '@/utils/apiErrorHandler';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useToast } from '@/hooks/useToast';
import { AlertCircle, Trash2, Key, Download } from 'lucide-react';
import { Dialog } from '@/components/ui/dialog';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
// FE-PAGE-004: Complete Settings page implementation - Account Settings
export function AccountSettings() {
const { logout } = useAuthStore();
const toast = useToast();
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
// Password change form
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
// Account deletion form
const [deletePassword, setDeletePassword] = useState('');
const [deleteReason, setDeleteReason] = useState('');
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [deleteValidationError, setDeleteValidationError] = useState<
string | null
>(null);
const [mutationError, setMutationError] = useState<Error | null>(null);
const [retryCount, setRetryCount] = useState(0);
const lastMutationRef = useRef<(() => Promise<void>) | null>(null);
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordError('');
if (newPassword !== confirmPassword) {
setPasswordError('New passwords do not match');
return;
}
if (newPassword.length < 12) {
setPasswordError('Password must be at least 12 characters long');
return;
}
// Action 3.4.1.3: Store mutation for retry
const performMutation = async () => {
await apiClient.put('/users/me/password', {
current_password: currentPassword,
new_password: newPassword,
});
toast.success('Password changed successfully');
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setMutationError(null);
setRetryCount(0);
lastMutationRef.current = null;
};
lastMutationRef.current = performMutation;
setIsChangingPassword(true);
try {
await performMutation();
} catch (error: unknown) {
const apiError = parseApiError(error);
setPasswordError(apiError.message);
setMutationError(new Error(apiError.message));
} finally {
setIsChangingPassword(false);
}
};
const handleDeleteAccount = async () => {
setDeleteValidationError(null);
setMutationError(null);
if (deleteConfirmText !== 'DELETE') {
setDeleteValidationError('Please type DELETE to confirm');
return;
}
try {
setIsDeletingAccount(true);
await apiClient.delete('/users/me', {
data: {
password: deletePassword,
reason: deleteReason,
confirm_text: deleteConfirmText,
},
});
toast.success('Account deletion requested. You will be logged out.');
setTimeout(() => {
logout();
window.location.href = '/login';
}, 2000);
} catch (error: unknown) {
const apiError = parseApiError(error);
setMutationError(new Error(apiError.message));
} finally {
setIsDeletingAccount(false);
setIsDeleteDialogOpen(false);
}
};
const handleExportData = async () => {
// Action 3.4.1.3: Store mutation for retry
const performMutation = async () => {
const response = await apiClient.get('/users/me/export', {
responseType: 'blob',
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute(
'download',
`veza-data-export-${new Date().toISOString()}.json`,
);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
toast.success('Data export started');
setMutationError(null);
setRetryCount(0);
lastMutationRef.current = null;
};
lastMutationRef.current = performMutation;
try {
await performMutation();
} catch (error: unknown) {
const apiError = parseApiError(error);
setMutationError(new Error(apiError.message));
}
};
// Action 3.4.1.3: Retry handler for failed mutations
const handleRetry = async () => {
if (!lastMutationRef.current || retryCount >= 3) return;
setRetryCount((prev) => prev + 1);
try {
await lastMutationRef.current();
} catch (error) {
// Error will be handled by the mutation function
}
};
return (
<div className="space-y-6">
{mutationError && (
<ErrorDisplay
error={mutationError}
variant="banner"
severity="error"
context={{
action: 'updating account settings',
resource: 'account',
}}
onRetry={retryCount < 3 ? handleRetry : undefined}
onDismiss={() => {
setMutationError(null);
setRetryCount(0);
lastMutationRef.current = null;
}}
/>
)}
{/* FE-PAGE-004: Change Password Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Change Password
</CardTitle>
<CardDescription>
Update your password to keep your account secure
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={12}
/>
<p className="text-xs text-muted-foreground">
Password must be at least 12 characters long
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={12}
/>
</div>
{passwordError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{passwordError}</AlertDescription>
</Alert>
)}
<Button type="submit" disabled={isChangingPassword}>
{isChangingPassword ? 'Changing...' : 'Change Password'}
</Button>
</form>
</CardContent>
</Card>
<TwoFactorSettings />
{/* FE-PAGE-004: Data Export Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Data Export
</CardTitle>
<CardDescription>Download a copy of your data (GDPR)</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={handleExportData}>
<Download className="mr-2 h-4 w-4" />
Export My Data
</Button>
</CardContent>
</Card>
{/* FE-PAGE-004: Delete Account Section */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="h-5 w-5" />
Delete Account
</CardTitle>
<CardDescription>
Permanently delete your account and all associated data
</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This action cannot be undone. All your data will be permanently
deleted.
</AlertDescription>
</Alert>
<Dialog
open={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
title="Are you absolutely sure?"
variant="alert"
onConfirm={handleDeleteAccount}
confirmLabel={isDeletingAccount ? 'Deleting...' : 'Delete Account'}
cancelLabel="Cancel"
size="lg"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
This will permanently delete your account and all associated
data. This action cannot be undone.
</p>
<div className="space-y-2">
<Label htmlFor="delete-password">Enter your password</Label>
<Input
id="delete-password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="delete-reason">Reason (optional)</Label>
<Input
id="delete-reason"
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
placeholder="Why are you deleting your account?"
/>
</div>
{deleteValidationError && (
<ErrorDisplay
error={deleteValidationError}
variant="inline"
severity="error"
size="sm"
dismissible={false}
/>
)}
<div className="space-y-2">
<Label htmlFor="delete-confirm">
Type <strong>DELETE</strong> to confirm
</Label>
<Input
id="delete-confirm"
value={deleteConfirmText}
onChange={(e) => {
setDeleteConfirmText(e.target.value);
setDeleteValidationError(null);
}}
required
placeholder="DELETE"
/>
</div>
</div>
</Dialog>
<Button
variant="destructive"
className="mt-4"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Account
</Button>
</CardContent>
</Card>
</div>
);
}